HCE Savepoint

This is HCE before companion merge
This commit is contained in:
Taveon Nelson 2026-05-26 03:36:45 -05:00
parent 04ea584c4f
commit 6c257b2080
66 changed files with 1328 additions and 2995 deletions

View File

@ -73,12 +73,8 @@ protobuf {
dependencies { dependencies {
implementation(libs.androidx.room.runtime) implementation(libs.androidx.room.runtime)
implementation(libs.vb.nfc.reader) implementation(libs.vb.nfc.reader)
// Temporarily commented out due to Lombok compilation issues in VB-DIM-Reader implementation(libs.dim.reader)
// implementation(libs.dim.reader) implementation(libs.androidx.core.ktx)
implementation(files("../../VB-DIM-Reader-2.0.0/build/libs/VB-DIM-Reader.jar"))
//VB-DIM-Reader runtime dependency required by the local JAR
implementation("at.favre.lib:bytes:1.5.0")
implementation(libs.androidx.room.ktx) implementation(libs.androidx.room.ktx)
implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.navigation.compose) implementation(libs.androidx.navigation.compose)
@ -94,13 +90,6 @@ dependencies {
implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3) implementation(libs.androidx.material3)
implementation(libs.androidx.compose.material.icons.extended) implementation(libs.androidx.compose.material.icons.extended)
implementation(libs.play.services.wearable)
implementation(libs.kotlinx.coroutines.play.services)
implementation(libs.timber)
implementation(libs.tiny.log)
implementation(libs.tiny.log.impl)
implementation(libs.slf4j.api)
runtimeOnly(libs.slf4j.timber)
debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest) debugImplementation(libs.androidx.ui.test.manifest)
@ -129,7 +118,4 @@ dependencies {
// HTTP request logging // HTTP request logging
implementation("com.squareup.okhttp3:logging-interceptor:4.11.0") implementation("com.squareup.okhttp3:logging-interceptor:4.11.0")
// In-app browser for OAuth login
implementation("androidx.browser:browser:1.8.0")
} }

View File

@ -2,16 +2,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<permission
android:name="com.github.nacabaro.vbhelper.permission.ACCESS_TRANSFER_SEEN"
android:protectionLevel="signature" />
<permission
android:name="com.github.nacabaro.vbhelper.permission.ACCESS_CARD_DATA"
android:protectionLevel="signature" />
<uses-permission android:name="android.permission.NFC" /> <uses-permission android:name="android.permission.NFC" />
<uses-feature android:name="android.hardware.nfc" android:required="true" /> <uses-feature android:name="android.hardware.nfc" android:required="true" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
@ -39,25 +31,6 @@
android:name="android.support.FILE_PROVIDER_PATHS" android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" /> android:resource="@xml/provider_paths" />
</provider> </provider>
<provider
android:name=".transfer.CardImportProvider"
android:authorities="com.github.nacabaro.vbhelper.cardimport"
android:exported="true"
android:readPermission="com.github.nacabaro.vbhelper.permission.ACCESS_CARD_DATA"
android:writePermission="com.github.nacabaro.vbhelper.permission.ACCESS_CARD_DATA" />
<provider
android:name=".transfer.SharedTransferSeenProvider"
android:authorities="com.github.nacabaro.vbhelper.transferseen"
android:exported="true"
android:readPermission="com.github.nacabaro.vbhelper.permission.ACCESS_TRANSFER_SEEN"
android:writePermission="com.github.nacabaro.vbhelper.permission.ACCESS_TRANSFER_SEEN" />
<activity android:name=".companion.card.CompanionImportCardActivity" />
<activity android:name=".companion.card.CompanionValidateCardActivity" />
<activity android:name=".companion.firmware.CompanionFirmwareImportActivity" />
<activity android:name=".companion.logs.CompanionWatchLogsActivity" />
<activity
android:name=".companion.transfer.CompanionCharacterImportActivity"
android:exported="false" />
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
@ -81,17 +54,12 @@
<data android:scheme="http" android:host="localhost" android:port="8080" android:pathPrefix="/authenticate" /> <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" /> <data android:scheme="http" android:host="127.0.0.1" android:port="8080" android:pathPrefix="/authenticate" />
</intent-filter> </intent-filter>
</activity>
<service
android:name=".companion.logs.WatchCommunicationService"
android:exported="true"
android:enabled="true">
<intent-filter> <intent-filter>
<action android:name="com.google.android.gms.wearable.CHANNEL_EVENT" /> <action android:name="android.intent.action.SEND" />
<action android:name="com.google.android.gms.wearable.MESSAGE_RECEIVED" /> <category android:name="android.intent.category.DEFAULT" />
<data android:scheme="wear" android:host="*" /> <data android:mimeType="application/x-vitalwear-character" />
</intent-filter> </intent-filter>
</service> </activity>
</application> </application>
</manifest> </manifest>

View File

@ -1,19 +1,19 @@
package com.github.nacabaro.vbhelper package com.github.nacabaro.vbhelper
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import android.widget.Toast
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.lifecycle.lifecycleScope
import androidx.compose.runtime.mutableStateOf import com.github.cfogrady.vitalwear.protos.Character
import androidx.compose.runtime.setValue
import com.github.nacabaro.vbhelper.navigation.AppNavigation import com.github.nacabaro.vbhelper.navigation.AppNavigation
import com.github.nacabaro.vbhelper.di.VBHelper import com.github.nacabaro.vbhelper.di.VBHelper
import com.github.nacabaro.vbhelper.navigation.AppNavigationHandlers import com.github.nacabaro.vbhelper.navigation.AppNavigationHandlers
import com.github.nacabaro.vbhelper.navigation.NavigationItems
import com.github.nacabaro.vbhelper.screens.homeScreens.HomeScreenControllerImpl import com.github.nacabaro.vbhelper.screens.homeScreens.HomeScreenControllerImpl
import com.github.nacabaro.vbhelper.screens.itemsScreen.ItemsScreenControllerImpl import com.github.nacabaro.vbhelper.screens.itemsScreen.ItemsScreenControllerImpl
import com.github.nacabaro.vbhelper.screens.scanScreen.ScanScreenControllerImpl import com.github.nacabaro.vbhelper.screens.scanScreen.ScanScreenControllerImpl
@ -22,12 +22,15 @@ import com.github.nacabaro.vbhelper.screens.adventureScreen.AdventureScreenContr
import com.github.nacabaro.vbhelper.screens.cardScreen.CardScreenControllerImpl import com.github.nacabaro.vbhelper.screens.cardScreen.CardScreenControllerImpl
import com.github.nacabaro.vbhelper.screens.spriteViewer.SpriteViewerControllerImpl import com.github.nacabaro.vbhelper.screens.spriteViewer.SpriteViewerControllerImpl
import com.github.nacabaro.vbhelper.screens.storageScreen.StorageScreenControllerImpl import com.github.nacabaro.vbhelper.screens.storageScreen.StorageScreenControllerImpl
import com.github.nacabaro.vbhelper.source.VitalWearCharacterImporter
import com.github.nacabaro.vbhelper.ui.theme.VBHelperTheme import com.github.nacabaro.vbhelper.ui.theme.VBHelperTheme
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val onActivityLifecycleListeners = HashMap<String, ActivityLifecycleListener>() private val onActivityLifecycleListeners = HashMap<String, ActivityLifecycleListener>()
private var initialRoute: String? by mutableStateOf(null) private var initialRoute: String? = null
private fun registerActivityLifecycleListener(key: String, activityLifecycleListener: ActivityLifecycleListener) { private fun registerActivityLifecycleListener(key: String, activityLifecycleListener: ActivityLifecycleListener) {
if( onActivityLifecycleListeners[key] != null) { if( onActivityLifecycleListeners[key] != null) {
@ -79,6 +82,7 @@ class MainActivity : ComponentActivity() {
} }
Log.i("MainActivity", "Activity onCreated") Log.i("MainActivity", "Activity onCreated")
handleImportIntent(intent)
} }
override fun onPause() { override fun onPause() {
@ -101,25 +105,62 @@ class MainActivity : ComponentActivity() {
super.onNewIntent(intent) super.onNewIntent(intent)
setIntent(intent) setIntent(intent)
initialRoute = getInitialRouteFromIntent(intent) initialRoute = getInitialRouteFromIntent(intent)
// Optionally, you may want to trigger navigation here if needed
handleImportIntent(intent)
}
private fun handleImportIntent(intent: Intent?) {
val importUri = extractVitalWearImportUri(intent) ?: return
val application = applicationContext as VBHelper
lifecycleScope.launch(Dispatchers.IO) {
val result = runCatching {
contentResolver.openInputStream(importUri)?.use { inputStream ->
val character = Character.parseFrom(inputStream)
VitalWearCharacterImporter(application.container.db).importCharacter(character)
} ?: VitalWearCharacterImporter.ImportResult(
success = false,
message = "VitalWear import file could not be opened."
)
}.getOrElse {
VitalWearCharacterImporter.ImportResult(
success = false,
message = "VitalWear import failed: ${it.message ?: "Unknown error"}"
)
}
runOnUiThread {
Toast.makeText(this@MainActivity, result.message, Toast.LENGTH_LONG).show()
}
}
}
private fun extractVitalWearImportUri(intent: Intent?): Uri? {
if (intent == null) {
return null
}
val isVitalWearImport = intent.type == VITALWEAR_CHARACTER_MIME
if (!isVitalWearImport) {
return null
}
return when (intent.action) {
Intent.ACTION_SEND -> {
@Suppress("DEPRECATION")
intent.getParcelableExtra(Intent.EXTRA_STREAM)
}
Intent.ACTION_VIEW -> intent.data
else -> null
}
} }
private fun getInitialRouteFromIntent(intent: Intent?): String? { private fun getInitialRouteFromIntent(intent: Intent?): String? {
if (intent == null) return null if (intent == null) return null
val data = intent.data val data = intent.data
if (intent.action == Intent.ACTION_VIEW && data != null) { if (intent.action == Intent.ACTION_VIEW && data != null) {
val isAppAuthCallback = data.scheme == "vbhelper" && data.host == "auth" if (data.scheme == "vbhelper" && data.host == "auth") {
val isLocalhostAuthCallback = return "Battle"
(data.scheme == "http" || data.scheme == "https") &&
(data.host == "localhost" || data.host == "127.0.0.1") &&
data.path?.startsWith("/authenticate") == true
val token = data.getQueryParameter("c") ?: data.getQueryParameter("token")
val hasAuthToken = !token.isNullOrEmpty()
if (isAppAuthCallback || isLocalhostAuthCallback || hasAuthToken) {
// BattlesScreen consumes the callback intent and exchanges the single-use token
// for a durable session token. Writing partial auth state here can clear an
// already-valid session token and cause the browser login loop to restart.
return NavigationItems.Battles.route
} }
} }
return null return null
@ -151,4 +192,8 @@ class MainActivity : ComponentActivity() {
initialRoute = initialRoute initialRoute = initialRoute
) )
} }
companion object {
private const val VITALWEAR_CHARACTER_MIME = "application/x-vitalwear-character"
}
} }

View File

@ -5,7 +5,9 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.delay
@Composable @Composable
fun AnimatedSpriteImage( fun AnimatedSpriteImage(
@ -13,9 +15,11 @@ fun AnimatedSpriteImage(
animationType: DigimonAnimationType = DigimonAnimationType.IDLE, animationType: DigimonAnimationType = DigimonAnimationType.IDLE,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
contentScale: ContentScale = ContentScale.Fit, contentScale: ContentScale = ContentScale.Fit,
reloadMappings: Boolean = false,
animationOffset: Long = 0L // New parameter for offsetting animation timing animationOffset: Long = 0L // New parameter for offsetting animation timing
) { ) {
val spriteManager = remember { IndividualSpriteManager() } val context = LocalContext.current
val spriteManager = remember { IndividualSpriteManager(context) }
// Calculate frame offset based on animation offset // Calculate frame offset based on animation offset
// 750ms is the idle animation duration, so we calculate how many frames to offset // 750ms is the idle animation duration, so we calculate how many frames to offset
@ -26,11 +30,18 @@ fun AnimatedSpriteImage(
0 0
} }
val animationStateMachine = remember { DigimonAnimationStateMachine(characterId, frameOffset, animationOffset) } val animationStateMachine = remember { DigimonAnimationStateMachine(characterId, context, frameOffset, animationOffset) }
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
var bitmap by remember { mutableStateOf<android.graphics.Bitmap?>(null) } var bitmap by remember { mutableStateOf<android.graphics.Bitmap?>(null) }
// Reload mappings when reloadMappings parameter changes
LaunchedEffect(reloadMappings) {
if (reloadMappings) {
animationStateMachine.reloadMappings()
}
}
// Start the animation when the component is first created // Start the animation when the component is first created
LaunchedEffect(characterId) { LaunchedEffect(characterId) {
coroutineScope.launch { coroutineScope.launch {

View File

@ -1,10 +1,17 @@
package com.github.nacabaro.vbhelper.battle package com.github.nacabaro.vbhelper.battle
import android.util.Log
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.State
class ArenaBattleSystem { class ArenaBattleSystem {
companion object {
private const val TAG = "ArenaBattleSystem"
}
// Attack phases: 0=Idle, 1=Player attack on player screen, 2=Player attack on opponent screen, // Attack phases: 0=Idle, 1=Player attack on player screen, 2=Player attack on opponent screen,
// 3=Opponent attack on opponent screen, 4=Opponent attack on player screen // 3=Opponent attack on opponent screen, 4=Opponent attack on player screen
private var _attackPhase by mutableStateOf(0) private var _attackPhase by mutableStateOf(0)
@ -37,6 +44,19 @@ class ArenaBattleSystem {
private var _critBarProgress by mutableStateOf(0) private var _critBarProgress by mutableStateOf(0)
val critBarProgress: Int get() = _critBarProgress val critBarProgress: Int get() = _critBarProgress
// Dodge animation states
private var _isDodging by mutableStateOf(false)
val isDodging: Boolean get() = _isDodging
private var _dodgeProgress by mutableStateOf(0f)
val dodgeProgress: Float get() = _dodgeProgress
private var _dodgeDirection by mutableStateOf(1f) // 1f = up, -1f = down
val dodgeDirection: Float get() = _dodgeDirection
private var _isHit by mutableStateOf(false)
val isHit: Boolean get() = _isHit
private var _hitProgress by mutableStateOf(0f) private var _hitProgress by mutableStateOf(0f)
val hitProgress: Float get() = _hitProgress val hitProgress: Float get() = _hitProgress
@ -160,6 +180,10 @@ class ArenaBattleSystem {
_isPlayerAttacking = false _isPlayerAttacking = false
_attackIsHit = false _attackIsHit = false
_currentView = 0 _currentView = 0
_isDodging = false
_dodgeProgress = 0f
_dodgeDirection = 1f
_isHit = false
_hitProgress = 0f _hitProgress = 0f
_isPlayerDodging = false _isPlayerDodging = false
_isOpponentDodging = false _isOpponentDodging = false
@ -191,10 +215,41 @@ class ArenaBattleSystem {
//Log.d(TAG, "Updated crit bar progress: $progress") //Log.d(TAG, "Updated crit bar progress: $progress")
} }
// Dodge animation methods
fun startDodge() {
_isDodging = true
_dodgeProgress = 0f
_dodgeDirection = 1f // Start moving up
}
fun setDodgeProgress(progress: Float) {
_dodgeProgress = progress
}
fun setDodgeDirection(direction: Float) {
_dodgeDirection = direction
}
fun endDodge() {
_isDodging = false
_dodgeProgress = 0f
}
// Hit animation methods
fun startHit() {
_isHit = true
_hitProgress = 0f
}
fun setHitProgress(progress: Float) { fun setHitProgress(progress: Float) {
_hitProgress = progress _hitProgress = progress
} }
fun endHit() {
_isHit = false
_hitProgress = 0f
}
// Player-specific dodge methods // Player-specific dodge methods
fun startPlayerDodge() { fun startPlayerDodge() {
_isPlayerDodging = true _isPlayerDodging = true
@ -290,6 +345,17 @@ class ArenaBattleSystem {
_isOpponentShakeDelayed = false _isOpponentShakeDelayed = false
} }
// Combined method to handle attack result
fun handleAttackResult(isHit: Boolean) {
_attackIsHit = isHit
if (isHit) {
// Player attack hit - opponent gets hit
startOpponentHit()
} else {
// Player attack missed - opponent dodges
startOpponentDodge()
}
}
// Method to handle opponent attack result // Method to handle opponent attack result
fun handleOpponentAttackResult(isHit: Boolean) { fun handleOpponentAttackResult(isHit: Boolean) {

View File

@ -20,7 +20,6 @@ class AuthInterceptor(private val token: String) : Interceptor {
// Use X-Session-Token header (preferred) or Authorization: Bearer // Use X-Session-Token header (preferred) or Authorization: Bearer
val authenticatedRequest = originalRequest.newBuilder() val authenticatedRequest = originalRequest.newBuilder()
.header("X-Session-Token", token) .header("X-Session-Token", token)
.header("Authorization", "Bearer $token")
.build() .build()
// Debug: Log which header is being used (first few chars of token for security) // Debug: Log which header is being used (first few chars of token for security)

View File

@ -1,46 +1,21 @@
package com.github.nacabaro.vbhelper.battle package com.github.nacabaro.vbhelper.battle
import com.google.gson.annotations.SerializedName
data class AdditionalInfo( data class AdditionalInfo(
@SerializedName(value = "avatar", alternate = ["avatar_url"])
val avatar: String? = null, val avatar: String? = null,
@SerializedName(value = "id", alternate = ["user_id"])
val id: Long? = null, val id: Long? = null,
@SerializedName(value = "name", alternate = ["username", "display_name"])
val name: String? = null, val name: String? = null,
val status: String? = null val status: String? = null
) )
data class UserInfo( data class UserInfo(
@SerializedName(value = "userId", alternate = ["user_id", "id"])
val userId: String? = null, val userId: String? = null,
@SerializedName(value = "additionalInfo", alternate = ["additional_info"])
val additionalInfo: AdditionalInfo? = null val additionalInfo: AdditionalInfo? = null
) )
data class AuthenticateResponse( data class AuthenticateResponse(
@SerializedName(value = "success", alternate = ["ok"])
val success: Boolean, val success: Boolean,
val message: String? = null, val message: String? = null,
@SerializedName(value = "userInfo", alternate = ["user_info", "user"])
val userInfo: UserInfo? = null, val userInfo: UserInfo? = null,
@SerializedName(value = "sessionToken", alternate = ["session_token", "token", "session"]) val sessionToken: String? = null
val sessionToken: String? = null,
@SerializedName(value = "userId", alternate = ["user_id"])
val topLevelUserId: String? = null,
@SerializedName(value = "id", alternate = ["uid"])
val topLevelId: Long? = null,
) )
fun AuthenticateResponse.extractSessionToken(): String? {
return sessionToken?.takeIf { it.isNotBlank() }
}
fun AuthenticateResponse.extractUserId(): Long? {
return userInfo?.userId?.toLongOrNull()
?: topLevelUserId?.toLongOrNull()
?: userInfo?.additionalInfo?.id
?: topLevelId
}

View File

@ -4,13 +4,22 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import android.content.Context
import java.io.File
enum class DigimonAnimationType { enum class DigimonAnimationType {
IDLE, IDLE,
IDLE2, IDLE2,
WALK, WALK,
WALK2,
RUN,
RUN2,
WORKOUT,
WORKOUT2,
HAPPY,
SLEEP, SLEEP,
ATTACK ATTACK,
FLEE
} }
data class AnimationState( data class AnimationState(
@ -22,6 +31,7 @@ data class AnimationState(
class DigimonAnimationStateMachine( class DigimonAnimationStateMachine(
private val characterId: String, private val characterId: String,
private val context: Context,
private val initialFrameOffset: Int = 0, // New parameter for offsetting the starting frame private val initialFrameOffset: Int = 0, // New parameter for offsetting the starting frame
private val timingOffset: Long = 0L // New parameter for offsetting the timing private val timingOffset: Long = 0L // New parameter for offsetting the timing
) { ) {
@ -34,13 +44,21 @@ class DigimonAnimationStateMachine(
var isPlaying by mutableStateOf(false) var isPlaying by mutableStateOf(false)
private set private set
// Direct mapping of frame numbers used by current battle animations. // Direct mapping of frame numbers (1-12) to animation types
// This is based on the standard Digimon sprite frame order
private val frameToAnimationType = mapOf( private val frameToAnimationType = mapOf(
1 to DigimonAnimationType.IDLE, 1 to DigimonAnimationType.IDLE,
2 to DigimonAnimationType.IDLE2, 2 to DigimonAnimationType.IDLE2,
3 to DigimonAnimationType.WALK, 3 to DigimonAnimationType.WALK,
4 to DigimonAnimationType.WALK2,
5 to DigimonAnimationType.RUN,
6 to DigimonAnimationType.RUN2,
7 to DigimonAnimationType.WORKOUT,
8 to DigimonAnimationType.WORKOUT2,
9 to DigimonAnimationType.HAPPY,
10 to DigimonAnimationType.SLEEP, 10 to DigimonAnimationType.SLEEP,
11 to DigimonAnimationType.ATTACK 11 to DigimonAnimationType.ATTACK,
12 to DigimonAnimationType.FLEE
) )
// Reverse mapping for getting frame numbers for each animation type // Reverse mapping for getting frame numbers for each animation type
@ -51,8 +69,15 @@ class DigimonAnimationStateMachine(
DigimonAnimationType.IDLE to 750L, DigimonAnimationType.IDLE to 750L,
DigimonAnimationType.IDLE2 to 750L, DigimonAnimationType.IDLE2 to 750L,
DigimonAnimationType.WALK to 200L, DigimonAnimationType.WALK to 200L,
DigimonAnimationType.WALK2 to 200L,
DigimonAnimationType.RUN to 150L,
DigimonAnimationType.RUN2 to 150L,
DigimonAnimationType.WORKOUT to 300L,
DigimonAnimationType.WORKOUT2 to 300L,
DigimonAnimationType.HAPPY to 400L,
DigimonAnimationType.SLEEP to 1500L, DigimonAnimationType.SLEEP to 1500L,
DigimonAnimationType.ATTACK to 650L DigimonAnimationType.ATTACK to 650L,
DigimonAnimationType.FLEE to 150L
) )
/* /*
@ -130,4 +155,13 @@ class DigimonAnimationStateMachine(
return currentFrameNumber return currentFrameNumber
} }
fun getCurrentCharacterId(): String {
return characterId
}
// Method to reload mappings (useful for testing)
fun reloadMappings() {
println("Reloading mappings for character: $characterId")
// No need to reload since we use direct frame mapping
}
} }

View File

@ -10,6 +10,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@ -24,9 +25,10 @@ fun HitEffectOverlay(
) { ) {
if (!isVisible) return if (!isVisible) return
val context = LocalContext.current
val configuration = LocalConfiguration.current val configuration = LocalConfiguration.current
val isLandscapeMode = configuration.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE val isLandscapeMode = configuration.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE
val hitEffectManager = remember { HitEffectSpriteManager() } val hitEffectManager = remember { HitEffectSpriteManager(context) }
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
var currentFrame by remember { mutableStateOf(0) } var currentFrame by remember { mutableStateOf(0) }

View File

@ -1,10 +1,13 @@
package com.github.nacabaro.vbhelper.battle package com.github.nacabaro.vbhelper.battle
import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.graphics.Rect
import android.os.Environment
import java.io.File import java.io.File
class HitEffectSpriteManager { class HitEffectSpriteManager(private val context: Context) {
private val spriteCache = mutableMapOf<String, Bitmap>() private val spriteCache = mutableMapOf<String, Bitmap>()
// Get the external storage directory for hit effect sprites // Get the external storage directory for hit effect sprites
@ -53,4 +56,117 @@ class HitEffectSpriteManager {
} }
} }
/**
* Load a damage effect sprite from spritesheet
* @param spritesheetName The spritesheet name (e.g., "dmg_ef1", "dmg_ef2")
* @param frameIndex The frame index (0-3 for dmg_ef1 and dmg_ef2, 0 for dmg_ef3)
* @return Bitmap of the damage effect frame, or null if not found
*/
fun loadDamageEffectSprite(spritesheetName: String, frameIndex: Int = 0): Bitmap? {
val cacheKey = "dmg_${spritesheetName}_frame_${frameIndex}"
// Check cache first
if (spriteCache.containsKey(cacheKey)) {
return spriteCache[cacheKey]
}
try {
val spritesheetFile = File(getHitSpritesDir(), "$spritesheetName.png")
if (!spritesheetFile.exists()) {
println("Damage effect spritesheet not found: ${spritesheetFile.absolutePath}")
return null
}
val spritesheet = BitmapFactory.decodeFile(spritesheetFile.absolutePath)
if (spritesheet == null) {
println("Failed to decode damage effect spritesheet: ${spritesheetFile.absolutePath}")
return null
}
// Extract frame from spritesheet
val frameBitmap = when (spritesheetName) {
"dmg_ef1", "dmg_ef2" -> {
// These are 2x2 spritesheets (4 frames)
val frameWidth = spritesheet.width / 2
val frameHeight = spritesheet.height / 2
val row = frameIndex / 2
val col = frameIndex % 2
val x = col * frameWidth
val y = row * frameHeight
Bitmap.createBitmap(spritesheet, x, y, frameWidth, frameHeight)
}
"dmg_ef3" -> {
// This is a single sprite
spritesheet
}
else -> {
println("Unknown spritesheet name: $spritesheetName")
return null
}
}
println("Successfully loaded damage effect frame: $spritesheetName frame $frameIndex (${frameBitmap.width}x${frameBitmap.height})")
// Cache the result
spriteCache[cacheKey] = frameBitmap
return frameBitmap
} catch (e: Exception) {
println("Error loading damage effect sprite: ${e.message}")
e.printStackTrace()
return null
}
}
/**
* Get all available hit sprites
* @return List of hit sprite names (without .png extension)
*/
fun getAvailableHitSprites(): List<String> {
val hitSpritesDir = getHitSpritesDir()
if (!hitSpritesDir.exists()) {
return emptyList()
}
return hitSpritesDir.listFiles { file ->
file.name.startsWith("hit_") && file.name.endsWith(".png")
}?.map { file ->
file.name.substringBefore(".png")
}?.sorted() ?: emptyList()
}
/**
* Get available damage effect spritesheet names
* @return List of available damage effect spritesheet names
*/
fun getAvailableDamageEffectSpritesheets(): List<String> {
try {
if (!getHitSpritesDir().exists()) {
return emptyList()
}
val dmgFiles = getHitSpritesDir().listFiles { file ->
file.name.startsWith("dmg_ef") && file.name.endsWith(".png")
} ?: emptyArray()
return dmgFiles.map { file ->
file.name.substringBefore(".png")
}.sorted()
} catch (e: Exception) {
println("Error getting available damage effect spritesheets: ${e.message}")
e.printStackTrace()
return emptyList()
}
}
/**
* Clear the sprite cache
*/
fun clearCache() {
spriteCache.clear()
}
} }

View File

@ -1,10 +1,12 @@
package com.github.nacabaro.vbhelper.battle package com.github.nacabaro.vbhelper.battle
import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.os.Environment
import java.io.File import java.io.File
class IndividualSpriteManager { class IndividualSpriteManager(private val context: Context) {
private val spriteCache = mutableMapOf<String, Bitmap>() private val spriteCache = mutableMapOf<String, Bitmap>()
// Get the external storage directory for sprite files // Get the external storage directory for sprite files
@ -63,4 +65,68 @@ class IndividualSpriteManager {
} }
} }
/**
* Get all available sprite frames for a character
* @param characterId The character ID
* @return List of frame numbers (1-12) that exist for this character
*/
fun getAvailableFrames(characterId: String): List<Int> {
val spriteBaseDir = getSpriteBaseDir()
val characterDir = File(spriteBaseDir, characterId)
if (!characterDir.exists()) {
return emptyList()
}
val spriteFiles = characterDir.listFiles { file ->
file.name.startsWith("${characterId}_") && file.name.endsWith(".png")
} ?: emptyArray()
return spriteFiles.mapNotNull { file ->
val fileName = file.name
val frameMatch = Regex("${characterId}_(\\d{2})\\.png").find(fileName)
frameMatch?.groupValues?.get(1)?.toIntOrNull()
}.sorted()
}
/**
* Get all available character IDs
* @return List of character IDs that have sprite directories
*/
fun getAvailableCharacters(): List<String> {
val spriteBaseDir = getSpriteBaseDir()
if (!spriteBaseDir.exists()) {
return emptyList()
}
return spriteBaseDir.listFiles { file ->
file.isDirectory && file.listFiles()?.any { it.name.endsWith(".png") } == true
}?.map { it.name }?.sorted() ?: emptyList()
}
/**
* Clear the sprite cache
*/
fun clearCache() {
spriteCache.clear()
}
/**
* Check if a character has sprite files
* @param characterId The character ID to check
* @return true if the character has sprite files, false otherwise
*/
fun hasCharacterSprites(characterId: String): Boolean {
val characterDir = File(getSpriteBaseDir(), characterId)
if (!characterDir.exists()) {
return false
}
val spriteFiles = characterDir.listFiles { file ->
file.name.startsWith("${characterId}_") && file.name.endsWith(".png")
} ?: emptyArray()
return spriteFiles.isNotEmpty()
}
} }

View File

@ -12,11 +12,7 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import com.github.nacabaro.vbhelper.battle.BattleAuthContainer import com.github.nacabaro.vbhelper.battle.BattleAuthContainer
import java.net.SocketTimeoutException
import java.io.InterruptedIOException
import java.util.concurrent.TimeUnit
class RetrofitHelper { class RetrofitHelper {
@ -32,22 +28,9 @@ class RetrofitHelper {
return OkHttpClient.Builder() return OkHttpClient.Builder()
.addInterceptor(AuthInterceptor(token)) .addInterceptor(AuthInterceptor(token))
.addInterceptor(loggingInterceptor) .addInterceptor(loggingInterceptor)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.callTimeout(45, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.build() .build()
} }
private fun classifyNetworkError(t: Throwable): String {
return when (t) {
is SocketTimeoutException -> "Authentication server timed out"
is InterruptedIOException -> "Request timed out"
else -> t.message ?: "Network error"
}
}
/** /**
* Gets the session token from AuthRepository for API calls. * Gets the session token from AuthRepository for API calls.
* Falls back to nacatech token if session token is not available (backward compatibility). * Falls back to nacatech token if session token is not available (backward compatibility).
@ -55,8 +38,7 @@ class RetrofitHelper {
private fun getAuthToken(context: Context): String? { private fun getAuthToken(context: Context): String? {
return try { return try {
val authContainer = BattleAuthContainer(context) val authContainer = BattleAuthContainer(context)
runBlocking(Dispatchers.IO) { runBlocking {
withTimeoutOrNull(5000L) {
// Prefer session token, fall back to nacatech token for backward compatibility // Prefer session token, fall back to nacatech token for backward compatibility
val sessionToken = authContainer.authRepository.sessionToken.first() val sessionToken = authContainer.authRepository.sessionToken.first()
if (!sessionToken.isNullOrEmpty()) { if (!sessionToken.isNullOrEmpty()) {
@ -70,10 +52,6 @@ class RetrofitHelper {
} }
nacatechToken nacatechToken
} }
} ?: run {
println("RetrofitHelper: Timed out while reading auth token from DataStore")
null
}
} }
} catch (e: Exception) { } catch (e: Exception) {
println("RetrofitHelper: Error getting auth token: ${e.message}") println("RetrofitHelper: Error getting auth token: ${e.message}")
@ -98,29 +76,6 @@ class RetrofitHelper {
.addConverterFactory(GsonConverterFactory.create()) .addConverterFactory(GsonConverterFactory.create())
.build() .build()
} }
/**
* Creates a Retrofit instance without authentication for public endpoints.
*/
private fun createPublicRetrofit(): Retrofit {
val loggingInterceptor = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
val client = OkHttpClient.Builder()
.addInterceptor(loggingInterceptor)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.callTimeout(45, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.build()
return Retrofit.Builder()
.baseUrl("http://battle.io-void.com:8080/")
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build()
}
/** /**
* Handles HTTP error responses (401, 403, 429). * Handles HTTP error responses (401, 403, 429).
@ -166,15 +121,16 @@ class RetrofitHelper {
} }
} }
fun getOpponents(context: Context, stage: String, onComplete: (() -> Unit)? = null, callback: (OpponentsDataModel) -> Unit) { fun getOpponents(context: Context, stage: String, callback: (OpponentsDataModel) -> Unit) {
//println("RetrofitHelper: Starting API call for stage: $stage") //println("RetrofitHelper: Starting API call for stage: $stage")
try { try {
// Opponents endpoint requires auth on the current backend. // Create an authenticated Retrofit instance
// Prefer authenticated Retrofit; fall back to public client only if no token is available. val retrofit = createAuthenticatedRetrofit(context)
val retrofit = createAuthenticatedRetrofit(context) ?: run { if (retrofit == null) {
println("RetrofitHelper: No auth token for opponents request, falling back to public Retrofit") println("RetrofitHelper: Cannot create authenticated Retrofit - no token available")
createPublicRetrofit() Toast.makeText(context, "Authentication required. Please log in.", Toast.LENGTH_SHORT).show()
return
} }
// Create an ApiService instance from the Retrofit instance. // Create an ApiService instance from the Retrofit instance.
@ -190,11 +146,9 @@ class RetrofitHelper {
// make an asynchronous API request. // make an asynchronous API request.
call.enqueue(object : Callback<OpponentsDataModel> { call.enqueue(object : Callback<OpponentsDataModel> {
override fun onFailure(call: Call<OpponentsDataModel>, t: Throwable) { override fun onFailure(call: Call<OpponentsDataModel>, t: Throwable) {
val classified = classifyNetworkError(t) println("RetrofitHelper: API call failed: ${t.message}")
println("RetrofitHelper: API call failed (${t::class.java.simpleName}): $classified")
t.printStackTrace() t.printStackTrace()
Toast.makeText(context, classified, Toast.LENGTH_SHORT).show() Toast.makeText(context, "Request Fail", Toast.LENGTH_SHORT).show()
onComplete?.invoke()
} }
override fun onResponse(call: Call<OpponentsDataModel>, response: Response<OpponentsDataModel>) { override fun onResponse(call: Call<OpponentsDataModel>, response: Response<OpponentsDataModel>) {
@ -202,19 +156,13 @@ class RetrofitHelper {
println("RetrofitHelper: Response body: ${response.body()}") println("RetrofitHelper: Response body: ${response.body()}")
if(response.isSuccessful){ if(response.isSuccessful){
val opponentsList = response.body() //println("RetrofitHelper: Response successful, calling callback")
if (opponentsList != null) { val opponentsList: OpponentsDataModel = response.body() as OpponentsDataModel
callback(opponentsList) callback(opponentsList)
} else {
println("RetrofitHelper: Successful opponents response had empty body")
Toast.makeText(context, "Empty response from server", Toast.LENGTH_SHORT).show()
}
onComplete?.invoke()
} else { } else {
val errorBody = response.errorBody()?.string() val errorBody = response.errorBody()?.string()
println("RetrofitHelper: Opponents response not successful - Code: ${response.code()}, Error: $errorBody") println("RetrofitHelper: Response not successful - Error: $errorBody")
handleErrorResponse(context, response, errorBody ?: "Unable to load opponents right now.") handleErrorResponse(context, response, errorBody ?: "Unknown error")
onComplete?.invoke()
} }
} }
}) })
@ -222,7 +170,6 @@ class RetrofitHelper {
println("RetrofitHelper: Exception in getOpponents: ${e.message}") println("RetrofitHelper: Exception in getOpponents: ${e.message}")
e.printStackTrace() e.printStackTrace()
Toast.makeText(context, "Request failed: ${e.message}", Toast.LENGTH_SHORT).show() Toast.makeText(context, "Request failed: ${e.message}", Toast.LENGTH_SHORT).show()
onComplete?.invoke()
} }
} }
@ -337,10 +284,9 @@ class RetrofitHelper {
override fun onFailure(call: Call<PVPDataModel>, t: Throwable) { override fun onFailure(call: Call<PVPDataModel>, t: Throwable) {
// This method is called when the API request fails. // This method is called when the API request fails.
val classified = classifyNetworkError(t) println("RetrofitHelper: PVP API call failed: ${t.message}")
println("RetrofitHelper: PVP API call failed (${t::class.java.simpleName}): $classified")
t.printStackTrace() t.printStackTrace()
Toast.makeText(context, classified, Toast.LENGTH_SHORT).show() Toast.makeText(context, "Request Fail", Toast.LENGTH_SHORT).show()
} }
override fun onResponse(call: Call<PVPDataModel>, response: Response<PVPDataModel>) { override fun onResponse(call: Call<PVPDataModel>, response: Response<PVPDataModel>) {
@ -348,13 +294,13 @@ class RetrofitHelper {
println("RetrofitHelper: PVP API response received - Code: ${response.code()}") println("RetrofitHelper: PVP API response received - Code: ${response.code()}")
if(response.isSuccessful){ if(response.isSuccessful){
val apiResults = response.body() // If the response is successful, parse the
if (apiResults != null) { // response body to a DataModel object.
val apiResults: PVPDataModel = response.body() as PVPDataModel
// Call the callback function with the DataModel
// object as a parameter.
callback(apiResults) callback(apiResults)
} else {
println("RetrofitHelper: Successful PVP response had empty body")
Toast.makeText(context, "Empty response from server", Toast.LENGTH_SHORT).show()
}
} else { } else {
val errorBody = response.errorBody()?.string() val errorBody = response.errorBody()?.string()
println("RetrofitHelper: PVP API response not successful - Code: ${response.code()}, Error: $errorBody") println("RetrofitHelper: PVP API response not successful - Code: ${response.code()}, Error: $errorBody")
@ -369,20 +315,12 @@ class RetrofitHelper {
} }
} }
fun authenticate( fun authenticate(context: Context, token: String, callback: (AuthenticateResponse) -> Unit) {
context: Context,
token: String,
showUserFacingErrors: Boolean = true,
callback: (AuthenticateResponse) -> Unit,
) {
//println("RetrofitHelper: Starting validate API call with token: $token") //println("RetrofitHelper: Starting validate API call with token: $token")
if (token.isEmpty()) { if (token.isEmpty()) {
println("RetrofitHelper: ERROR - Token is empty!") println("RetrofitHelper: ERROR - Token is empty!")
if (showUserFacingErrors) {
Toast.makeText(context, "Authentication failed: Token is empty", Toast.LENGTH_SHORT).show() Toast.makeText(context, "Authentication failed: Token is empty", Toast.LENGTH_SHORT).show()
}
callback(AuthenticateResponse(success = false, message = "Token is empty"))
return return
} }
@ -393,10 +331,6 @@ class RetrofitHelper {
} }
val okHttpClient = OkHttpClient.Builder() val okHttpClient = OkHttpClient.Builder()
.addInterceptor(loggingInterceptor) .addInterceptor(loggingInterceptor)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.callTimeout(45, TimeUnit.SECONDS)
.build() .build()
val retrofit: Retrofit = Retrofit.Builder() val retrofit: Retrofit = Retrofit.Builder()
@ -414,11 +348,7 @@ class RetrofitHelper {
override fun onFailure(call: Call<AuthenticateResponse>, t: Throwable) { override fun onFailure(call: Call<AuthenticateResponse>, t: Throwable) {
println("RetrofitHelper: Validate API call failed: ${t.message}") println("RetrofitHelper: Validate API call failed: ${t.message}")
t.printStackTrace() t.printStackTrace()
val message = classifyNetworkError(t) Toast.makeText(context, "Authentication failed: ${t.message}", Toast.LENGTH_SHORT).show()
if (showUserFacingErrors) {
Toast.makeText(context, "Authentication failed: $message", Toast.LENGTH_SHORT).show()
}
callback(AuthenticateResponse(success = false, message = message))
} }
override fun onResponse(call: Call<AuthenticateResponse>, response: Response<AuthenticateResponse>) { override fun onResponse(call: Call<AuthenticateResponse>, response: Response<AuthenticateResponse>) {
@ -430,25 +360,11 @@ class RetrofitHelper {
} else { } else {
println("RetrofitHelper: Validation failed: Invalid response body") println("RetrofitHelper: Validation failed: Invalid response body")
Toast.makeText(context, "Authentication failed: Invalid response", Toast.LENGTH_SHORT).show() Toast.makeText(context, "Authentication failed: Invalid response", Toast.LENGTH_SHORT).show()
callback(AuthenticateResponse(success = false, message = "Invalid response body"))
} }
} else { } else {
val errorBody = response.errorBody()?.string() val errorBody = response.errorBody()?.string()
println("RetrofitHelper: Validate response not successful - Code: ${response.code()}, Error: $errorBody") println("RetrofitHelper: Validate response not successful - Code: ${response.code()}, Error: $errorBody")
val userMessage = when { Toast.makeText(context, "Authentication failed: ${response.code()}", Toast.LENGTH_SHORT).show()
response.code() == 522 -> "Authentication server timed out. Please try again in a moment."
response.code() >= 500 -> "Authentication server is unavailable. Please try again later."
else -> "Authentication failed: ${response.code()}"
}
if (showUserFacingErrors) {
Toast.makeText(context, userMessage, Toast.LENGTH_SHORT).show()
}
callback(
AuthenticateResponse(
success = false,
message = "HTTP ${response.code()}: ${errorBody ?: "Authentication server error"}"
)
)
} }
} }
}) })
@ -456,7 +372,6 @@ class RetrofitHelper {
println("RetrofitHelper: Exception in validate: ${e.message}") println("RetrofitHelper: Exception in validate: ${e.message}")
e.printStackTrace() e.printStackTrace()
Toast.makeText(context, "Authentication failed: ${e.message}", Toast.LENGTH_SHORT).show() Toast.makeText(context, "Authentication failed: ${e.message}", Toast.LENGTH_SHORT).show()
callback(AuthenticateResponse(success = false, message = e.message ?: "Authentication error"))
} }
} }
} }

View File

@ -1,13 +1,275 @@
package com.github.nacabaro.vbhelper.battle package com.github.nacabaro.vbhelper.battle
import android.content.Context
import java.io.File import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.FileInputStream
class SpriteFileManager { class SpriteFileManager(private val context: Context) {
private fun getExternalSpriteBaseDir(): File {
// Get the external storage directory where files are already located
fun getExternalSpriteBaseDir(): File {
val externalDir = android.os.Environment.getExternalStorageDirectory() val externalDir = android.os.Environment.getExternalStorageDirectory()
return File(externalDir, "VBHelper/battle_sprites") return File(externalDir, "VBHelper/battle_sprites")
} }
// Get the internal storage directory for sprite files
private fun getInternalSpriteBaseDir(): File {
return File(context.filesDir, "battle_sprites")
}
fun copySpriteFilesToInternalStorage() {
try {
println("Starting sprite file copy process from external storage to internal storage...")
val externalDir = getExternalSpriteBaseDir()
val internalDir = getInternalSpriteBaseDir()
// Check if external directory exists
if (!externalDir.exists()) {
println("External sprite directory does not exist: ${externalDir.absolutePath}")
return
}
println("External sprite directory exists: ${externalDir.absolutePath}")
println("Copying to internal storage: ${internalDir.absolutePath}")
// Create internal directory if it doesn't exist
if (!internalDir.exists()) {
val created = internalDir.mkdirs()
println("Created internal sprite directory: $created")
}
// Copy all subdirectories from external to internal storage
val externalFiles = externalDir.listFiles()
if (externalFiles != null) {
println("Found ${externalFiles.size} items in external directory")
externalFiles.forEach { item ->
val targetItem = File(internalDir, item.name)
if (item.isDirectory) {
println("Copying directory: ${item.name}")
copyDirectory(item, targetItem)
} else {
println("Copying file: ${item.name}")
copyFile(item, targetItem)
}
}
}
println("Sprite files copied successfully to internal storage: ${internalDir.absolutePath}")
} catch (e: Exception) {
println("Error copying sprite files to internal storage: ${e.message}")
e.printStackTrace()
}
}
fun copySpriteFilesToExternalStorage() {
try {
println("Starting sprite file copy process to external storage...")
// Debug: List what's in the assets directory
val assetManager = context.assets
val battleSpritesFiles = assetManager.list("battle_sprites")
println("battle_sprites directory in assets contains: ${battleSpritesFiles?.joinToString(", ")}")
val extractedAssetsFiles = assetManager.list("battle_sprites/extracted_assets")
println("battle_sprites/extracted_assets directory in assets contains: ${extractedAssetsFiles?.joinToString(", ")}")
// Check specifically for extracted_atksprites in assets (now directly under battle_sprites)
val atkspritesInAssets = assetManager.list("battle_sprites/extracted_atksprites")
println("extracted_atksprites in assets contains: ${atkspritesInAssets?.size ?: 0} files")
if (atkspritesInAssets != null && atkspritesInAssets.isNotEmpty()) {
println("First few attack files in assets: ${atkspritesInAssets.take(5).joinToString(", ")}")
}
// Check for extracted_battlebgs in assets (now directly under battle_sprites)
val battlebgsInAssets = assetManager.list("battle_sprites/extracted_battlebgs")
println("extracted_battlebgs in assets contains: ${battlebgsInAssets?.size ?: 0} files")
if (battlebgsInAssets != null && battlebgsInAssets.isNotEmpty()) {
println("First few battle background files in assets: ${battlebgsInAssets.take(5).joinToString(", ")}")
}
// Try to list all possible subdirectories in battle_sprites
println("Checking all possible subdirectories in battle_sprites...")
battleSpritesFiles?.forEach { subdir ->
try {
val subdirFiles = assetManager.list("battle_sprites/$subdir")
println(" $subdir contains: ${subdirFiles?.size ?: 0} files")
if (subdirFiles != null && subdirFiles.isNotEmpty()) {
println(" First few files: ${subdirFiles.take(3).joinToString(", ")}")
}
} catch (e: Exception) {
println(" Error listing $subdir: ${e.message}")
}
}
// Create the base directory for battle_sprites in external storage
val battleSpritesDir = getExternalSpriteBaseDir()
if (!battleSpritesDir.exists()) {
battleSpritesDir.mkdirs()
println("Created battle_sprites directory in external storage: ${battleSpritesDir.absolutePath}")
} else {
println("battle_sprites directory already exists in external storage: ${battleSpritesDir.absolutePath}")
}
// Copy all subdirectories from battle_sprites assets to external storage
println("Copying all battle_sprites subdirectories to external storage...")
battleSpritesFiles?.forEach { subdir ->
val sourcePath = "battle_sprites/$subdir"
val targetDir = File(battleSpritesDir, subdir)
println("Copying $sourcePath to ${targetDir.absolutePath}")
copyAssetDirectory(sourcePath, targetDir)
}
println("Sprite files copied successfully to external storage: ${battleSpritesDir.absolutePath}")
// Verify that attack sprites were copied
val atkspritesDir = File(battleSpritesDir, "extracted_atksprites")
if (atkspritesDir.exists()) {
val attackFiles = atkspritesDir.listFiles()
println("Attack sprites directory exists with ${attackFiles?.size ?: 0} files")
if (attackFiles != null && attackFiles.isNotEmpty()) {
println("First few attack files: ${attackFiles.take(5).map { it.name }}")
}
} else {
println("WARNING: extracted_atksprites directory does not exist!")
// List what's actually in the battle_sprites directory
val battleSpritesContents = battleSpritesDir.listFiles()
println("battle_sprites directory contains: ${battleSpritesContents?.map { it.name }?.joinToString(", ")}")
}
// Verify that battle backgrounds were copied
val battlebgsDir = File(battleSpritesDir, "extracted_battlebgs")
if (battlebgsDir.exists()) {
val bgFiles = battlebgsDir.listFiles()
println("Battle backgrounds directory exists with ${bgFiles?.size ?: 0} files")
if (bgFiles != null && bgFiles.isNotEmpty()) {
println("First few battle background files: ${bgFiles.take(5).map { it.name }}")
}
} else {
println("WARNING: extracted_battlebgs directory does not exist!")
}
} catch (e: Exception) {
println("Error copying sprite files to external storage: ${e.message}")
e.printStackTrace()
}
}
private fun copyAssetDirectory(assetPath: String, targetDir: File) {
try {
val assetManager = context.assets
val files = assetManager.list(assetPath) ?: return
println("Copying asset directory: $assetPath (${files.size} items)")
println("Files found: ${files.joinToString(", ")}")
for (file in files) {
val assetFilePath = if (assetPath.isEmpty()) file else "$assetPath/$file"
val targetFile = File(targetDir, file)
// Create subdirectories if needed
if (targetFile.parentFile != null && !targetFile.parentFile!!.exists()) {
targetFile.parentFile!!.mkdirs()
}
// Check if it's a directory by trying to list its contents
try {
val subFiles = assetManager.list(assetFilePath)
if (subFiles != null && subFiles.isNotEmpty()) {
// It's a directory, create it and copy contents
println("Copying subdirectory: $assetFilePath (${subFiles.size} files)")
if (!targetFile.exists()) {
targetFile.mkdirs()
}
copyAssetDirectory(assetFilePath, targetFile)
} else {
// It's a file, copy it
copyAssetFile(assetFilePath, targetFile)
}
} catch (e: Exception) {
// If we can't list contents, it's probably a file
println("Treating $assetFilePath as file (could not list contents)")
copyAssetFile(assetFilePath, targetFile)
}
}
// Special handling for extracted_atksprites - try to copy it directly if it wasn't found
if (assetPath == "battle_sprites/extracted_assets") {
println("Special handling: Checking for extracted_atksprites directory...")
try {
val atkspritesFiles = assetManager.list("battle_sprites/extracted_assets/extracted_atksprites")
if (atkspritesFiles != null && atkspritesFiles.isNotEmpty()) {
println("Found extracted_atksprites with ${atkspritesFiles.size} files")
val atkspritesDir = File(targetDir, "extracted_atksprites")
if (!atkspritesDir.exists()) {
atkspritesDir.mkdirs()
}
copyAssetDirectory("battle_sprites/extracted_assets/extracted_atksprites", atkspritesDir)
} else {
println("extracted_atksprites directory not found in assets")
}
} catch (e: Exception) {
println("Error checking extracted_atksprites: ${e.message}")
}
}
} catch (e: Exception) {
println("Error copying asset directory $assetPath: ${e.message}")
e.printStackTrace()
}
}
private fun copyDirectory(sourceDir: File, targetDir: File) {
if (!targetDir.exists()) {
targetDir.mkdirs()
}
val files = sourceDir.listFiles()
if (files != null) {
files.forEach { file ->
val targetFile = File(targetDir, file.name)
if (file.isDirectory) {
copyDirectory(file, targetFile)
} else {
copyFile(file, targetFile)
}
}
}
}
private fun copyFile(sourceFile: File, targetFile: File) {
try {
val inputStream = FileInputStream(sourceFile)
val outputStream = FileOutputStream(targetFile)
inputStream.copyTo(outputStream)
inputStream.close()
outputStream.close()
println("Copied: ${sourceFile.name} -> ${targetFile.absolutePath}")
} catch (e: IOException) {
println("Error copying file ${sourceFile.name}: ${e.message}")
}
}
private fun copyAssetFile(assetPath: String, targetFile: File) {
try {
val inputStream = context.assets.open(assetPath)
val outputStream = FileOutputStream(targetFile)
inputStream.copyTo(outputStream)
inputStream.close()
outputStream.close()
println("Copied: $assetPath -> ${targetFile.absolutePath}")
} catch (e: IOException) {
println("Error copying asset file $assetPath: ${e.message}")
}
}
fun checkSpriteFilesExist(): Boolean { fun checkSpriteFilesExist(): Boolean {
val battleSpritesDir = getExternalSpriteBaseDir() val battleSpritesDir = getExternalSpriteBaseDir()
val extractedAssetsDir = File(battleSpritesDir, "extracted_assets") val extractedAssetsDir = File(battleSpritesDir, "extracted_assets")
@ -32,4 +294,35 @@ class SpriteFileManager {
return battleSpritesExist && assetsExist && statsExist && atkspritesExist && battlebgsExist return battleSpritesExist && assetsExist && statsExist && atkspritesExist && battlebgsExist
} }
fun clearSpriteFiles() {
try {
val battleSpritesDir = getInternalSpriteBaseDir()
if (battleSpritesDir.exists()) {
deleteDirectory(battleSpritesDir)
println("Cleared battle_sprites directory from internal storage")
}
} catch (e: Exception) {
println("Error clearing sprite files: ${e.message}")
e.printStackTrace()
}
}
private fun deleteDirectory(directory: File) {
if (directory.exists()) {
val files = directory.listFiles()
if (files != null) {
for (file in files) {
if (file.isDirectory) {
deleteDirectory(file)
} else {
file.delete()
}
}
}
directory.delete()
}
}
} }

View File

@ -5,6 +5,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
@Composable @Composable
fun SpriteImage( fun SpriteImage(
@ -13,7 +14,8 @@ fun SpriteImage(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
contentScale: ContentScale = ContentScale.Fit contentScale: ContentScale = ContentScale.Fit
) { ) {
val spriteManager = remember { IndividualSpriteManager() } val context = LocalContext.current
val spriteManager = remember { IndividualSpriteManager(context) }
var bitmap by remember { mutableStateOf<android.graphics.Bitmap?>(null) } var bitmap by remember { mutableStateOf<android.graphics.Bitmap?>(null) }

View File

@ -2,7 +2,6 @@ package com.github.nacabaro.vbhelper.daos
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Query import androidx.room.Query
import androidx.room.RewriteQueriesToDropUnusedColumns
import com.github.nacabaro.vbhelper.dtos.CharacterDtos import com.github.nacabaro.vbhelper.dtos.CharacterDtos
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -20,7 +19,6 @@ interface AdventureDao {
""") """)
fun getAdventureCount(): Int fun getAdventureCount(): Int
@RewriteQueriesToDropUnusedColumns
@Query( @Query(
""" """
SELECT SELECT

View File

@ -13,9 +13,6 @@ interface CardDao {
@Insert(onConflict = OnConflictStrategy.IGNORE) @Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertNewCard(card: Card): Long suspend fun insertNewCard(card: Card): Long
@Query("SELECT * FROM Card")
fun getAllCards(): List<Card>
@Query("SELECT * FROM Card WHERE cardId = :id") @Query("SELECT * FROM Card WHERE cardId = :id")
fun getCardByCardId(id: Int): List<Card> fun getCardByCardId(id: Int): List<Card>

View File

@ -2,7 +2,6 @@ package com.github.nacabaro.vbhelper.daos
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Query import androidx.room.Query
import androidx.room.RewriteQueriesToDropUnusedColumns
import com.github.cfogrady.vbnfc.data.NfcCharacter import com.github.cfogrady.vbnfc.data.NfcCharacter
import com.github.nacabaro.vbhelper.dtos.CharacterDtos import com.github.nacabaro.vbhelper.dtos.CharacterDtos
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -28,12 +27,12 @@ interface CardFusionsDao {
toCharaId: Int toCharaId: Int
) )
@RewriteQueriesToDropUnusedColumns
@Query(""" @Query("""
SELECT SELECT
cf.toCharaId as charaId, cf.toCharaId as charaId,
cf.fromCharaId as fromCharaId, cf.fromCharaId as fromCharaId,
s.spriteIdle1 as spriteIdle, s.spriteIdle1 as spriteIdle,
cc.attribute as attribute,
s.width as spriteWidth, s.width as spriteWidth,
s.height as spriteHeight, s.height as spriteHeight,
d.discoveredOn as discoveredOn, d.discoveredOn as discoveredOn,

View File

@ -13,15 +13,9 @@ interface CharacterDao {
@Insert @Insert
suspend fun insertCharacter(vararg characterData: CardCharacter) suspend fun insertCharacter(vararg characterData: CardCharacter)
@Query("SELECT * FROM CardCharacter WHERE id = :id LIMIT 1")
fun getCharacterById(id: Long): CardCharacter?
@Query("SELECT * FROM CardCharacter WHERE charaIndex = :monIndex AND cardId = :dimId LIMIT 1") @Query("SELECT * FROM CardCharacter WHERE charaIndex = :monIndex AND cardId = :dimId LIMIT 1")
fun getCharacterByMonIndex(monIndex: Int, dimId: Long): CardCharacter fun getCharacterByMonIndex(monIndex: Int, dimId: Long): CardCharacter
@Query("SELECT * FROM CardCharacter WHERE charaIndex = :monIndex AND cardId = :dimId LIMIT 1")
fun getCharacterByMonIndexOrNull(monIndex: Int, dimId: Long): CardCharacter?
@Insert @Insert
suspend fun insertSprite(vararg sprite: Sprite) suspend fun insertSprite(vararg sprite: Sprite)

View File

@ -11,9 +11,6 @@ interface SpriteDao {
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertSprite(sprite: Sprite): Long fun insertSprite(sprite: Sprite): Long
@Query("SELECT * FROM Sprite WHERE id = :id LIMIT 1")
fun getSpriteById(id: Long): Sprite?
@Query("SELECT * FROM Sprite") @Query("SELECT * FROM Sprite")
suspend fun getAllSprites(): List<Sprite> suspend fun getAllSprites(): List<Sprite>
} }

View File

@ -4,7 +4,6 @@ import androidx.room.Dao
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import androidx.room.RewriteQueriesToDropUnusedColumns
import androidx.room.Upsert import androidx.room.Upsert
import com.github.nacabaro.vbhelper.domain.card.CardCharacter import com.github.nacabaro.vbhelper.domain.card.CardCharacter
import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter
@ -39,7 +38,6 @@ interface UserCharacterDao {
@Upsert @Upsert
fun insertSpecialMissions(vararg specialMissions: SpecialMissions) fun insertSpecialMissions(vararg specialMissions: SpecialMissions)
@RewriteQueriesToDropUnusedColumns
@Query( @Query(
""" """
SELECT SELECT
@ -57,7 +55,6 @@ interface UserCharacterDao {
) )
fun getTransformationHistory(monId: Long): Flow<List<CharacterDtos.TransformationHistory>> fun getTransformationHistory(monId: Long): Flow<List<CharacterDtos.TransformationHistory>>
@RewriteQueriesToDropUnusedColumns
@Query( @Query(
""" """
SELECT SELECT
@ -74,7 +71,6 @@ interface UserCharacterDao {
) )
suspend fun getTransformationHistoryForExport(monId: Long): List<CharacterDtos.TransformationHistoryExport> suspend fun getTransformationHistoryForExport(monId: Long): List<CharacterDtos.TransformationHistoryExport>
@RewriteQueriesToDropUnusedColumns
@Query( @Query(
""" """
SELECT SELECT
@ -100,7 +96,6 @@ interface UserCharacterDao {
) )
fun getAllCharacters(): Flow<List<CharacterDtos.CharacterWithSprites>> fun getAllCharacters(): Flow<List<CharacterDtos.CharacterWithSprites>>
@RewriteQueriesToDropUnusedColumns
@Query( @Query(
""" """
SELECT SELECT
@ -133,22 +128,12 @@ interface UserCharacterDao {
@Query("SELECT * FROM BECharacterData WHERE id = :id") @Query("SELECT * FROM BECharacterData WHERE id = :id")
fun getBeData(id: Long): Flow<BECharacterData> fun getBeData(id: Long): Flow<BECharacterData>
@Query("SELECT * FROM BECharacterData WHERE id = :id")
suspend fun getBeDataOrNull(id: Long): BECharacterData?
@Query("SELECT * FROM VBCharacterData WHERE id = :id") @Query("SELECT * FROM VBCharacterData WHERE id = :id")
fun getVbData(id: Long): Flow<VBCharacterData> fun getVbData(id: Long): Flow<VBCharacterData>
@Query("SELECT * FROM VBCharacterData WHERE id = :id")
suspend fun getVbDataOrNull(id: Long): VBCharacterData?
@Query("DELETE FROM BECharacterData WHERE id = :id")
suspend fun deleteBECharacterData(id: Long)
@Query("SELECT * FROM SpecialMissions WHERE characterId = :id") @Query("SELECT * FROM SpecialMissions WHERE characterId = :id")
fun getSpecialMissions(id: Long): Flow<List<SpecialMissions>> fun getSpecialMissions(id: Long): Flow<List<SpecialMissions>>
@RewriteQueriesToDropUnusedColumns
@Query( @Query(
""" """
SELECT SELECT
@ -214,7 +199,6 @@ interface UserCharacterDao {
@Query("""SELECT * FROM VitalsHistory WHERE charId = :charId ORDER BY id ASC""") @Query("""SELECT * FROM VitalsHistory WHERE charId = :charId ORDER BY id ASC""")
suspend fun getVitalsHistory(charId: Long): List<VitalsHistory> suspend fun getVitalsHistory(charId: Long): List<VitalsHistory>
@RewriteQueriesToDropUnusedColumns
@Query( @Query(
""" """
SELECT SELECT
@ -241,7 +225,6 @@ interface UserCharacterDao {
) )
suspend fun getBECharacters(): List<CharacterDtos.CharacterWithSprites> suspend fun getBECharacters(): List<CharacterDtos.CharacterWithSprites>
@RewriteQueriesToDropUnusedColumns
@Query( @Query(
""" """
SELECT SELECT

View File

@ -2,8 +2,6 @@ package com.github.nacabaro.vbhelper.database
import androidx.room.Database import androidx.room.Database
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.github.nacabaro.vbhelper.daos.AdventureDao import com.github.nacabaro.vbhelper.daos.AdventureDao
import com.github.nacabaro.vbhelper.daos.CardAdventureDao import com.github.nacabaro.vbhelper.daos.CardAdventureDao
import com.github.nacabaro.vbhelper.daos.CharacterDao import com.github.nacabaro.vbhelper.daos.CharacterDao
@ -15,7 +13,6 @@ import com.github.nacabaro.vbhelper.daos.ItemDao
import com.github.nacabaro.vbhelper.daos.SpecialMissionDao import com.github.nacabaro.vbhelper.daos.SpecialMissionDao
import com.github.nacabaro.vbhelper.daos.SpriteDao import com.github.nacabaro.vbhelper.daos.SpriteDao
import com.github.nacabaro.vbhelper.daos.UserCharacterDao import com.github.nacabaro.vbhelper.daos.UserCharacterDao
import com.github.nacabaro.vbhelper.daos.VitalWearSettingsDao
import com.github.nacabaro.vbhelper.domain.card.Background import com.github.nacabaro.vbhelper.domain.card.Background
import com.github.nacabaro.vbhelper.domain.card.CardCharacter import com.github.nacabaro.vbhelper.domain.card.CardCharacter
import com.github.nacabaro.vbhelper.domain.card.Card import com.github.nacabaro.vbhelper.domain.card.Card
@ -32,12 +29,10 @@ import com.github.nacabaro.vbhelper.domain.device_data.TransformationHistory
import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter
import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData
import com.github.nacabaro.vbhelper.domain.device_data.VitalsHistory import com.github.nacabaro.vbhelper.domain.device_data.VitalsHistory
import com.github.nacabaro.vbhelper.domain.device_data.VitalWearCharacterSettings
import com.github.nacabaro.vbhelper.domain.items.Items import com.github.nacabaro.vbhelper.domain.items.Items
@Database( @Database(
version = 6, version = 1,
exportSchema = false,
entities = [ entities = [
Card::class, Card::class,
CardProgress::class, CardProgress::class,
@ -55,8 +50,7 @@ import com.github.nacabaro.vbhelper.domain.items.Items
Items::class, Items::class,
Adventure::class, Adventure::class,
Background::class, Background::class,
PossibleTransformations::class, PossibleTransformations::class
VitalWearCharacterSettings::class,
] ]
) )
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
@ -71,92 +65,4 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun specialMissionDao(): SpecialMissionDao abstract fun specialMissionDao(): SpecialMissionDao
abstract fun cardAdventureDao(): CardAdventureDao abstract fun cardAdventureDao(): CardAdventureDao
abstract fun cardFusionsDao(): CardFusionsDao abstract fun cardFusionsDao(): CardFusionsDao
abstract fun vitalWearSettingsDao(): VitalWearSettingsDao
companion object {
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS `VitalWearCharacterSettings` (
`characterId` INTEGER NOT NULL,
`trainingInBackground` INTEGER NOT NULL,
`allowedBattles` INTEGER NOT NULL,
`accumulatedDailyInjuries` INTEGER NOT NULL,
PRIMARY KEY(`characterId`)
)
""".trimIndent()
)
// Clean existing duplicates before adding the unique index.
db.execSQL(
"""
DELETE FROM `CardCharacter`
WHERE `id` NOT IN (
SELECT MIN(`id`) FROM `CardCharacter` GROUP BY `cardId`, `charaIndex`
)
""".trimIndent()
)
db.execSQL(
"""
CREATE UNIQUE INDEX IF NOT EXISTS `index_CardCharacter_cardId_charaIndex`
ON `CardCharacter` (`cardId`, `charaIndex`)
""".trimIndent()
)
}
}
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
val backfillTimestamp = System.currentTimeMillis()
db.execSQL(
"""
INSERT INTO `TransformationHistory`(`monId`, `stageId`, `transformationDate`)
SELECT uc.`id`, uc.`charId`, $backfillTimestamp
FROM `UserCharacter` uc
WHERE NOT EXISTS (
SELECT 1
FROM `TransformationHistory` th
WHERE th.`monId` = uc.`id`
)
""".trimIndent()
)
}
}
val MIGRATION_3_5 = object : Migration(3, 5) {
override fun migrate(db: SupportSQLiteDatabase) {
// TransferSeen moved to shared_transfer.db
db.execSQL("DROP TABLE IF EXISTS `TransferSeen`")
db.execSQL("DROP INDEX IF EXISTS `index_TransferSeen_cardLookupKey_slotId`")
}
}
val MIGRATION_4_5 = object : Migration(4, 5) {
override fun migrate(db: SupportSQLiteDatabase) {
// TransferSeen moved to shared_transfer.db
db.execSQL("DROP TABLE IF EXISTS `TransferSeen`")
db.execSQL("DROP INDEX IF EXISTS `index_TransferSeen_cardLookupKey_slotId`")
}
}
val MIGRATION_5_6 = object : Migration(5, 6) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("CREATE INDEX IF NOT EXISTS `index_CardCharacter_spriteId` ON `CardCharacter` (`spriteId`)")
db.execSQL("CREATE INDEX IF NOT EXISTS `index_CardAdventure_cardId` ON `CardAdventure` (`cardId`)")
db.execSQL("CREATE INDEX IF NOT EXISTS `index_CardAdventure_characterId` ON `CardAdventure` (`characterId`)")
db.execSQL("CREATE INDEX IF NOT EXISTS `index_CardFusions_fromCharaId` ON `CardFusions` (`fromCharaId`)")
db.execSQL("CREATE INDEX IF NOT EXISTS `index_CardFusions_toCharaId` ON `CardFusions` (`toCharaId`)")
db.execSQL("CREATE INDEX IF NOT EXISTS `index_Background_cardId` ON `Background` (`cardId`)")
db.execSQL("CREATE INDEX IF NOT EXISTS `index_PossibleTransformations_charaId` ON `PossibleTransformations` (`charaId`)")
db.execSQL("CREATE INDEX IF NOT EXISTS `index_PossibleTransformations_toCharaId` ON `PossibleTransformations` (`toCharaId`)")
db.execSQL("CREATE INDEX IF NOT EXISTS `index_UserCharacter_charId` ON `UserCharacter` (`charId`)")
db.execSQL("CREATE INDEX IF NOT EXISTS `index_SpecialMissions_characterId` ON `SpecialMissions` (`characterId`)")
db.execSQL("CREATE INDEX IF NOT EXISTS `index_TransformationHistory_monId` ON `TransformationHistory` (`monId`)")
db.execSQL("CREATE INDEX IF NOT EXISTS `index_TransformationHistory_stageId` ON `TransformationHistory` (`stageId`)")
db.execSQL("CREATE INDEX IF NOT EXISTS `index_VitalsHistory_charId` ON `VitalsHistory` (`charId`)")
}
}
}
} }

View File

@ -1,15 +1,11 @@
package com.github.nacabaro.vbhelper.di package com.github.nacabaro.vbhelper.di
import com.github.cfogrady.vitalwear.common.data.SharedTransferSeenDao
import com.github.nacabaro.vbhelper.database.AppDatabase import com.github.nacabaro.vbhelper.database.AppDatabase
import com.github.nacabaro.vbhelper.source.CardSettingsRepository
import com.github.nacabaro.vbhelper.source.CurrencyRepository import com.github.nacabaro.vbhelper.source.CurrencyRepository
import com.github.nacabaro.vbhelper.source.DataStoreSecretsRepository import com.github.nacabaro.vbhelper.source.DataStoreSecretsRepository
interface AppContainer { interface AppContainer {
val db: AppDatabase val db: AppDatabase
val transferSeenDao: SharedTransferSeenDao
val dataStoreSecretsRepository: DataStoreSecretsRepository val dataStoreSecretsRepository: DataStoreSecretsRepository
val currencyRepository: CurrencyRepository val currencyRepository: CurrencyRepository
val cardSettingsRepository: CardSettingsRepository
} }

View File

@ -4,11 +4,8 @@ import androidx.datastore.dataStore
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import androidx.room.Room import androidx.room.Room
import com.github.cfogrady.vitalwear.common.data.SharedDatabaseFactory
import com.github.cfogrady.vitalwear.common.data.SharedTransferSeenDao
import com.github.nacabaro.vbhelper.di.AppContainer
import com.github.nacabaro.vbhelper.database.AppDatabase import com.github.nacabaro.vbhelper.database.AppDatabase
import com.github.nacabaro.vbhelper.source.CardSettingsRepository import com.github.nacabaro.vbhelper.di.AppContainer
import com.github.nacabaro.vbhelper.source.CurrencyRepository import com.github.nacabaro.vbhelper.source.CurrencyRepository
import com.github.nacabaro.vbhelper.source.DataStoreSecretsRepository import com.github.nacabaro.vbhelper.source.DataStoreSecretsRepository
import com.github.nacabaro.vbhelper.source.SecretsSerializer import com.github.nacabaro.vbhelper.source.SecretsSerializer
@ -34,22 +31,11 @@ class DefaultAppContainer(private val context: Context) : AppContainer {
"internalDb" "internalDb"
) )
.createFromAsset("items.db") .createFromAsset("items.db")
.addMigrations(AppDatabase.MIGRATION_1_2)
.addMigrations(AppDatabase.MIGRATION_2_3)
.addMigrations(AppDatabase.MIGRATION_3_5)
.addMigrations(AppDatabase.MIGRATION_4_5)
.addMigrations(AppDatabase.MIGRATION_5_6)
.build() .build()
} }
override val transferSeenDao: SharedTransferSeenDao by lazy {
SharedDatabaseFactory.getDatabase(context).transferSeenDao()
}
override val dataStoreSecretsRepository = DataStoreSecretsRepository(context.secretsStore) override val dataStoreSecretsRepository = DataStoreSecretsRepository(context.secretsStore)
override val currencyRepository = CurrencyRepository(context.currencyStore) override val currencyRepository = CurrencyRepository(context.currencyStore)
override val cardSettingsRepository = CardSettingsRepository(context.currencyStore)
} }

View File

@ -2,37 +2,12 @@ package com.github.nacabaro.vbhelper.di
import DefaultAppContainer import DefaultAppContainer
import android.app.Application import android.app.Application
import androidx.room.Room
import com.github.nacabaro.vbhelper.companion.logging.TinyLogTree
import com.github.nacabaro.vbhelper.companion.logs.CompanionLogService
import com.github.nacabaro.vbhelper.companion.validation.CompanionValidatedDatabase
import com.github.nacabaro.vbhelper.companion.validation.ValidatedCardManager
import timber.log.Timber
class VBHelper : Application() { class VBHelper : Application() {
lateinit var container: DefaultAppContainer lateinit var container: DefaultAppContainer
lateinit var validatedCardDatabase: CompanionValidatedDatabase
lateinit var validatedCardManager: ValidatedCardManager
val companionLogService = CompanionLogService()
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
container = DefaultAppContainer(applicationContext) container = DefaultAppContainer(applicationContext)
Timber.plant(Timber.DebugTree())
Timber.plant(TinyLogTree(this))
val originalExceptionHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
Timber.e(throwable, "VBHelper crashed on thread ${thread.name}")
TinyLogTree.shutdown()
originalExceptionHandler?.uncaughtException(thread, throwable)
}
validatedCardDatabase = Room.databaseBuilder(
applicationContext,
CompanionValidatedDatabase::class.java,
"vbhelper_companion_tools"
).build()
validatedCardManager = ValidatedCardManager(validatedCardDatabase.validatedCardDao())
} }
} }

View File

@ -2,11 +2,9 @@ package com.github.nacabaro.vbhelper.domain.card
import androidx.room.Entity import androidx.room.Entity
import androidx.room.ForeignKey import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
@Entity( @Entity(
indices = [Index(value = ["cardId"])],
foreignKeys = [ foreignKeys = [
ForeignKey( ForeignKey(
entity = Card::class, entity = Card::class,

View File

@ -2,14 +2,9 @@ package com.github.nacabaro.vbhelper.domain.card
import androidx.room.Entity import androidx.room.Entity
import androidx.room.ForeignKey import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
@Entity( @Entity(
indices = [
Index(value = ["cardId"]),
Index(value = ["characterId"])
],
foreignKeys = [ foreignKeys = [
ForeignKey( ForeignKey(
entity = CardCharacter::class, entity = CardCharacter::class,

View File

@ -2,16 +2,11 @@ package com.github.nacabaro.vbhelper.domain.card
import androidx.room.Entity import androidx.room.Entity
import androidx.room.ForeignKey import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import com.github.cfogrady.vbnfc.data.NfcCharacter import com.github.cfogrady.vbnfc.data.NfcCharacter
import com.github.nacabaro.vbhelper.domain.characters.Sprite import com.github.nacabaro.vbhelper.domain.characters.Sprite
@Entity( @Entity(
indices = [
Index(value = ["cardId", "charaIndex"], unique = true),
Index(value = ["spriteId"])
],
foreignKeys = [ foreignKeys = [
ForeignKey( ForeignKey(
entity = Card::class, entity = Card::class,
@ -29,8 +24,9 @@ import com.github.nacabaro.vbhelper.domain.characters.Sprite
) )
/* /*
* Character represents a character on a specific card slot. * Character represents a character on a card. There should only be one of these per dimId
* Uniqueness is enforced per cardId + charaIndex. * and monIndex.
* TODO: Customs will mean this should be unique per cardName and monIndex
*/ */
data class CardCharacter ( data class CardCharacter (
@PrimaryKey(autoGenerate = true) val id: Long = 0, @PrimaryKey(autoGenerate = true) val id: Long = 0,

View File

@ -2,15 +2,10 @@ package com.github.nacabaro.vbhelper.domain.card
import androidx.room.Entity import androidx.room.Entity
import androidx.room.ForeignKey import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import com.github.cfogrady.vbnfc.data.NfcCharacter import com.github.cfogrady.vbnfc.data.NfcCharacter
@Entity( @Entity(
indices = [
Index(value = ["fromCharaId"]),
Index(value = ["toCharaId"])
],
foreignKeys = [ foreignKeys = [
ForeignKey( ForeignKey(
entity = CardCharacter::class, entity = CardCharacter::class,

View File

@ -2,14 +2,9 @@ package com.github.nacabaro.vbhelper.domain.card
import androidx.room.Entity import androidx.room.Entity
import androidx.room.ForeignKey import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
@Entity( @Entity(
indices = [
Index(value = ["charaId"]),
Index(value = ["toCharaId"])
],
foreignKeys = [ foreignKeys = [
ForeignKey( ForeignKey(
entity = CardCharacter::class, entity = CardCharacter::class,

View File

@ -2,12 +2,10 @@ package com.github.nacabaro.vbhelper.domain.device_data
import androidx.room.Entity import androidx.room.Entity
import androidx.room.ForeignKey import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import com.github.cfogrady.vbnfc.vb.SpecialMission import com.github.cfogrady.vbnfc.vb.SpecialMission
@Entity( @Entity(
indices = [Index(value = ["characterId"])],
foreignKeys = [ foreignKeys = [
ForeignKey( ForeignKey(
entity = UserCharacter::class, entity = UserCharacter::class,

View File

@ -2,15 +2,10 @@ package com.github.nacabaro.vbhelper.domain.device_data
import androidx.room.Entity import androidx.room.Entity
import androidx.room.ForeignKey import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import com.github.nacabaro.vbhelper.domain.card.CardCharacter import com.github.nacabaro.vbhelper.domain.card.CardCharacter
@Entity( @Entity(
indices = [
Index(value = ["monId"]),
Index(value = ["stageId"])
],
foreignKeys = [ foreignKeys = [
ForeignKey( ForeignKey(
entity = UserCharacter::class, entity = UserCharacter::class,

View File

@ -2,14 +2,12 @@ package com.github.nacabaro.vbhelper.domain.device_data
import androidx.room.Entity import androidx.room.Entity
import androidx.room.ForeignKey import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import com.github.cfogrady.vbnfc.data.NfcCharacter import com.github.cfogrady.vbnfc.data.NfcCharacter
import com.github.nacabaro.vbhelper.utils.DeviceType import com.github.nacabaro.vbhelper.utils.DeviceType
import com.github.nacabaro.vbhelper.domain.card.CardCharacter import com.github.nacabaro.vbhelper.domain.card.CardCharacter
@Entity( @Entity(
indices = [Index(value = ["charId"])],
foreignKeys = [ foreignKeys = [
ForeignKey( ForeignKey(
entity = CardCharacter::class, entity = CardCharacter::class,

View File

@ -2,11 +2,9 @@ package com.github.nacabaro.vbhelper.domain.device_data
import androidx.room.Entity import androidx.room.Entity
import androidx.room.ForeignKey import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
@Entity( @Entity(
indices = [Index(value = ["charId"])],
foreignKeys = [ foreignKeys = [
ForeignKey( ForeignKey(
entity = UserCharacter::class, entity = UserCharacter::class,

View File

@ -6,7 +6,6 @@ import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -15,7 +14,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import com.github.nacabaro.vbhelper.di.VBHelper import com.github.nacabaro.vbhelper.di.VBHelper
@ -60,21 +58,6 @@ fun AppNavigation(
) { ) {
val navController = rememberNavController() val navController = rememberNavController()
LaunchedEffect(initialRoute) {
val route = initialRoute ?: return@LaunchedEffect
if (navController.currentBackStackEntry?.destination?.route == route) {
return@LaunchedEffect
}
navController.navigate(route) {
popUpTo(navController.graph.findStartDestination().id) {
inclusive = false
saveState = false
}
launchSingleTop = true
restoreState = false
}
}
Scaffold( Scaffold(
bottomBar = { bottomBar = {
BottomNavigationBar(navController = navController) BottomNavigationBar(navController = navController)

View File

@ -7,7 +7,6 @@ import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -31,14 +30,17 @@ fun BottomNavigationBar(navController: NavController) {
label = { Text(text = stringResource(item.label)) }, label = { Text(text = stringResource(item.label)) },
selected = currentRoute == item.route, selected = currentRoute == item.route,
onClick = { onClick = {
navController.navigate(item.route) { if (item == NavigationItems.Home) {
// Always route tab clicks to each tab's root screen. navController.navigate(NavigationItems.Home.route) {
popUpTo(navController.graph.findStartDestination().id) { popUpTo(0) { inclusive = false }
inclusive = false
saveState = false
}
launchSingleTop = true launchSingleTop = true
restoreState = false }
} else {
navController.navigate(item.route) {
popUpTo(navController.graph.startDestinationId) { saveState = true }
launchSingleTop = true
restoreState = true
}
} }
} }
) )

View File

@ -6,8 +6,8 @@ import androidx.annotation.StringRes
sealed class NavigationItems( sealed class NavigationItems(
val route: String, val route: String,
@param:DrawableRes val icon: Int, @DrawableRes val icon: Int,
@param:StringRes val label: Int @StringRes val label: Int
) { ) {
object Scan : NavigationItems( object Scan : NavigationItems(
"Scan/{characterId}", "Scan/{characterId}",

View File

@ -51,7 +51,6 @@ import android.content.Intent
import android.net.Uri import android.net.Uri
import android.media.MediaPlayer import android.media.MediaPlayer
import android.os.Environment import android.os.Environment
import android.widget.Toast
//import androidx.compose.animation.core.animateFloatAsState //import androidx.compose.animation.core.animateFloatAsState
//import androidx.compose.animation.core.tween //import androidx.compose.animation.core.tween
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@ -65,8 +64,6 @@ import com.github.nacabaro.vbhelper.battle.APIBattleCharacter
import android.util.Log import android.util.Log
import com.github.nacabaro.vbhelper.components.TopBanner import com.github.nacabaro.vbhelper.components.TopBanner
import com.github.nacabaro.vbhelper.battle.RetrofitHelper import com.github.nacabaro.vbhelper.battle.RetrofitHelper
import com.github.nacabaro.vbhelper.battle.extractSessionToken
import com.github.nacabaro.vbhelper.battle.extractUserId
import com.github.nacabaro.vbhelper.battle.AttackSpriteImage import com.github.nacabaro.vbhelper.battle.AttackSpriteImage
import com.github.nacabaro.vbhelper.battle.SpriteFileManager import com.github.nacabaro.vbhelper.battle.SpriteFileManager
import com.github.nacabaro.vbhelper.battle.ArenaBattleSystem import com.github.nacabaro.vbhelper.battle.ArenaBattleSystem
@ -74,7 +71,6 @@ 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 com.github.nacabaro.vbhelper.battle.BattleAuthContainer
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
@ -903,6 +899,7 @@ fun MiddleBattleView(
y = enemyVerticalOffset + 40.dp y = enemyVerticalOffset + 40.dp
), ),
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
reloadMappings = false,
animationOffset = 375L // Offset enemy animation by half the idle duration animationOffset = 375L // Offset enemy animation by half the idle duration
) )
@ -989,6 +986,7 @@ fun MiddleBattleView(
y = playerVerticalOffset - 40.dp y = playerVerticalOffset - 40.dp
), ),
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
reloadMappings = false,
animationOffset = 0L // Player animation starts immediately animationOffset = 0L // Player animation starts immediately
) )
@ -1074,30 +1072,19 @@ fun MiddleBattleView(
// Capture userId for use in this lambda // Capture userId for use in this lambda
val playerUserId = userId val playerUserId = userId
if (playerUserId == null) {
context?.let {
Toast.makeText(it, "Session missing user id. Please re-authenticate.", Toast.LENGTH_SHORT).show()
}
battleSystem.resetAttackState()
battleSystem.enableAttackButton()
return@Button
}
// Get crit bar progress as float (0.0f to 100.0f) // Get crit bar progress as float (0.0f to 100.0f)
val critBarProgressFloat = battleSystem.critBarProgress.toFloat() val critBarProgressFloat = battleSystem.critBarProgress.toFloat()
// Determine player and opponent stages // Determine player and opponent stages
// activeCharacter.stage is DB stage (2=rookie, 3=champion, 4=ultimate, 5=mega)
// API expects stage 0-3
val playerStage = when (activeCharacter?.stage) { val playerStage = when (activeCharacter?.stage) {
2 -> 0 // rookie 0 -> 0 // rookie
3 -> 1 // champion 1 -> 1 // champion
4 -> 2 // ultimate 2 -> 2 // ultimate
5 -> 3 // mega 3 -> 3 // mega
else -> 0 else -> 0
} }
// opponentCharacter.stage comes from the API and is already 0-3
val opponentStage = when (opponentCharacter?.stage) { val opponentStage = when (opponentCharacter?.stage) {
0 -> 0 // rookie 0 -> 0 // rookie
1 -> 1 // champion 1 -> 1 // champion
@ -1114,11 +1101,11 @@ fun MiddleBattleView(
RetrofitHelper().getPVPWinner( RetrofitHelper().getPVPWinner(
ctx, ctx,
1, 1,
playerUserId, playerUserId ?: 2L,
activeCharacter?.charaId ?: "dim011_mon01", activeCharacter?.name ?: "Player",
playerStage, playerStage,
battleSystem.critBarProgress, opponentStage,
opponentCharacter?.charaId ?: "dim011_mon01", opponentCharacter?.name ?: "Opponent",
opponentStage opponentStage
) { apiResult -> ) { apiResult ->
// Handle API response here // Handle API response here
@ -1322,6 +1309,7 @@ fun PlayerBattleView(
y = verticalOffset y = verticalOffset
), ),
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
reloadMappings = false,
animationOffset = 0L // Player animation starts immediately animationOffset = 0L // Player animation starts immediately
) )
@ -1503,6 +1491,7 @@ fun EnemyBattleView(
y = verticalOffset y = verticalOffset
), ),
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
reloadMappings = false,
animationOffset = 375L // Offset enemy animation by half the idle duration animationOffset = 375L // Offset enemy animation by half the idle duration
) )
@ -1652,63 +1641,8 @@ fun BattlesScreen() {
// Track processed tokens to prevent duplicate API calls // Track processed tokens to prevent duplicate API calls
// Use rememberSaveable to persist across configuration changes (screen rotation) // Use rememberSaveable to persist across configuration changes (screen rotation)
var processedTokens by rememberSaveable { mutableStateOf<Set<String>>(emptySet()) } var processedTokens by rememberSaveable { mutableStateOf<Set<String>>(emptySet()) }
var isProcessingAuthCallback by rememberSaveable { mutableStateOf(false) }
var authBrowserLaunchInFlight by rememberSaveable { mutableStateOf(false) }
var opponentsList by remember { mutableStateOf(ArrayList<APIBattleCharacter>()) } var opponentsList by remember { mutableStateOf(ArrayList<APIBattleCharacter>()) }
var isLoadingOpponents by remember { mutableStateOf(false) }
var authReadyToken by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) {
battleAuthContainer.authRepository.sessionToken
.combine(battleAuthContainer.authRepository.authToken) { sessionToken, nacatechToken ->
sessionToken?.takeIf { it.isNotBlank() }
?: nacatechToken?.takeIf { it.isNotBlank() }
}
.collect { token ->
authReadyToken = token
if (token.isNullOrBlank() && opponentsList.isNotEmpty()) {
opponentsList = ArrayList()
}
}
}
val openBattleAuthPage: () -> Unit = openAuth@{
if (isAuthenticated && !authReadyToken.isNullOrBlank()) {
println("BATTLESCREEN: Skipping auth browser launch because session is already ready")
return@openAuth
}
if (isProcessingAuthCallback) {
println("BATTLESCREEN: Skipping auth browser launch while callback is being processed")
return@openAuth
}
if (authBrowserLaunchInFlight) {
println("BATTLESCREEN: Auth browser launch already in flight, skipping duplicate request")
return@openAuth
}
val authUrl = "http://auth.nacatech.es/begin?app=443654920&redirect_uri=vbhelper://auth?token="
authBrowserLaunchInFlight = true
try {
val customTabsIntent = androidx.browser.customtabs.CustomTabsIntent.Builder()
.setShowTitle(true)
.build()
customTabsIntent.launchUrl(context, Uri.parse(authUrl))
println("BATTLESCREEN: Opened auth URL in Custom Tab: $authUrl")
} catch (e: Exception) {
authBrowserLaunchInFlight = false
println("BATTLESCREEN: Failed to open auth URL in Custom Tab: ${e.message}")
// Fall back to external browser if Custom Tabs is unavailable
try {
val fallbackIntent = Intent(Intent.ACTION_VIEW, Uri.parse(authUrl))
context.startActivity(fallbackIntent)
println("BATTLESCREEN: Fallback to external browser succeeded")
} catch (e2: Exception) {
println("BATTLESCREEN: Fallback browser also failed: ${e2.message}")
e2.printStackTrace()
}
}
}
var activeCharacter by remember { mutableStateOf<APIBattleCharacter?>(null) } var activeCharacter by remember { mutableStateOf<APIBattleCharacter?>(null) }
var selectedOpponent by remember { mutableStateOf<APIBattleCharacter?>(null) } var selectedOpponent by remember { mutableStateOf<APIBattleCharacter?>(null) }
@ -1790,85 +1724,39 @@ fun BattlesScreen() {
// Determine if player can battle based on stage (derived from activeUserCharacter) // Determine if player can battle based on stage (derived from activeUserCharacter)
val canBattle = activeUserCharacter?.stage?.let { it >= 2 } ?: false val canBattle = activeUserCharacter?.stage?.let { it >= 2 } ?: false
val loadOpponentsIfReady: () -> Unit = loadOpponents@{ // Load opponents automatically based on player's stage
val currentCharacter = activeUserCharacter // Only load if authenticated and character is ready
val readyToken = authReadyToken LaunchedEffect(activeUserCharacter, isAuthenticated) {
// Wait for authentication to complete before loading opponents
if (!isAuthenticated) { if (!isAuthenticated) {
if (opponentsList.isNotEmpty()) { return@LaunchedEffect
opponentsList = ArrayList()
}
return@loadOpponents
} }
if (readyToken.isNullOrBlank()) { val currentCharacter = activeUserCharacter
println("BATTLESCREEN: Skipping opponent load - auth token not persisted yet") if (currentCharacter != null && canBattle && playerBattleType != null) {
return@loadOpponents
}
if (isLoadingOpponents) {
println("BATTLESCREEN: Opponents load already in flight, skipping duplicate request")
return@loadOpponents
}
if (currentCharacter != null && currentCharacter.stage >= 2) {
val battleType = when (currentCharacter.stage) {
2 -> "rookie"
3 -> "champion"
4 -> "ultimate"
5 -> "mega"
else -> null
}
if (battleType == null) {
println("BATTLESCREEN: Stage ${currentCharacter.stage} has no battle type, skipping")
return@loadOpponents
}
try { try {
isLoadingOpponents = true RetrofitHelper().getOpponents(context, playerBattleType!!) { opponents ->
RetrofitHelper().getOpponents(context, battleType, onComplete = {
isLoadingOpponents = false
}) { opponents ->
try { try {
// Create a new list to trigger UI recomposition
opponentsList = ArrayList(opponents.opponentsList) opponentsList = ArrayList(opponents.opponentsList)
println("BATTLESCREEN: Loaded ${opponents.opponentsList.size} opponents for stage $battleType")
} catch (e: Exception) { } catch (e: Exception) {
Log.d(TAG, "Error processing opponents data: ${e.message}") Log.d(TAG, "Error processing opponents data: ${e.message}")
e.printStackTrace() e.printStackTrace()
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
isLoadingOpponents = false Log.d(TAG,"Error calling getOpponents: ${e.message}")
Log.d(TAG, "Error calling getOpponents: ${e.message}")
e.printStackTrace() e.printStackTrace()
} }
} else { } else {
if (opponentsList.isNotEmpty()) { println("BATTLESCREEN: Cannot load opponents - activeUserCharacter: $currentCharacter")
opponentsList = ArrayList() println("BATTLESCREEN: canBattle: $canBattle")
println("BATTLESCREEN: playerBattleType: $playerBattleType")
println("BATTLESCREEN: currentCharacter != null: ${currentCharacter != null}")
if (currentCharacter != null) {
println("BATTLESCREEN: currentCharacter.stage: ${currentCharacter.stage}")
println("BATTLESCREEN: currentCharacter.stage >= 2: ${currentCharacter.stage >= 2}")
} }
println("BATTLESCREEN: Cannot load opponents - stage ${currentCharacter?.stage}, must be >= 2")
}
}
// Load opponents automatically based on player's stage once auth is truly ready.
LaunchedEffect(activeUserCharacter, isAuthenticated, authReadyToken, playerBattleType) {
loadOpponentsIfReady()
}
// Re-fetch opponents when returning from browser (resume)
DisposableEffect(context, isAuthenticated, activeUserCharacter, canBattle, playerBattleType, authReadyToken) {
val activity = context as? ComponentActivity
val lifecycleOwner = activity as? LifecycleOwner
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
if (isAuthenticated && !authReadyToken.isNullOrBlank()) {
loadOpponentsIfReady()
}
}
}
lifecycleOwner?.lifecycle?.addObserver(observer)
onDispose {
lifecycleOwner?.lifecycle?.removeObserver(observer)
} }
} }
@ -1887,19 +1775,17 @@ fun BattlesScreen() {
if (!processedTokens.contains(token)) { if (!processedTokens.contains(token)) {
// Mark token as being processed IMMEDIATELY to prevent duplicate API calls // Mark token as being processed IMMEDIATELY to prevent duplicate API calls
processedTokens = processedTokens + token processedTokens = processedTokens + token
isProcessingAuthCallback = true
authBrowserLaunchInFlight = false
// Exchange token with battle server // Exchange token with battle server
RetrofitHelper().authenticate(context, token) { response -> RetrofitHelper().authenticate(context, token) { response ->
if (response.success) { if (response.success) {
// Extract userId and sessionToken from response // Extract userId and sessionToken from response
val extractedUserId = response.extractUserId() val extractedUserId = response.userInfo?.userId?.toLongOrNull()
val sessionToken = response.extractSessionToken() val sessionToken = response.sessionToken
println("BATTLESCREEN: Authentication successful, userId: $extractedUserId, sessionToken: ${if (sessionToken != null) "present" else "missing"}") println("BATTLESCREEN: Authentication successful, userId: $extractedUserId, sessionToken: ${if (sessionToken != null) "present" else "missing"}")
// Persist auth first so follow-up API calls can immediately use the token. // Store both nacatech token (for re-auth) and sessionToken (for API calls)
kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch { kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch {
battleAuthContainer.authRepository.setAuthenticated( battleAuthContainer.authRepository.setAuthenticated(
isAuthenticated = true, isAuthenticated = true,
@ -1907,17 +1793,15 @@ fun BattlesScreen() {
sessionToken = sessionToken, sessionToken = sessionToken,
userId = extractedUserId userId = extractedUserId
) )
}
withContext(Dispatchers.Main) { // Update UI state on main thread
kotlinx.coroutines.CoroutineScope(Dispatchers.Main).launch {
isAuthenticated = true isAuthenticated = true
isCheckingAuth = false isCheckingAuth = false
userId = extractedUserId userId = extractedUserId
isProcessingAuthCallback = false
authBrowserLaunchInFlight = false
println("BATTLESCREEN: Authentication successful, userId: $extractedUserId") println("BATTLESCREEN: Authentication successful, userId: $extractedUserId")
android.widget.Toast.makeText(context, "Authentication successful!", android.widget.Toast.LENGTH_SHORT).show() android.widget.Toast.makeText(context, "Authentication successful!", android.widget.Toast.LENGTH_SHORT).show()
} }
}
} else { } else {
println("BATTLESCREEN: Authentication failed: ${response.message}") println("BATTLESCREEN: Authentication failed: ${response.message}")
// If it's an "Invalid user nonce" error, the token was already used - keep it marked to prevent retries // If it's an "Invalid user nonce" error, the token was already used - keep it marked to prevent retries
@ -1931,16 +1815,23 @@ fun BattlesScreen() {
kotlinx.coroutines.CoroutineScope(Dispatchers.Main).launch { kotlinx.coroutines.CoroutineScope(Dispatchers.Main).launch {
isAuthenticated = false isAuthenticated = false
isCheckingAuth = false isCheckingAuth = false
isProcessingAuthCallback = false // Small delay to ensure state is updated
authBrowserLaunchInFlight = false kotlinx.coroutines.delay(100)
println("BATTLESCREEN: Auth callback token was already used; avoiding automatic browser relaunch loop") // 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()
}
} }
} else { } else {
// For other errors, remove from processed set to allow retry with a new token // For other errors, remove from processed set to allow retry with a new token
println("BATTLESCREEN: Authentication failed, removing token from processed set to allow retry") println("BATTLESCREEN: Authentication failed, removing token from processed set to allow retry")
processedTokens = processedTokens - token processedTokens = processedTokens - token
isProcessingAuthCallback = false
authBrowserLaunchInFlight = false
} }
// Show toast on main thread // Show toast on main thread
kotlinx.coroutines.CoroutineScope(Dispatchers.Main).launch { kotlinx.coroutines.CoroutineScope(Dispatchers.Main).launch {
@ -1948,47 +1839,12 @@ fun BattlesScreen() {
} }
} }
} }
} else {
println("BATTLESCREEN: Ignoring already processed auth token")
} }
} else { } else {
println("BATTLESCREEN: No token found in URI: $uri (checked 'c' and 'token' parameters)") println("BATTLESCREEN: No token found in URI: $uri (checked 'c' and 'token' parameters)")
} }
} }
val consumeAuthCallbackIntent: () -> Boolean = consume@{
val currentActivity = context as? ComponentActivity ?: return@consume false
val incomingIntent = currentActivity.intent ?: return@consume false
if (incomingIntent.action != Intent.ACTION_VIEW) return@consume false
val uri = incomingIntent.data ?: return@consume false
val isAppAuthCallback = uri.scheme == "vbhelper" && uri.host == "auth"
val isLocalhostAuthCallback =
(uri.scheme == "http" || uri.scheme == "https") &&
(uri.host == "localhost" || uri.host == "127.0.0.1") &&
uri.path?.startsWith("/authenticate") == true
val hasAuthToken = !uri.getQueryParameter("c").isNullOrEmpty() ||
!uri.getQueryParameter("token").isNullOrEmpty()
if (!hasAuthToken) {
return@consume false
}
if (!isAppAuthCallback && !isLocalhostAuthCallback) {
return@consume false
}
handleTokenFromUri(uri)
// Clear callback intent so reopening Battles does not replay stale deep links.
currentActivity.setIntent(Intent(incomingIntent).apply {
action = Intent.ACTION_MAIN
data = null
})
true
}
// Check authentication status on load // Check authentication status on load
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
try { try {
@ -1996,7 +1852,6 @@ fun BattlesScreen() {
val localAuthState = authRepository.isAuthenticated.first() val localAuthState = authRepository.isAuthenticated.first()
val storedToken = authRepository.authToken.first() val storedToken = authRepository.authToken.first()
val storedUserId = authRepository.userId.first() val storedUserId = authRepository.userId.first()
val storedSessionToken = authRepository.sessionToken.first()
// Load stored userId if available // Load stored userId if available
if (storedUserId != null) { if (storedUserId != null) {
@ -2012,35 +1867,40 @@ fun BattlesScreen() {
userId = storedUserId userId = storedUserId
} }
if (consumeAuthCallbackIntent()) { // Only check for token in intent if it's a fresh deep link (ACTION_VIEW intent)
return@LaunchedEffect // 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 ->
if (uri.getQueryParameter("c") != null || uri.getQueryParameter("token") != null) {
handleTokenFromUri(uri)
return@LaunchedEffect // Don't open auth URL if we're processing a token
}
}
} }
// If we have a stored token and userId, assume session is still active // If we have a stored token and userId, assume session is still active
// The single-use token can't be re-validated, but the server maintains a session after initial validation // The single-use token can't be re-validated, but the server maintains a session after initial validation
// We'll only re-authenticate if API calls fail with authentication errors // We'll only re-authenticate if API calls fail with authentication errors
if (localAuthState && storedToken != null && storedToken.isNotEmpty() && (storedUserId != null || !storedSessionToken.isNullOrEmpty())) { if (localAuthState && storedToken != null && storedToken.isNotEmpty() && storedUserId != null) {
// Session appears to be active - user is already authenticated // Session appears to be active - user is already authenticated
// No need to re-validate the single-use token, just restore the session // No need to re-validate the single-use token, just restore the session
println("BATTLESCREEN: Restoring active session (userId: $storedUserId, sessionTokenPresent=${!storedSessionToken.isNullOrEmpty()})") println("BATTLESCREEN: Restoring active session (userId: $storedUserId)")
isAuthenticated = true isAuthenticated = true
isCheckingAuth = false isCheckingAuth = false
userId = storedUserId userId = storedUserId
// Session is restored, no need to validate token again // Session is restored, no need to validate token again
} else if (localAuthState && storedToken != null && storedToken.isNotEmpty() && storedSessionToken.isNullOrEmpty()) { } else if (localAuthState && storedToken != null && storedToken.isNotEmpty()) {
// We have a token but no userId - try to validate once to get userId // We have a token but no userId - try to validate once to get userId
// This should only happen on first login or if userId was lost // This should only happen on first login or if userId was lost
println("BATTLESCREEN: Have token but no userId, validating once to get userId...") println("BATTLESCREEN: Have token but no userId, validating once to get userId...")
RetrofitHelper().authenticate( RetrofitHelper().authenticate(context, storedToken) { response ->
context = context,
token = storedToken,
showUserFacingErrors = false,
) { response ->
// Update UI on main thread // Update UI on main thread
kotlinx.coroutines.CoroutineScope(Dispatchers.Main).launch { kotlinx.coroutines.CoroutineScope(Dispatchers.Main).launch {
if (response.success) { if (response.success) {
val extractedUserId = response.extractUserId() val extractedUserId = response.userInfo?.userId?.toLongOrNull()
val sessionToken = response.extractSessionToken() val sessionToken = response.sessionToken
// Update stored userId and sessionToken // Update stored userId and sessionToken
if (extractedUserId != null) { if (extractedUserId != null) {
@ -2064,22 +1924,8 @@ fun BattlesScreen() {
response.message?.contains("nonce") == true || response.message?.contains("nonce") == true ||
response.message?.contains("invalid") == true || response.message?.contains("invalid") == true ||
response.message?.contains("expired") == true response.message?.contains("expired") == true
val latestSessionToken = withContext(Dispatchers.IO) {
authRepository.sessionToken.first()
}
val latestUserId = withContext(Dispatchers.IO) {
authRepository.userId.first()
}
if (isCriticalError) { if (isCriticalError) {
// A parallel callback auth can complete before this fallback check returns.
if (!latestSessionToken.isNullOrEmpty() || latestUserId != null) {
println("BATTLESCREEN: Ignoring nonce validation failure because a valid session already exists")
isAuthenticated = true
isCheckingAuth = false
userId = latestUserId ?: userId
return@launch
}
// Critical error - token is invalid, need to re-authenticate // Critical error - token is invalid, need to re-authenticate
println("BATTLESCREEN: Critical authentication error, clearing state and redirecting") println("BATTLESCREEN: Critical authentication error, clearing state and redirecting")
kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch { kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch {
@ -2087,7 +1933,11 @@ fun BattlesScreen() {
} }
isAuthenticated = false isAuthenticated = false
isCheckingAuth = false isCheckingAuth = false
openBattleAuthPage() // 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 critical validation failure: $authUrl")
} else { } else {
// Non-critical error (e.g., network issue) - keep authenticated state // Non-critical error (e.g., network issue) - keep authenticated state
println("BATTLESCREEN: Non-critical validation error, keeping authenticated state") println("BATTLESCREEN: Non-critical validation error, keeping authenticated state")
@ -2102,7 +1952,10 @@ fun BattlesScreen() {
isAuthenticated = false isAuthenticated = false
isCheckingAuth = false isCheckingAuth = false
// If not authenticated and no fresh token in intent, open auth URL // If not authenticated and no fresh token in intent, open auth URL
openBattleAuthPage() 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) { } catch (e: Exception) {
println("BATTLESCREEN: Error checking authentication status: ${e.message}") println("BATTLESCREEN: Error checking authentication status: ${e.message}")
@ -2111,11 +1964,42 @@ fun BattlesScreen() {
} }
} }
// Handle deep link callback once on initial load and consume it. // 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) { LaunchedEffect(Unit) {
// Small delay to ensure activity is fully initialized // Small delay to ensure activity is fully initialized
kotlinx.coroutines.delay(100) kotlinx.coroutines.delay(100)
consumeAuthCallbackIntent()
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) {
// 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)
} 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 // Check intent when screen becomes visible or when authentication state changes
@ -2125,10 +2009,15 @@ fun BattlesScreen() {
val lifecycleOwner = activity as? LifecycleOwner val lifecycleOwner = activity as? LifecycleOwner
val observer = LifecycleEventObserver { _, event -> val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) { if (event == Lifecycle.Event.ON_RESUME && !isAuthenticated) {
authBrowserLaunchInFlight = false // Check intent data when activity resumes - only if it's a fresh ACTION_VIEW intent
if (!isAuthenticated) { val intent = activity?.intent
consumeAuthCallbackIntent() if (intent?.action == Intent.ACTION_VIEW) {
intent.data?.let { uri ->
if (uri.getQueryParameter("c") != null || uri.getQueryParameter("token") != null) {
handleTokenFromUri(uri)
}
}
} }
} }
} }
@ -2149,16 +2038,45 @@ fun BattlesScreen() {
isAuthenticated = false isAuthenticated = false
isCheckingAuth = false isCheckingAuth = false
// Open auth URL to get a fresh token // Open auth URL to get a fresh token
openBattleAuthPage() 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 for callback when auth state changes. // Also check intent when authentication state changes
// Only process if it's a fresh ACTION_VIEW intent (deep link)
LaunchedEffect(isAuthenticated) { LaunchedEffect(isAuthenticated) {
if (!isAuthenticated) { if (!isAuthenticated) {
kotlinx.coroutines.delay(200) // Small delay to ensure intent is available kotlinx.coroutines.delay(200) // Small delay to ensure intent is available
consumeAuthCallbackIntent() 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)
}
}
}
} }
} }
@ -2166,8 +2084,9 @@ fun BattlesScreen() {
// Only check if permission is granted // Only check if permission is granted
LaunchedEffect(hasStoragePermission) { LaunchedEffect(hasStoragePermission) {
if (hasStoragePermission) { if (hasStoragePermission) {
val spriteFileManager = SpriteFileManager() val spriteFileManager = SpriteFileManager(context)
if (!spriteFileManager.checkSpriteFilesExist()) { if (spriteFileManager.checkSpriteFilesExist()) {
} else {
println("BATTLESCREEN: Sprite files not found in external storage") println("BATTLESCREEN: Sprite files not found in external storage")
} }
} else { } else {
@ -2314,10 +2233,6 @@ fun BattlesScreen() {
color = Color.Gray, color = Color.Gray,
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { openBattleAuthPage() }) {
Text("Open Login")
}
} }
} }
} }
@ -2352,12 +2267,6 @@ fun BattlesScreen() {
items(opponentsList) { opponent -> items(opponentsList) { opponent ->
Button( Button(
onClick = { onClick = {
val currentUserId = userId
if (currentUserId == null) {
Toast.makeText(context, "Session missing user id. Please re-authenticate.", Toast.LENGTH_SHORT).show()
return@Button
}
activeCardId?.let { cardId -> activeCardId?.let { cardId ->
selectedOpponent = opponent selectedOpponent = opponent
// Randomly select background set (0, 1, or 2) // Randomly select background set (0, 1, or 2)
@ -2372,7 +2281,7 @@ fun BattlesScreen() {
else -> 0 else -> 0
} }
RetrofitHelper().getPVPWinner(context, 0, currentUserId, cardId, apiStage, 0, opponent.charaId, apiStage) { apiResult -> RetrofitHelper().getPVPWinner(context, 0, userId ?: 2L, cardId, apiStage, 0, opponent.charaId, apiStage) { apiResult ->
// Check if there's an existing match // Check if there's an existing match
when { when {
apiResult.status.contains("Existing match found", ignoreCase = true) -> { apiResult.status.contains("Existing match found", ignoreCase = true) -> {
@ -2473,17 +2382,14 @@ fun BattlesScreen() {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
// Determine player and opponent stages // Determine player and opponent stages
// activeCharacter.stage is DB stage (2=rookie, 3=champion, 4=ultimate, 5=mega)
// API expects stage 0-3
val playerStage = when (activeCharacter?.stage) { val playerStage = when (activeCharacter?.stage) {
2 -> 0 // rookie 0 -> 0 // rookie
3 -> 1 // champion 1 -> 1 // champion
4 -> 2 // ultimate 2 -> 2 // ultimate
5 -> 3 // mega 3 -> 3 // mega
else -> 0 else -> 0
} }
// selectedOpponent.stage comes from the API and is already 0-3
val opponentStage = when (selectedOpponent?.stage) { val opponentStage = when (selectedOpponent?.stage) {
0 -> 0 // rookie 0 -> 0 // rookie
1 -> 1 // champion 1 -> 1 // champion
@ -2496,16 +2402,11 @@ fun BattlesScreen() {
RetrofitHelper().getPVPWinner( RetrofitHelper().getPVPWinner(
context, context,
1, 1,
userId ?: run { userId ?: 2L,
Toast.makeText(context, "Session missing user id. Please re-authenticate.", Toast.LENGTH_SHORT).show() activeCharacter?.name ?: "Player",
isWinnerLoaded = true
winnerName = "Unknown"
return@LaunchedEffect
},
activeCharacter?.charaId ?: "dim011_mon01",
playerStage, playerStage,
0, opponentStage,
selectedOpponent?.charaId ?: "dim011_mon01", selectedOpponent?.name ?: "Opponent",
opponentStage opponentStage
) { apiResult -> ) { apiResult ->
// Winner might be empty in first call, but we can check HP values // Winner might be empty in first call, but we can check HP values
@ -2514,7 +2415,7 @@ fun BattlesScreen() {
// Also check winner field if it's not empty // Also check winner field if it's not empty
val playerWonFromWinner = activeCardId?.let { cardId -> val playerWonFromWinner = activeCardId?.let { cardId ->
val winner = apiResult.winner val winner = apiResult.winner ?: ""
if (winner.isNotEmpty()) { if (winner.isNotEmpty()) {
if (winner.contains("|")) { if (winner.contains("|")) {
// Pipe-separated format: first part is the winner // Pipe-separated format: first part is the winner
@ -2541,21 +2442,18 @@ fun BattlesScreen() {
println("BATTLESCREEN: Battle result (first call) - winner: '${apiResult.winner}', playerHP: ${apiResult.playerHP}, opponentHP: ${apiResult.opponentHP}, playerWonFromHP: $playerWonFromHP, playerWonFromWinner: $playerWonFromWinner, final playerWon: $playerWon") println("BATTLESCREEN: Battle result (first call) - winner: '${apiResult.winner}', playerHP: ${apiResult.playerHP}, opponentHP: ${apiResult.opponentHP}, playerWonFromHP: $playerWonFromHP, playerWonFromWinner: $playerWonFromWinner, final playerWon: $playerWon")
// Store winner name for display (will be updated in cleanup call if available) // Store winner name for display (will be updated in cleanup call if available)
winnerName = if (apiResult.winner.isNotEmpty()) apiResult.winner else if (playerWon) "You" else "Opponent" winnerName = apiResult.winner ?: if (playerWon) "You" else "Opponent"
isWinnerLoaded = true isWinnerLoaded = true
// Then send the cleanup call - this will have the actual winner name // Then send the cleanup call - this will have the actual winner name
RetrofitHelper().getPVPWinner( RetrofitHelper().getPVPWinner(
context, context,
2, 2,
userId ?: run { userId ?: 2L,
Toast.makeText(context, "Session missing user id. Please re-authenticate.", Toast.LENGTH_SHORT).show() activeCharacter?.name ?: "Player",
return@getPVPWinner
},
activeCharacter?.charaId ?: "dim011_mon01",
playerStage, playerStage,
0, opponentStage,
selectedOpponent?.charaId ?: "dim011_mon01", selectedOpponent?.name ?: "Opponent",
opponentStage opponentStage
) { cleanupResult -> ) { cleanupResult ->
// Update winner name from cleanup call if available // Update winner name from cleanup call if available
@ -2567,7 +2465,7 @@ fun BattlesScreen() {
// Primary method: Check HP values (opponentHP <= 0 means opponent lost = player won) // Primary method: Check HP values (opponentHP <= 0 means opponent lost = player won)
// Secondary method: Check winner name (if winner doesn't match opponent, player won) // Secondary method: Check winner name (if winner doesn't match opponent, player won)
val opponentName = selectedOpponent?.name ?: "" val opponentName = selectedOpponent?.name ?: ""
val winner = cleanupResult.winner val winner = cleanupResult.winner ?: ""
// Primary: HP-based determination (most reliable) // Primary: HP-based determination (most reliable)
// If opponentHP <= 0, opponent is dead = player won // If opponentHP <= 0, opponent is dead = player won
@ -2704,12 +2602,7 @@ fun BattlesScreen() {
RetrofitHelper().getPVPWinner( RetrofitHelper().getPVPWinner(
context, context,
0, 0,
userId ?: run { userId ?: 2L,
Toast.makeText(context, "Session missing user id. Please re-authenticate.", Toast.LENGTH_SHORT).show()
showResumeDialog = false
existingMatchState = null
return@let
},
cardId, cardId,
apiStage, apiStage,
0, 0,
@ -2834,12 +2727,7 @@ fun BattlesScreen() {
RetrofitHelper().getPVPWinner( RetrofitHelper().getPVPWinner(
context, context,
0, 0,
userId ?: run { userId ?: 2L,
Toast.makeText(context, "Session missing user id. Please re-authenticate.", Toast.LENGTH_SHORT).show()
showResumeDialog = false
existingMatchState = null
return@let
},
cardId, cardId,
apiStage, apiStage,
0, 0,

View File

@ -33,12 +33,16 @@ import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData
import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData
import com.github.nacabaro.vbhelper.dtos.ItemDtos import com.github.nacabaro.vbhelper.dtos.ItemDtos
import com.github.nacabaro.vbhelper.navigation.NavigationItems import com.github.nacabaro.vbhelper.navigation.NavigationItems
import com.github.nacabaro.vbhelper.screens.homeScreens.screens.BEBEmHomeScreen
import com.github.nacabaro.vbhelper.screens.homeScreens.screens.BEDiMHomeScreen
import com.github.nacabaro.vbhelper.screens.homeScreens.screens.VBDiMHomeScreen import com.github.nacabaro.vbhelper.screens.homeScreens.screens.VBDiMHomeScreen
import com.github.nacabaro.vbhelper.screens.itemsScreen.ObtainedItemDialog import com.github.nacabaro.vbhelper.screens.itemsScreen.ObtainedItemDialog
import com.github.nacabaro.vbhelper.source.StorageRepository import com.github.nacabaro.vbhelper.source.StorageRepository
import com.github.nacabaro.vbhelper.R import com.github.nacabaro.vbhelper.R
import com.github.nacabaro.vbhelper.dtos.CardDtos import com.github.nacabaro.vbhelper.dtos.CardDtos
import com.github.nacabaro.vbhelper.source.CardRepository import com.github.nacabaro.vbhelper.source.CardRepository
import com.github.nacabaro.vbhelper.source.VitalWearCharacterExporter
import android.widget.Toast
import com.github.nacabaro.vbhelper.utils.BitmapData import com.github.nacabaro.vbhelper.utils.BitmapData
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlin.collections.emptyList import kotlin.collections.emptyList
@ -72,8 +76,9 @@ fun HomeScreen(
?: flowOf(emptyList()) ?: flowOf(emptyList())
).collectAsState(initial = emptyList()) ).collectAsState(initial = emptyList())
val specialMissions by ( val vbSpecialMissions by (
activeMon activeMon
?.takeIf { it.characterType == DeviceType.VBDevice }
?.let { chara -> ?.let { chara ->
storageRepository.getSpecialMissions(chara.id) storageRepository.getSpecialMissions(chara.id)
} }
@ -141,20 +146,29 @@ fun HomeScreen(
height = cardIconData!!.cardIconHeight height = cardIconData!!.cardIconHeight
) )
val activeCharacter = activeMon!! if (activeMon!!.isBemCard && beData != null) {
val displaySpecialMissions = specialMissions BEBEmHomeScreen(
activeMon = activeMon!!,
if (activeCharacter.characterType == DeviceType.BEDevice || vbData != null) { beData = beData!!,
VBDiMHomeScreen(
activeMon = activeCharacter,
vbData = vbData ?: VBCharacterData(
id = activeCharacter.id,
generation = 0,
totalTrophies = activeCharacter.trophies,
),
transformationHistory = transformationHistory, transformationHistory = transformationHistory,
contentPadding = PaddingValues(0.dp), contentPadding = PaddingValues(0.dp),
specialMissions = displaySpecialMissions, cardIcon = cardIcon
)
} else if (!activeMon!!.isBemCard && activeMon!!.characterType == DeviceType.BEDevice && beData != null) {
BEDiMHomeScreen(
activeMon = activeMon!!,
beData = beData!!,
transformationHistory = transformationHistory,
contentPadding = PaddingValues(0.dp),
cardIcon = cardIcon
)
} else if (vbData != null) {
VBDiMHomeScreen(
activeMon = activeMon!!,
vbData = vbData!!,
transformationHistory = transformationHistory,
contentPadding = PaddingValues(0.dp),
specialMissions = vbSpecialMissions,
homeScreenController = homeScreenController, homeScreenController = homeScreenController,
onClickCollect = { item, currency -> onClickCollect = { item, currency ->
collectedItem = item collectedItem = item
@ -164,6 +178,27 @@ fun HomeScreen(
) )
} }
Button(
onClick = {
try {
val intent = VitalWearCharacterExporter(application, application.container.db)
.buildShareIntent(activeMon!!.id)
.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
application.startActivity(intent)
} catch (e: Exception) {
Toast.makeText(
application,
"Could not send character to VitalWear: ${e.message}",
Toast.LENGTH_LONG
).show()
}
},
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(text = "Send to VitalWear")
}
} }
} }
} }
@ -213,5 +248,3 @@ fun HomeScreen(
} }
} }
} }

View File

@ -8,17 +8,12 @@ import androidx.compose.foundation.layout.size
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.github.nacabaro.vbhelper.dtos.ItemDtos import com.github.nacabaro.vbhelper.dtos.ItemDtos
import com.github.nacabaro.vbhelper.domain.items.ItemType
import com.github.nacabaro.vbhelper.R import com.github.nacabaro.vbhelper.R
@Composable @Composable
@ -33,12 +28,6 @@ fun ItemElement(
.aspectRatio(1f) .aspectRatio(1f)
) { ) {
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
ItemCompatibilityBadge(
itemType = item.itemType,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp)
)
Icon( Icon(
painter = painterResource(id = getIconResource(item.itemIcon)), painter = painterResource(id = getIconResource(item.itemIcon)),
contentDescription = null, contentDescription = null,
@ -59,48 +48,3 @@ fun ItemElement(
} }
} }
} }
@Composable
private fun ItemCompatibilityBadge(
itemType: ItemType,
modifier: Modifier = Modifier,
) {
val (label, containerColor, contentColor) = when (itemType) {
ItemType.VBITEM -> Triple(
"VB",
MaterialTheme.colorScheme.primaryContainer,
MaterialTheme.colorScheme.onPrimaryContainer,
)
ItemType.BEITEM -> Triple(
"BE",
MaterialTheme.colorScheme.tertiaryContainer,
MaterialTheme.colorScheme.onTertiaryContainer,
)
ItemType.UNIVERSAL -> Triple(
"VB+BE",
MaterialTheme.colorScheme.secondaryContainer,
MaterialTheme.colorScheme.onSecondaryContainer,
)
ItemType.SPECIALMISSION -> Triple(
"MISSION",
MaterialTheme.colorScheme.errorContainer,
MaterialTheme.colorScheme.onErrorContainer,
)
}
Surface(
modifier = modifier,
shape = RoundedCornerShape(12.dp),
color = containerColor,
tonalElevation = 2.dp,
shadowElevation = 1.dp,
) {
Text(
text = label,
color = contentColor,
fontSize = 10.sp,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
)
}
}

View File

@ -6,7 +6,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Tab import androidx.compose.material3.Tab
import androidx.compose.material3.PrimaryTabRow import androidx.compose.material3.TabRow
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -34,7 +34,7 @@ fun ItemsScreen(
topBar = { topBar = {
Column { Column {
TopBanner(text = stringResource(R.string.items_title)) TopBanner(text = stringResource(R.string.items_title))
PrimaryTabRow( TabRow(
selectedTabIndex = selectedTabItem, selectedTabIndex = selectedTabItem,
modifier = Modifier modifier = Modifier
) { ) {

View File

@ -7,6 +7,7 @@ import com.github.nacabaro.vbhelper.domain.device_data.SpecialMissions
import com.github.nacabaro.vbhelper.database.AppDatabase import com.github.nacabaro.vbhelper.database.AppDatabase
import com.github.nacabaro.vbhelper.di.VBHelper import com.github.nacabaro.vbhelper.di.VBHelper
import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData
import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData
import com.github.nacabaro.vbhelper.dtos.ItemDtos import com.github.nacabaro.vbhelper.dtos.ItemDtos
import com.github.nacabaro.vbhelper.utils.DeviceType import com.github.nacabaro.vbhelper.utils.DeviceType
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -50,12 +51,19 @@ class ItemsScreenControllerImpl (
val item = getItem(itemId) val item = getItem(itemId)
val characterData = database.userCharacterDao().getCharacter(characterId) val characterData = database.userCharacterDao().getCharacter(characterId)
var beCharacterData: BECharacterData? = null var beCharacterData: BECharacterData? = null
var vbCharacterData: VBCharacterData? = null
if (characterData.characterType == DeviceType.BEDevice) { if (characterData.characterType == DeviceType.BEDevice) {
beCharacterData = database beCharacterData = database
.userCharacterDao() .userCharacterDao()
.getBeData(characterId) .getBeData(characterId)
.firstOrNull() .firstOrNull()
} else if (characterData.characterType == DeviceType.VBDevice) {
vbCharacterData = database
.userCharacterDao()
.getVbData(characterId)
.firstOrNull()
} }
if ( if (
@ -114,7 +122,8 @@ class ItemsScreenControllerImpl (
.updateCharacter(characterData) .updateCharacter(characterData)
} else if (item.itemIcon in ItemTypes.Step8k.id .. ItemTypes.Win4.id && } else if (item.itemIcon in ItemTypes.Step8k.id .. ItemTypes.Win4.id &&
(characterData.characterType == DeviceType.VBDevice || characterData.characterType == DeviceType.BEDevice) characterData.characterType == DeviceType.VBDevice &&
vbCharacterData != null
) { ) {
applySpecialMission(item.itemIcon, item.itemLength, characterId) applySpecialMission(item.itemIcon, item.itemLength, characterId)
} }
@ -171,12 +180,12 @@ class ItemsScreenControllerImpl (
.getSpecialMissions(characterId) .getSpecialMissions(characterId)
.first() .first()
val existingMission = availableSpecialMissions.firstOrNull { it.watchId == specialMissionSlot } var newSpecialMission = availableSpecialMissions[specialMissionSlot]
val newSpecialMission = SpecialMissions( newSpecialMission = SpecialMissions(
id = existingMission?.id ?: 0, id = newSpecialMission.id,
characterId = characterId, characterId = newSpecialMission.characterId,
goal = specialMissionGoal, goal = specialMissionGoal,
watchId = specialMissionSlot, watchId = newSpecialMission.watchId,
progress = 0, progress = 0,
status = SpecialMission.Status.AVAILABLE, status = SpecialMission.Status.AVAILABLE,
timeElapsedInMinutes = 0, timeElapsedInMinutes = 0,

View File

@ -43,7 +43,7 @@ fun ChooseConnectOption(
.padding(contentPadding) .padding(contentPadding)
) { ) {
Text( Text(
text = "VitalWear/VBH", text = "Bandai Toys = Vital Bracelet, Vital Hero, BE Bracelet",
fontSize = 14.sp, fontSize = 14.sp,
modifier = Modifier.padding(bottom = 24.dp) modifier = Modifier.padding(bottom = 24.dp)
) )

View File

@ -24,7 +24,6 @@ import com.github.nacabaro.vbhelper.source.isMissingSecrets
import com.github.nacabaro.vbhelper.source.proto.Secrets import com.github.nacabaro.vbhelper.source.proto.Secrets
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import com.github.nacabaro.vbhelper.R import com.github.nacabaro.vbhelper.R
@ -132,8 +131,6 @@ fun ScanScreenPreview() {
navController = rememberNavController(), navController = rememberNavController(),
scanScreenController = object: ScanScreenController { scanScreenController = object: ScanScreenController {
override val secretsFlow = MutableStateFlow<Secrets>(Secrets.getDefaultInstance()) override val secretsFlow = MutableStateFlow<Secrets>(Secrets.getDefaultInstance())
override val detectedTransportFlow: StateFlow<DetectedTransport> = MutableStateFlow(DetectedTransport.UNKNOWN)
override val transferStatusFlow: StateFlow<String?> = MutableStateFlow(null)
override fun unregisterActivityLifecycleListener(key: String) { } override fun unregisterActivityLifecycleListener(key: String) { }
override fun registerActivityLifecycleListener( override fun registerActivityLifecycleListener(
key: String, key: String,
@ -144,12 +141,7 @@ fun ScanScreenPreview() {
override fun flushCharacter(cardId: Long) {} override fun flushCharacter(cardId: Long) {}
override fun onClickRead(secrets: Secrets, onComplete: ()->Unit, onMultipleCards: (List<Card>) -> Unit) {} override fun onClickRead(secrets: Secrets, onComplete: ()->Unit, onMultipleCards: (List<Card>) -> Unit) {}
override fun onClickCheckCard(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: () -> Unit) {} override fun onClickCheckCard(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: () -> Unit) {}
override fun onClickWrite( override fun onClickWrite(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: () -> Unit) {}
secrets: Secrets,
nfcCharacter: NfcCharacter,
characterId: Long?,
onComplete: (ScanScreenController.WriteResult) -> Unit
) {}
override fun cancelRead() {} override fun cancelRead() {}
override fun characterFromNfc(nfcCharacter: NfcCharacter, onMultipleCards: (List<Card>, NfcCharacter) -> Unit): String { return "" } override fun characterFromNfc(nfcCharacter: NfcCharacter, onMultipleCards: (List<Card>, NfcCharacter) -> Unit): String { return "" }
override suspend fun characterToNfc(characterId: Long): NfcCharacter? { return null } override suspend fun characterToNfc(characterId: Long): NfcCharacter? { return null }

View File

@ -5,27 +5,12 @@ import com.github.nacabaro.vbhelper.ActivityLifecycleListener
import com.github.nacabaro.vbhelper.domain.card.Card import com.github.nacabaro.vbhelper.domain.card.Card
import com.github.nacabaro.vbhelper.source.proto.Secrets import com.github.nacabaro.vbhelper.source.proto.Secrets
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
interface ScanScreenController { interface ScanScreenController {
val secretsFlow: Flow<Secrets> val secretsFlow: Flow<Secrets>
val detectedTransportFlow: StateFlow<DetectedTransport>
val transferStatusFlow: StateFlow<String?>
enum class WriteResult {
MOVE_CONFIRMED,
COPIED,
BLOCKED_DEVICE_FULL,
}
fun onClickRead(secrets: Secrets, onComplete: ()->Unit, onMultipleCards: (List<Card>) -> Unit) fun onClickRead(secrets: Secrets, onComplete: ()->Unit, onMultipleCards: (List<Card>) -> Unit)
fun onClickCheckCard(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: () -> Unit) fun onClickCheckCard(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: () -> Unit)
fun onClickWrite( fun onClickWrite(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: () -> Unit)
secrets: Secrets,
nfcCharacter: NfcCharacter,
characterId: Long? = null,
onComplete: (WriteResult) -> Unit
)
fun cancelRead() fun cancelRead()

View File

@ -3,7 +3,6 @@ package com.github.nacabaro.vbhelper.screens.scanScreen
import android.content.Intent import android.content.Intent
import android.nfc.NfcAdapter import android.nfc.NfcAdapter
import android.nfc.Tag import android.nfc.Tag
import android.nfc.tech.IsoDep
import android.nfc.tech.NfcA import android.nfc.tech.NfcA
import android.os.Bundle import android.os.Bundle
import android.provider.Settings import android.provider.Settings
@ -12,34 +11,21 @@ import android.widget.Toast
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.github.cfogrady.vbnfc.TagCommunicator import com.github.cfogrady.vbnfc.TagCommunicator
import com.github.cfogrady.vitalwear.protos.Character
import com.github.cfogrady.vbnfc.be.BENfcCharacter import com.github.cfogrady.vbnfc.be.BENfcCharacter
import com.github.cfogrady.vbnfc.data.NfcCharacter import com.github.cfogrady.vbnfc.data.NfcCharacter
import com.github.cfogrady.vbnfc.data.DeviceType as NfcDeviceTypeId
import com.github.cfogrady.vbnfc.vb.VBNfcCharacter import com.github.cfogrady.vbnfc.vb.VBNfcCharacter
import com.github.nacabaro.vbhelper.ActivityLifecycleListener import com.github.nacabaro.vbhelper.ActivityLifecycleListener
import com.github.nacabaro.vbhelper.domain.card.Card import com.github.nacabaro.vbhelper.domain.card.Card
import com.github.nacabaro.vbhelper.di.VBHelper
import com.github.nacabaro.vbhelper.screens.scanScreen.converters.FromNfcConverter import com.github.nacabaro.vbhelper.screens.scanScreen.converters.FromNfcConverter
import com.github.nacabaro.vbhelper.screens.scanScreen.converters.ToNfcConverter import com.github.nacabaro.vbhelper.screens.scanScreen.converters.ToNfcConverter
import com.github.nacabaro.vbhelper.source.VitalWearCharacterExporter
import com.github.nacabaro.vbhelper.source.VitalWearCharacterImporter
import com.github.nacabaro.vbhelper.source.getCryptographicTransformerMap import com.github.nacabaro.vbhelper.source.getCryptographicTransformerMap
import com.github.nacabaro.vbhelper.source.isMissingSecrets import com.github.nacabaro.vbhelper.source.isMissingSecrets
import com.github.nacabaro.vbhelper.source.proto.Secrets import com.github.nacabaro.vbhelper.source.proto.Secrets
import com.github.nacabaro.vbhelper.transfer.hce.VitalWearHceReaderClient
import com.github.nacabaro.vbhelper.utils.DeviceType
import com.github.nacabaro.vbhelper.R
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import com.github.nacabaro.vbhelper.R
import java.util.Arrays
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
class ScanScreenControllerImpl( class ScanScreenControllerImpl(
override val secretsFlow: Flow<Secrets>, override val secretsFlow: Flow<Secrets>,
@ -47,36 +33,8 @@ class ScanScreenControllerImpl(
private val registerActivityLifecycleListener: (String, ActivityLifecycleListener)->Unit, private val registerActivityLifecycleListener: (String, ActivityLifecycleListener)->Unit,
private val unregisterActivityLifecycleListener: (String)->Unit, private val unregisterActivityLifecycleListener: (String)->Unit,
): ScanScreenController { ): ScanScreenController {
override val detectedTransportFlow: StateFlow<DetectedTransport>
get() = _detectedTransportFlow
override val transferStatusFlow: StateFlow<String?>
get() = _transferStatusFlow
private val _detectedTransportFlow = MutableStateFlow(DetectedTransport.UNKNOWN)
private val _transferStatusFlow = MutableStateFlow<String?>(null)
private var lastScannedCharacter: NfcCharacter? = null private var lastScannedCharacter: NfcCharacter? = null
private var lastRequestedCharacterId: Long? = null
private var lastWriteCharacterId: Long? = null
private val nfcAdapter: NfcAdapter private val nfcAdapter: NfcAdapter
private val isHandlingTag = AtomicBoolean(false)
private var lastTagId: ByteArray? = null
private var lastTagHandledAtMs: Long = 0L
private val tagStateLock = Any()
private val readerSessionCounter = AtomicLong(0L)
@Volatile private var activeReaderSessionId: Long = 0L
companion object {
private const val TAG_DEBOUNCE_MS = 1500L
private const val TAG_IGNORE_AFTER_HANDLED_MS = 2000
private val VITALWEAR_AID = byteArrayOf(
0xF0.toByte(), 0x56, 0x49, 0x54, 0x41, 0x4C, 0x57, 0x45, 0x41, 0x52
)
private const val SW_OK = 0x9000
// Lifecycle key for the always-on NFC suppressor that prevents Android from launching
// com.android.apps.tag/.TagViewer whenever the watch's HCE comes into range.
private const val LIFECYCLE_KEY_NFC_SUPPRESSOR = "nfc_suppressor"
}
init { init {
val maybeNfcAdapter = NfcAdapter.getDefaultAdapter(componentActivity) val maybeNfcAdapter = NfcAdapter.getDefaultAdapter(componentActivity)
@ -85,66 +43,10 @@ class ScanScreenControllerImpl(
} }
nfcAdapter = maybeNfcAdapter nfcAdapter = maybeNfcAdapter
checkSecrets() checkSecrets()
registerNfcSuppressor()
} }
/** override fun onClickRead(secrets: Secrets, onComplete: ()->Unit, onMultipleCards: (List<Card>) -> Unit) {
* Registers a lifecycle listener that keeps reader mode active (with a no-op callback) handleTag(secrets) { tagCommunicator ->
* whenever this Activity is in the foreground. This prevents Android's default NFC dispatch
* system from launching com.android.apps.tag/.TagViewer when the watch's HCE service is
* detected while the user hasn't explicitly pressed a transfer button yet.
*
* When the user presses Read/Write/CheckCard, [handleTag] replaces this suppressor with
* the real transfer callback via [NfcAdapter.enableReaderMode]. After the transfer
* completes, [enableNfcSuppressor] is called again to restore the passive suppressor.
*/
private fun registerNfcSuppressor() {
registerActivityLifecycleListener(
LIFECYCLE_KEY_NFC_SUPPRESSOR,
object : ActivityLifecycleListener {
override fun onResume() {
enableNfcSuppressor()
}
override fun onPause() {
disableReaderModeSafely()
}
}
)
}
/**
* Enables reader mode with a silent no-op callback. Calling [NfcAdapter.enableReaderMode]
* suppresses Android's default tag-dispatch system (and therefore the "new tag scanned"
* TagViewer screen) for as long as this Activity is in the foreground. The actual transfer
* logic is wired up separately via [handleTag] when the user presses a button.
*/
private fun enableNfcSuppressor() {
if (!nfcAdapter.isEnabled) return
val options = Bundle()
options.putInt(NfcAdapter.EXTRA_READER_PRESENCE_CHECK_DELAY, 250)
val flags = NfcAdapter.FLAG_READER_NFC_A or
NfcAdapter.FLAG_READER_NO_PLATFORM_SOUNDS or
NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK
runCatching {
nfcAdapter.enableReaderMode(
componentActivity,
{ /* suppress default dispatch — user must tap a transfer button */ },
flags,
options
)
}.onFailure {
Log.w("NFC", "Failed to enable NFC suppressor reader mode", it)
}
}
// ---- Read (phone receives character FROM watch or bracelet) --------------------
override fun onClickRead(secrets: Secrets, onComplete: () -> Unit, onMultipleCards: (List<Card>) -> Unit) {
_detectedTransportFlow.value = DetectedTransport.UNKNOWN
setTransferStatus(R.string.scan_transfer_waiting_tap)
handleTag(
secrets,
nfcAHandler = { tagCommunicator ->
try { try {
val character = tagCommunicator.receiveCharacter() val character = tagCommunicator.receiveCharacter()
val resultMessage = characterFromNfc(character) { cards, nfcCharacter -> val resultMessage = characterFromNfc(character) { cards, nfcCharacter ->
@ -161,202 +63,19 @@ class ScanScreenControllerImpl(
onComplete.invoke() onComplete.invoke()
componentActivity.getString(R.string.scan_error_generic) componentActivity.getString(R.string.scan_error_generic)
} }
},
isoDepHandler = { isoDep ->
val application = componentActivity.applicationContext as VBHelper
val importer = VitalWearCharacterImporter(application.container.db, application.container.transferSeenDao)
try {
// ISO-DEP/HCE route: VitalWear characters are always imported as BE device type.
var importResult: VitalWearCharacterImporter.ImportResult? = null
val moved = VitalWearHceReaderClient(isoDep).moveCharacterFromWatch { character ->
// Force imported device type to BE since source is VitalWear HCE (BE only).
val result = importer.importCharacter(character, forcedDeviceType = DeviceType.BEDevice)
importResult = result
result.success
}
onComplete.invoke()
if (moved) {
importResult?.message ?: componentActivity.getString(R.string.scan_sent_character_success)
} else {
importResult?.message
?: "VitalWear import was rejected. Source character remains on the watch."
}
} catch (readError: Exception) {
Log.e("NFC_READ", "HCE read failed; watch may be armed as destination", readError)
onComplete.invoke()
"No source character detected on watch. If the watch is waiting to receive, use VBH to Watch."
} }
} }
)
}
// ---- Write (phone sends character TO watch or bracelet) ------------------------
override fun onClickWrite(
secrets: Secrets,
nfcCharacter: NfcCharacter,
characterId: Long?,
onComplete: (ScanScreenController.WriteResult) -> Unit
) {
_detectedTransportFlow.value = DetectedTransport.UNKNOWN
setTransferStatus(R.string.scan_transfer_waiting_tap)
handleTag(
secrets,
nfcAHandler = { tagCommunicator ->
try {
val targetHeader = runCatching { tagCommunicator.readTargetHeader() }.getOrNull()
val normalizedDeviceType = when (targetHeader?.deviceTypeId) {
512u.toUShort() -> NfcDeviceTypeId.VitalSeriesDeviceType
768u.toUShort() -> NfcDeviceTypeId.VitalCharactersDeviceType
1024u.toUShort() -> NfcDeviceTypeId.VitalBraceletBEDeviceType
else -> targetHeader?.deviceTypeId
}
val forcedProfile = when (normalizedDeviceType) {
NfcDeviceTypeId.VitalBraceletBEDeviceType -> DeviceType.BEDevice
else -> DeviceType.VBDevice
}
Log.i(
"NFC_WRITE_A",
"Target NFC-A header rawDeviceType=${targetHeader?.deviceTypeId}, normalizedDeviceType=$normalizedDeviceType, dimId=${targetHeader?.getDimId()}, forcedProfile=$forcedProfile"
)
// NFC-A route: choose export profile by target bracelet type.
// - Vital Series / Vital Characters => VB profile
// - Vital Bracelet BE => BE profile
val nfcACharacter = if (characterId != null) {
runBlocking {
ToNfcConverter(componentActivity = componentActivity)
.characterToNfc(characterId, forcedProfile)
}
} else {
nfcCharacter
}
val initialSlotState = readNfcASlotState(tagCommunicator)
if (initialSlotState.isFull()) {
onComplete.invoke(ScanScreenController.WriteResult.BLOCKED_DEVICE_FULL)
return@handleTag componentActivity.getString(R.string.scan_target_device_full)
}
val migrationCheck = verifyActiveToBackupMigration(tagCommunicator, initialSlotState)
if (!migrationCheck.canProceed) {
onComplete.invoke(ScanScreenController.WriteResult.COPIED)
return@handleTag migrationCheck.message
}
// Send with VB profile (which was forced above). Real bracelets only accept VBNfcCharacter.
return@handleTag try {
when (nfcACharacter) {
is VBNfcCharacter -> tagCommunicator.sendCharacter(nfcACharacter)
is BENfcCharacter -> {
// This should not happen since we forced VBDevice above, but fail if it does.
Log.e("NFC_WRITE_A", "BENfcCharacter sent to NFC-A (real bracelet) — protocol error")
throw IllegalStateException("NFC-A forced VBDevice but received BENfcCharacter")
}
}
onComplete.invoke(ScanScreenController.WriteResult.MOVE_CONFIRMED)
componentActivity.getString(R.string.scan_sent_character_success)
} catch (deviceTypeMismatch: Exception) {
// Device type mismatch on NFC-A is terminal for this tap.
if (deviceTypeMismatch.message?.contains("Character doesn't match device type") == true) {
Log.e("NFC_WRITE_A", "Device type mismatch on NFC-A with selected transfer profile", deviceTypeMismatch)
onComplete.invoke(ScanScreenController.WriteResult.COPIED)
"Transfer failed: bracelet rejected character profile for this tap. Retry and keep bracelet steady."
} else {
Log.e("NFC_WRITE_A", "NFC-A write failed", deviceTypeMismatch)
onComplete.invoke(ScanScreenController.WriteResult.COPIED)
throw deviceTypeMismatch
}
}
} catch (writeError: Throwable) {
Log.e("NFC_WRITE_A", "NFC-A setup failed", writeError)
onComplete.invoke(ScanScreenController.WriteResult.COPIED)
throw writeError
}
},
isoDepHandler = { isoDep ->
val characterId = lastRequestedCharacterId
?: throw IllegalStateException("No character id available for VitalWear HCE write")
val application = componentActivity.applicationContext as VBHelper
val hceClient = VitalWearHceReaderClient(isoDep)
try {
// ISO-DEP/HCE route: VitalWear always uses BE profile (enforced).
// Create protobuf with forced BE device type for correct serialization.
val proto = runBlocking {
VitalWearCharacterExporter(application.container.db)
.buildCharacterProto(characterId, forcedTransferProfile = DeviceType.BEDevice)
}
if (proto.characterStats.deviceType != Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_BE) {
throw IllegalStateException("VitalWear HCE export must use BE transfer profile")
}
// Send with status confirmation: only mark MOVE_CONFIRMED after watch confirms import.
val confirmedMove = hceClient.sendCharacterToWatchAndConfirm(proto)
if (confirmedMove) {
onComplete.invoke(ScanScreenController.WriteResult.MOVE_CONFIRMED)
componentActivity.getString(R.string.scan_sent_character_success)
} else {
// Transfer acknowledging but watch import failed or did not confirm.
onComplete.invoke(ScanScreenController.WriteResult.COPIED)
"Transfer sent but watch did not confirm import. Source was kept in VBH."
}
} catch (writeError: Throwable) {
Log.e("NFC_WRITE_HCE", "HCE write failed", writeError)
onComplete.invoke(ScanScreenController.WriteResult.COPIED)
throw writeError
}
}
)
}
// ---- Check card (NFC-A only; ISO-DEP watches don't need a DIM prep) -----------
override fun onClickCheckCard(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: () -> Unit) {
_detectedTransportFlow.value = DetectedTransport.UNKNOWN
setTransferStatus(R.string.scan_transfer_waiting_tap)
handleTag(
secrets,
nfcAHandler = { tagCommunicator ->
tagCommunicator.prepareDIMForCharacter(nfcCharacter.dimId)
onComplete.invoke()
componentActivity.getString(R.string.scan_sent_dim_success)
},
// HCE equivalent of the prep step: verify watch is armed for phone->watch transfer.
isoDepHandler = { isoDep ->
runCatching {
VitalWearHceReaderClient(isoDep).verifyWatchReadyToReceive()
}.fold(
onSuccess = {
onComplete.invoke()
"Watch ready. Tap again to send character."
},
onFailure = { error ->
Log.e("NFC_HCE", "Watch is not ready to receive character", error)
"Watch is not ready. On watch: Transfer > Receive from VBH, then tap again."
}
)
}
)
}
// ---- Cancel / lifecycle -------------------------------------------------------
override fun cancelRead() { override fun cancelRead() {
_detectedTransportFlow.value = DetectedTransport.UNKNOWN if(nfcAdapter.isEnabled) {
setTransferStatus(R.string.scan_transfer_cancelled) nfcAdapter.disableReaderMode(componentActivity)
activeReaderSessionId = readerSessionCounter.incrementAndGet()
isHandlingTag.set(false)
synchronized(tagStateLock) {
lastTagId = null
lastTagHandledAtMs = 0L
} }
// Re-arm the suppressor so the TagViewer doesn't appear while the user is still
// on the scan screen but hasn't pressed a transfer button yet.
enableNfcSuppressor()
} }
override fun registerActivityLifecycleListener(key: String, activityLifecycleListener: ActivityLifecycleListener) { override fun registerActivityLifecycleListener(
key: String,
activityLifecycleListener: ActivityLifecycleListener
) {
registerActivityLifecycleListener.invoke(key, activityLifecycleListener) registerActivityLifecycleListener.invoke(key, activityLifecycleListener)
} }
@ -364,232 +83,43 @@ class ScanScreenControllerImpl(
unregisterActivityLifecycleListener.invoke(key) unregisterActivityLifecycleListener.invoke(key)
} }
// ---- NFC adapter wiring ------------------------------------------------------- // EXTRACTED DIRECTLY FROM EXAMPLE APP
private fun handleTag(secrets: Secrets, handlerFunc: (TagCommunicator)->String) {
/**
* Arms the NFC reader for both NFC-A (bracelet) and ISO-DEP (VitalWear HCE).
* [isoDepHandler] is optional; when null the reader only accepts NFC-A.
*/
private fun handleTag(
secrets: Secrets,
nfcAHandler: (TagCommunicator) -> String,
isoDepHandler: ((IsoDep) -> String)? = null
) {
if (!nfcAdapter.isEnabled) { if (!nfcAdapter.isEnabled) {
showWirelessSettings() showWirelessSettings()
return } else {
}
val options = Bundle() val options = Bundle()
// Work around for some broken Nfc firmware implementations that poll the card too fast // Work around for some broken Nfc firmware implementations that poll the card too fast
options.putInt(NfcAdapter.EXTRA_READER_PRESENCE_CHECK_DELAY, 250) options.putInt(NfcAdapter.EXTRA_READER_PRESENCE_CHECK_DELAY, 250)
val flags = NfcAdapter.FLAG_READER_NFC_A or nfcAdapter.enableReaderMode(componentActivity, buildOnReadTag(secrets, handlerFunc), NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK,
NfcAdapter.FLAG_READER_NO_PLATFORM_SOUNDS or
NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK
val sessionId = readerSessionCounter.incrementAndGet()
activeReaderSessionId = sessionId
isHandlingTag.set(false)
synchronized(tagStateLock) {
lastTagId = null
lastTagHandledAtMs = 0L
}
disableReaderModeSafely()
nfcAdapter.enableReaderMode(
componentActivity,
buildOnReadTag(secrets, nfcAHandler, isoDepHandler, sessionId),
flags,
options options
) )
} }
}
private fun buildOnReadTag( // EXTRACTED DIRECTLY FROM EXAMPLE APP
secrets: Secrets, private fun buildOnReadTag(secrets: Secrets, handlerFunc: (TagCommunicator)->String): (Tag)->Unit {
nfcAHandler: (TagCommunicator) -> String, return { tag->
isoDepHandler: ((IsoDep) -> String)?, val nfcData = NfcA.get(tag)
sessionId: Long, if (nfcData == null) {
): (Tag) -> Unit {
return { tag ->
if (activeReaderSessionId == sessionId) {
val now = System.currentTimeMillis()
val shouldHandleTag = synchronized(tagStateLock) {
val sameRecentTag = lastTagId != null && tag.id != null && Arrays.equals(lastTagId, tag.id) &&
(now - lastTagHandledAtMs) < TAG_DEBOUNCE_MS
if (sameRecentTag || !isHandlingTag.compareAndSet(false, true)) {
false
} else {
lastTagId = tag.id?.clone()
lastTagHandledAtMs = now
true
}
}
if (shouldHandleTag) {
setTransferStatus(R.string.scan_transfer_detected_keep_tap)
// Detect transport once per tap and lock to that route for this transfer.
// Real Bandai bracelets route through NFC-A; VitalWear routes through ISO-DEP/HCE.
val isoDep = IsoDep.get(tag)
val hasNfcARoute = NfcA.get(tag) != null
val hasIsoDepHandler = isoDep != null && isoDepHandler != null
val confirmedVitalWear = if (hasIsoDepHandler) {
runCatching { isVitalWearHceTarget(isoDep) }.getOrDefault(false)
} else {
false
}
try {
if (hasIsoDepHandler && confirmedVitalWear) {
val isoDepTarget = isoDep
val isoDepAction = isoDepHandler
try {
_detectedTransportFlow.value = DetectedTransport.ISO_DEP
isoDepTarget.connect()
isoDepTarget.use {
setTransferStatus(R.string.scan_transfer_in_progress)
val successText = isoDepAction.invoke(isoDepTarget)
componentActivity.runOnUiThread {
Toast.makeText(componentActivity, successText, Toast.LENGTH_SHORT).show()
setTransferStatus(R.string.scan_transfer_complete_remove)
}
}
} catch (e: Throwable) {
Log.e("NFC_HCE", "IsoDep transfer failed", e)
componentActivity.runOnUiThread {
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_error_generic), Toast.LENGTH_SHORT).show()
setTransferStatus(R.string.scan_transfer_failed_try_again)
}
}
} else if (hasIsoDepHandler && !confirmedVitalWear && !hasNfcARoute) {
// Hard-fail only when IsoDep is present without any NFC-A fallback.
// This keeps HCE safety while allowing real bracelets to route via NFC-A.
Log.w("NFC_ROUTE", "IsoDep present but VitalWear AID not confirmed and no NFC-A route; cancelling transfer")
_detectedTransportFlow.value = DetectedTransport.UNKNOWN
componentActivity.runOnUiThread {
Toast.makeText(componentActivity, "VitalWear HCE not detected. Transfer cancelled.", Toast.LENGTH_SHORT).show()
setTransferStatus(R.string.scan_transfer_failed_try_again)
}
} else if (hasNfcARoute) {
if (hasIsoDepHandler && !confirmedVitalWear) {
Log.i("NFC_ROUTE", "IsoDep present but VitalWear AID not confirmed; routing to NFC-A fallback")
}
if (!handleNfcATag(tag, secrets, nfcAHandler)) {
componentActivity.runOnUiThread { componentActivity.runOnUiThread {
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_tag_not_vb), Toast.LENGTH_SHORT).show() Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_tag_not_vb), Toast.LENGTH_SHORT).show()
setTransferStatus(R.string.scan_transfer_failed_try_again)
} }
} }
} else {
if (hasIsoDepHandler && !confirmedVitalWear) {
Log.w("NFC_ROUTE", "IsoDep tag does not expose VitalWear HCE AID and has no NFC-A fallback")
}
_detectedTransportFlow.value = DetectedTransport.UNKNOWN
componentActivity.runOnUiThread {
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_tag_not_vb), Toast.LENGTH_SHORT).show()
setTransferStatus(R.string.scan_transfer_failed_try_again)
}
}
} finally {
finishHandledTagSession(tag, sessionId)
}
}
}
}
}
private fun finishHandledTagSession(tag: Tag, sessionId: Long) {
if (activeReaderSessionId != sessionId) return
// Keep Android from redispatching this same tag to other NFC apps while still in range.
runCatching {
nfcAdapter.ignore(
tag,
TAG_IGNORE_AFTER_HANDLED_MS,
NfcAdapter.OnTagRemovedListener { },
null
)
}.onFailure {
Log.w("NFC", "Failed to ignore handled tag", it)
}
// Restore the passive suppressor so devices can remain close without Android
// launching the TagViewer between transfers or while waiting for the next button press.
enableNfcSuppressor()
isHandlingTag.set(false)
}
private fun handleNfcATag(
tag: Tag,
secrets: Secrets,
nfcAHandler: (TagCommunicator) -> String,
): Boolean {
val nfcData = NfcA.get(tag) ?: return false
_detectedTransportFlow.value = DetectedTransport.NFC_A
return try {
nfcData.connect() nfcData.connect()
nfcData.use { nfcData.use {
val tagCommunicator = TagCommunicator.getInstance(nfcData, secrets.getCryptographicTransformerMap()) val tagCommunicator = TagCommunicator.getInstance(nfcData, secrets.getCryptographicTransformerMap())
setTransferStatus(R.string.scan_transfer_in_progress) val successText = handlerFunc(tagCommunicator)
val successText = nfcAHandler(tagCommunicator)
componentActivity.runOnUiThread { componentActivity.runOnUiThread {
Toast.makeText(componentActivity, successText, Toast.LENGTH_SHORT).show() Toast.makeText(componentActivity, successText, Toast.LENGTH_SHORT).show()
setTransferStatus(R.string.scan_transfer_complete_remove)
} }
} }
true
} catch (e: Throwable) {
val staleTagSession = e is SecurityException && (e.message?.contains("out of date", ignoreCase = true) == true)
if (staleTagSession) {
Log.w("NFC_A", "Ignoring stale NFC-A tag session; waiting for a fresh tap", e)
componentActivity.runOnUiThread {
Toast.makeText(componentActivity, "Tag session expired. Please tap again.", Toast.LENGTH_SHORT).show()
setTransferStatus(R.string.scan_transfer_waiting_tap)
}
return false
}
Log.e("NFC_A", "NfcA transfer failed", e)
componentActivity.runOnUiThread {
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_error_generic), Toast.LENGTH_SHORT).show()
setTransferStatus(R.string.scan_transfer_failed_try_again)
}
true
} }
} }
private fun setTransferStatus(messageRes: Int) {
_transferStatusFlow.value = componentActivity.getString(messageRes)
}
private fun disableReaderModeSafely() {
if (!nfcAdapter.isEnabled) return
runCatching { nfcAdapter.disableReaderMode(componentActivity) }
}
private fun isVitalWearHceTarget(isoDep: IsoDep): Boolean {
return runCatching {
val originalTimeout = isoDep.timeout
try {
isoDep.timeout = 500
if (!isoDep.isConnected) {
isoDep.connect()
}
val selectApdu = byteArrayOf(0x00, 0xA4.toByte(), 0x04, 0x00, VITALWEAR_AID.size.toByte()) + VITALWEAR_AID
val response = isoDep.transceive(selectApdu)
statusWord(response) == SW_OK
} finally {
isoDep.timeout = originalTimeout
runCatching { if (isoDep.isConnected) isoDep.close() }
}
}.getOrDefault(false)
}
private fun statusWord(response: ByteArray): Int {
if (response.size < 2) return -1
return ((response[response.size - 2].toInt() and 0xFF) shl 8) or
(response[response.size - 1].toInt() and 0xFF)
}
// ---- Misc ---------------------------------------------------------------------
private fun checkSecrets() { private fun checkSecrets() {
componentActivity.lifecycleScope.launch(Dispatchers.IO) { componentActivity.lifecycleScope.launch(Dispatchers.IO) {
if (secretsFlow.stateIn(componentActivity.lifecycleScope).value.isMissingSecrets()) { if(secretsFlow.stateIn(componentActivity.lifecycleScope).value.isMissingSecrets()) {
componentActivity.runOnUiThread { componentActivity.runOnUiThread {
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_missing_secrets), Toast.LENGTH_SHORT).show() Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_missing_secrets), Toast.LENGTH_SHORT).show()
} }
@ -597,153 +127,74 @@ class ScanScreenControllerImpl(
} }
} }
override fun onClickWrite(
secrets: Secrets,
nfcCharacter: NfcCharacter,
onComplete: () -> Unit
) {
handleTag(secrets) { tagCommunicator ->
try {
if (nfcCharacter is VBNfcCharacter) {
Log.d("SendCharacter", "VBNfcCharacter")
val castNfcCharacter: VBNfcCharacter = nfcCharacter
tagCommunicator.sendCharacter(castNfcCharacter)
} else if (nfcCharacter is BENfcCharacter) {
Log.d("SendCharacter", "BENfcCharacter")
val castNfcCharacter: BENfcCharacter = nfcCharacter
tagCommunicator.sendCharacter(castNfcCharacter)
}
onComplete.invoke()
componentActivity.getString(R.string.scan_sent_character_success)
} catch (e: Throwable) {
Log.e("TAG", e.stackTraceToString())
componentActivity.getString(R.string.scan_error_generic)
}
}
}
override fun onClickCheckCard(
secrets: Secrets,
nfcCharacter: NfcCharacter,
onComplete: () -> Unit
) {
handleTag(secrets) { tagCommunicator ->
tagCommunicator.prepareDIMForCharacter(nfcCharacter.dimId)
onComplete.invoke()
componentActivity.getString(R.string.scan_sent_dim_success)
}
}
// EXTRACTED DIRECTLY FROM EXAMPLE APP
private fun showWirelessSettings() { private fun showWirelessSettings() {
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_nfc_must_be_enabled), Toast.LENGTH_SHORT).show() Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_nfc_must_be_enabled), Toast.LENGTH_SHORT).show()
componentActivity.startActivity(Intent(Settings.ACTION_WIRELESS_SETTINGS)) componentActivity.startActivity(Intent(Settings.ACTION_WIRELESS_SETTINGS))
} }
/** override fun characterFromNfc(
* Best-effort, non-destructive slot-capacity check for NFC-A devices. nfcCharacter: NfcCharacter,
*/ onMultipleCards: (List<Card>, NfcCharacter) -> Unit
private fun readNfcASlotState(tagCommunicator: TagCommunicator): NfcASlotState { ): String {
val communicatorClass = tagCommunicator.javaClass val nfcConverter = FromNfcConverter(
componentActivity = componentActivity
val countMethod = communicatorClass.methods.firstOrNull {
it.parameterCount == 0 &&
Number::class.java.isAssignableFrom(it.returnType) &&
it.name.contains("count", ignoreCase = true) &&
it.name.contains("character", ignoreCase = true)
}
val count = if (countMethod != null) {
runCatching { (countMethod.invoke(tagCommunicator) as Number).toInt() }.getOrNull()
} else null
val activeMethod = communicatorClass.methods.firstOrNull {
it.parameterCount == 0 && it.returnType == java.lang.Boolean.TYPE &&
(it.name.contains("active", ignoreCase = true) || it.name.contains("current", ignoreCase = true)) &&
it.name.contains("character", ignoreCase = true)
}
val backupMethod = communicatorClass.methods.firstOrNull {
it.parameterCount == 0 && it.returnType == java.lang.Boolean.TYPE &&
it.name.contains("backup", ignoreCase = true) &&
it.name.contains("character", ignoreCase = true)
}
val activePresent = if (activeMethod != null) {
runCatching { activeMethod.invoke(tagCommunicator) as Boolean }.getOrNull()
} else null
val backupPresent = if (backupMethod != null) {
runCatching { backupMethod.invoke(tagCommunicator) as Boolean }.getOrNull()
} else null
if (count == null && activePresent == null && backupPresent == null) {
Log.w("NFC_A", "Unable to introspect NFC-A slot occupancy; defaulting to non-blocking write path")
}
return NfcASlotState(count = count, activePresent = activePresent, backupPresent = backupPresent)
}
/**
* Attempts to mirror official toy behavior by moving the active character to backup.
* If the library already does this internally, this is a harmless no-op.
*/
private fun verifyActiveToBackupMigration(
tagCommunicator: TagCommunicator,
beforeState: NfcASlotState,
): SlotMigrationCheck {
val migrationResult = moveActiveToBackupIfSupported(tagCommunicator)
if (migrationResult.attempted && !migrationResult.success) {
return SlotMigrationCheck(
canProceed = false,
message = "Transfer blocked. Could not move active character to backup safely."
) )
} return nfcConverter.addCharacter(nfcCharacter, onMultipleCards)
// If we could not introspect occupancy at all, keep legacy behavior and proceed.
if (!beforeState.hasOccupancySignal()) {
return SlotMigrationCheck(canProceed = true, message = "")
}
val afterState = readNfcASlotState(tagCommunicator)
val migrationVerified = afterState.backupPresent == true ||
(beforeState.count != null && afterState.count != null && afterState.count >= beforeState.count)
if (!migrationVerified) {
// Some real bracelets do not expose stable occupancy signals through the current
// reflection-based probing. In that case, prefer compatibility over false blocks.
Log.w("NFC_A", "Could not verify active->backup migration from slot signals; proceeding with write")
return SlotMigrationCheck(canProceed = true, message = "")
}
return SlotMigrationCheck(canProceed = true, message = "")
}
private fun moveActiveToBackupIfSupported(tagCommunicator: TagCommunicator): MigrationInvokeResult {
val communicatorClass = tagCommunicator.javaClass
val candidate = communicatorClass.methods.firstOrNull {
it.parameterCount == 0 &&
(
(it.name.contains("move", ignoreCase = true) && it.name.contains("backup", ignoreCase = true)) ||
(it.name.contains("shift", ignoreCase = true) && it.name.contains("backup", ignoreCase = true)) ||
(it.name.contains("promote", ignoreCase = true) && it.name.contains("backup", ignoreCase = true))
)
}
if (candidate == null) {
return MigrationInvokeResult(attempted = false, success = true)
}
return runCatching { candidate.invoke(tagCommunicator) }
.fold(
onSuccess = { MigrationInvokeResult(attempted = true, success = true) },
onFailure = {
Log.w("NFC_A", "Failed to move active character to backup", it)
MigrationInvokeResult(attempted = true, success = false)
}
)
}
private data class NfcASlotState(
val count: Int?,
val activePresent: Boolean?,
val backupPresent: Boolean?,
) {
fun hasOccupancySignal(): Boolean {
return count != null || activePresent != null || backupPresent != null
}
fun isFull(): Boolean {
if (count != null) {
return count >= 2
}
if (activePresent != null && backupPresent != null) {
return activePresent && backupPresent
}
return false
}
}
private data class SlotMigrationCheck(
val canProceed: Boolean,
val message: String,
)
private data class MigrationInvokeResult(
val attempted: Boolean,
val success: Boolean,
)
override fun characterFromNfc(nfcCharacter: NfcCharacter, onMultipleCards: (List<Card>, NfcCharacter) -> Unit): String {
return FromNfcConverter(componentActivity = componentActivity).addCharacter(nfcCharacter, onMultipleCards)
} }
override suspend fun characterToNfc(characterId: Long): NfcCharacter { override suspend fun characterToNfc(characterId: Long): NfcCharacter {
lastRequestedCharacterId = characterId val nfcGenerator = ToNfcConverter(
val character = ToNfcConverter(componentActivity = componentActivity).characterToNfc(characterId) componentActivity = componentActivity
)
val character = nfcGenerator.characterToNfc(characterId)
Log.d("CharacterType", character.toString()) Log.d("CharacterType", character.toString())
return character return character
} }
override fun flushCharacter(cardId: Long) { override fun flushCharacter(cardId: Long) {
val nfcConverter = FromNfcConverter(componentActivity = componentActivity) val nfcConverter = FromNfcConverter(
componentActivity = componentActivity
)
componentActivity.lifecycleScope.launch(Dispatchers.IO) { componentActivity.lifecycleScope.launch(Dispatchers.IO) {
if (lastScannedCharacter != null) { if (lastScannedCharacter != null) {
nfcConverter.addCharacterUsingCard(lastScannedCharacter!!, cardId) nfcConverter.addCharacterUsingCard(lastScannedCharacter!!, cardId)

View File

@ -3,7 +3,6 @@ package com.github.nacabaro.vbhelper.screens.scanScreen.converters
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import com.github.cfogrady.vbnfc.be.BENfcCharacter import com.github.cfogrady.vbnfc.be.BENfcCharacter
import com.github.cfogrady.vbnfc.data.NfcCharacter import com.github.cfogrady.vbnfc.data.NfcCharacter
import com.github.cfogrady.vbnfc.vb.SpecialMission
import com.github.cfogrady.vbnfc.vb.VBNfcCharacter import com.github.cfogrady.vbnfc.vb.VBNfcCharacter
import com.github.nacabaro.vbhelper.di.VBHelper import com.github.nacabaro.vbhelper.di.VBHelper
import com.github.nacabaro.vbhelper.domain.card.Card import com.github.nacabaro.vbhelper.domain.card.Card
@ -20,7 +19,6 @@ class FromNfcConverter (
) { ) {
private val application = componentActivity.applicationContext as VBHelper private val application = componentActivity.applicationContext as VBHelper
private val database = application.container.db private val database = application.container.db
private val transferSeenDao = application.container.transferSeenDao
fun addCharacterUsingCard( fun addCharacterUsingCard(
@ -120,16 +118,11 @@ class FromNfcConverter (
.userCharacterDao() .userCharacterDao()
.insertCharacterData(characterData) .insertCharacterData(characterData)
val seenTimestamp = System.currentTimeMillis()
markSeen(cardData, nfcCharacter.charIndex.toInt(), seenTimestamp)
if (nfcCharacter is BENfcCharacter) { if (nfcCharacter is BENfcCharacter) {
addBeCharacterToDatabase( addBeCharacterToDatabase(
characterId = characterId, characterId = characterId,
nfcCharacter = nfcCharacter nfcCharacter = nfcCharacter
) )
// Keep a VB profile alongside BE stats for later VB-target exports.
addVbCharacterProfileFromBe(characterId, nfcCharacter)
} else if (nfcCharacter is VBNfcCharacter) { } else if (nfcCharacter is VBNfcCharacter) {
addVbCharacterToDatabase( addVbCharacterToDatabase(
characterId = characterId, characterId = characterId,
@ -236,8 +229,8 @@ class FromNfcConverter (
itemType = nfcCharacter.itemType.toInt(), itemType = nfcCharacter.itemType.toInt(),
itemMultiplier = nfcCharacter.itemMultiplier.toInt(), itemMultiplier = nfcCharacter.itemMultiplier.toInt(),
itemRemainingTime = nfcCharacter.itemRemainingTime.toInt(), itemRemainingTime = nfcCharacter.itemRemainingTime.toInt(),
otp0 = nfcCharacter.getOtp0().joinToString("") { "%02x".format(it) }, otp0 = "", //nfcCharacter.value!!.otp0.toString(),
otp1 = nfcCharacter.getOtp1().joinToString("") { "%02x".format(it) }, otp1 = "", //nfcCharacter.value!!.otp1.toString(),
minorVersion = nfcCharacter.characterCreationFirmwareVersion.minorVersion.toInt(), minorVersion = nfcCharacter.characterCreationFirmwareVersion.minorVersion.toInt(),
majorVersion = nfcCharacter.characterCreationFirmwareVersion.majorVersion.toInt(), majorVersion = nfcCharacter.characterCreationFirmwareVersion.majorVersion.toInt(),
) )
@ -247,42 +240,6 @@ class FromNfcConverter (
.insertBECharacterData(extraCharacterData) .insertBECharacterData(extraCharacterData)
} }
private fun addVbCharacterProfileFromBe(
characterId: Long,
nfcCharacter: BENfcCharacter,
) {
val historyCount = nfcCharacter.transformationHistory.count { it.toCharIndex.toInt() != 255 }
val vbData = VBCharacterData(
id = characterId,
generation = (historyCount - 1).coerceAtLeast(0),
totalTrophies = nfcCharacter.trophies.toInt(),
)
database.userCharacterDao().insertVBCharacterData(vbData)
// Populate empty mission slots for BE characters for UI consistency
addEmptyMissionSlotsForBe(characterId)
}
private fun addEmptyMissionSlotsForBe(characterId: Long) {
// Create 4 empty mission slots (one for each watch position 0-3)
val emptyMissions = (0..3).map { watchId ->
SpecialMissions(
characterId = characterId,
watchId = watchId,
missionType = SpecialMission.Type.NONE,
status = SpecialMission.Status.UNAVAILABLE,
goal = 0,
progress = 0,
timeElapsedInMinutes = 0,
timeLimitInMinutes = 0,
)
}
database
.userCharacterDao()
.insertSpecialMissions(*emptyMissions.toTypedArray())
}
private fun addVitalsHistoryToDatabase( private fun addVitalsHistoryToDatabase(
@ -335,14 +292,12 @@ class FromNfcConverter (
database database
.dexDao() .dexDao()
.insertCharacter(item.toCharIndex.toInt(), dimData.id, date) .insertCharacter(
transferSeenDao.markSeen(dimData.name, item.toCharIndex.toInt(), date) item.toCharIndex.toInt(),
dimData.id,
date
)
} }
} }
} }
private fun markSeen(cardData: Card, slotId: Int, timestamp: Long) {
database.dexDao().insertCharacter(slotId, cardData.id, timestamp)
transferSeenDao.markSeen(cardData.name, slotId, timestamp)
}
} }

View File

@ -11,42 +11,22 @@ import com.github.cfogrady.vbnfc.vb.SpecialMission
import com.github.cfogrady.vbnfc.vb.VBNfcCharacter import com.github.cfogrady.vbnfc.vb.VBNfcCharacter
import com.github.nacabaro.vbhelper.database.AppDatabase import com.github.nacabaro.vbhelper.database.AppDatabase
import com.github.nacabaro.vbhelper.di.VBHelper import com.github.nacabaro.vbhelper.di.VBHelper
import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData
import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter
import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData
import com.github.nacabaro.vbhelper.dtos.CharacterDtos import com.github.nacabaro.vbhelper.dtos.CharacterDtos
import com.github.nacabaro.vbhelper.utils.DeviceType import com.github.nacabaro.vbhelper.utils.DeviceType
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import java.util.Date import java.util.Date
class ToNfcConverter( class ToNfcConverter(
private val componentActivity: ComponentActivity private val componentActivity: ComponentActivity
) { ) {
companion object {
private const val MIN_NFC_TRANSFORMATION_YEAR = 2021
private const val MAX_NFC_TRANSFORMATION_YEAR = 2035
internal fun shouldEncodeAsBem(
forcedProfile: DeviceType?,
storedDeviceType: DeviceType,
): Boolean {
return when (forcedProfile) {
DeviceType.BEDevice -> true
DeviceType.VBDevice -> false
else -> storedDeviceType == DeviceType.BEDevice
}
}
}
private val application: VBHelper = componentActivity.applicationContext as VBHelper private val application: VBHelper = componentActivity.applicationContext as VBHelper
private val database: AppDatabase = application.container.db private val database: AppDatabase = application.container.db
suspend fun characterToNfc( suspend fun characterToNfc(
characterId: Long, characterId: Long
forcedProfile: DeviceType? = null,
): NfcCharacter { ): NfcCharacter {
val app = componentActivity.applicationContext as VBHelper val app = componentActivity.applicationContext as VBHelper
val database = app.container.db val database = app.container.db
@ -59,13 +39,7 @@ class ToNfcConverter(
.characterDao() .characterDao()
.getCharacterInfo(userCharacter.charId) .getCharacterInfo(userCharacter.charId)
// Forced profile overrides stored device type for transfer semantics: return if (userCharacter.characterType == DeviceType.BEDevice)
// - NFC-A route forces VBDevice (real bracelets only understand VB)
// - HCE route forces BEDevice (VitalWear only sends/accepts BE)
// Otherwise, use the character's own stored type.
val shouldEncodeAsBem = shouldEncodeAsBem(forcedProfile, userCharacter.characterType)
return if (shouldEncodeAsBem)
nfcToBENfc(characterId, characterInfo, userCharacter) nfcToBENfc(characterId, characterInfo, userCharacter)
else else
nfcToVBNfc(characterId, characterInfo, userCharacter) nfcToVBNfc(characterId, characterInfo, userCharacter)
@ -81,12 +55,7 @@ class ToNfcConverter(
val vbData = database val vbData = database
.userCharacterDao() .userCharacterDao()
.getVbData(characterId) .getVbData(characterId)
.firstOrNull() .first()
?: VBCharacterData(
id = characterId,
generation = 0,
totalTrophies = userCharacter.trophies
)
val paddedTransformationArray = generateTransformationHistory(characterId, 9) val paddedTransformationArray = generateTransformationHistory(characterId, 9)
@ -207,47 +176,11 @@ class ToNfcConverter(
): BENfcCharacter { ): BENfcCharacter {
val beData = database val beData = database
.userCharacterDao() .userCharacterDao()
.getBeDataOrNull(characterId) .getBeData(characterId)
?: BECharacterData( .first()
id = characterId,
trainingHp = 0,
trainingAp = 0,
trainingBp = 0,
remainingTrainingTimeInMinutes = 0,
itemEffectMentalStateValue = 0,
itemEffectMentalStateMinutesRemaining = 0,
itemEffectActivityLevelValue = 0,
itemEffectActivityLevelMinutesRemaining = 0,
itemEffectVitalPointsChangeValue = 0,
itemEffectVitalPointsChangeMinutesRemaining = 0,
abilityRarity = NfcCharacter.AbilityRarity.None,
abilityType = 0,
abilityBranch = 0,
abilityReset = 0,
rank = 0,
itemType = 0,
itemMultiplier = 0,
itemRemainingTime = 0,
otp0 = "",
otp1 = "",
minorVersion = 0,
majorVersion = 0,
)
val paddedTransformationArray = generateTransformationHistory(characterId) val paddedTransformationArray = generateTransformationHistory(characterId)
// Convert hex-encoded OTP strings back to ByteArrays; use empty arrays if not stored
val otp0ByteArray = if (beData.otp0.isNotEmpty()) {
beData.otp0.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
} else {
ByteArray(8) // Default to 8-byte zero array if not available
}
val otp1ByteArray = if (beData.otp1.isNotEmpty()) {
beData.otp1.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
} else {
ByteArray(8) // Default to 8-byte zero array if not available
}
val nfcData = BENfcCharacter( val nfcData = BENfcCharacter(
dimId = characterInfo.cardId.toUShort(), dimId = characterInfo.cardId.toUShort(),
charIndex = characterInfo.charId.toUShort(), charIndex = characterInfo.charId.toUShort(),
@ -288,8 +221,8 @@ class ToNfcConverter(
itemType = beData.itemType.toByte(), itemType = beData.itemType.toByte(),
itemMultiplier = beData.itemMultiplier.toByte(), itemMultiplier = beData.itemMultiplier.toByte(),
itemRemainingTime = beData.itemRemainingTime.toByte(), itemRemainingTime = beData.itemRemainingTime.toByte(),
otp0 = otp0ByteArray, otp0 = byteArrayOf(8),
otp1 = otp1ByteArray, otp1 = byteArrayOf(8),
characterCreationFirmwareVersion = FirmwareVersion( characterCreationFirmwareVersion = FirmwareVersion(
minorVersion = beData.minorVersion.toByte(), minorVersion = beData.minorVersion.toByte(),
majorVersion = beData.majorVersion.toByte() majorVersion = beData.majorVersion.toByte()
@ -321,15 +254,11 @@ class ToNfcConverter(
"Day: ${calendar.get(Calendar.DAY_OF_MONTH)}" "Day: ${calendar.get(Calendar.DAY_OF_MONTH)}"
) )
val rawYear = calendar.get(Calendar.YEAR)
val normalizedYear = rawYear.coerceIn(MIN_NFC_TRANSFORMATION_YEAR, MAX_NFC_TRANSFORMATION_YEAR)
if (normalizedYear != rawYear) {
Log.w("TransformationHistory", "Normalizing out-of-range transformation year $rawYear to $normalizedYear")
}
NfcCharacter.Transformation( NfcCharacter.Transformation(
toCharIndex = it.monIndex.toUByte(), toCharIndex = it.monIndex.toUByte(),
year = normalizedYear.toUShort(), year = calendar
.get(Calendar.YEAR)
.toUShort(),
month = (calendar month = (calendar
.get(Calendar.MONTH) + 1) .get(Calendar.MONTH) + 1)
.toUByte(), .toUByte(),

View File

@ -1,54 +1,47 @@
package com.github.nacabaro.vbhelper.screens.scanScreen.screens package com.github.nacabaro.vbhelper.screens.scanScreen.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment
import androidx.compose.runtime.remember import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource
import com.github.nacabaro.vbhelper.screens.scanScreen.TransferHaptics import androidx.compose.ui.unit.dp
import com.github.nacabaro.vbhelper.utils.ImageBitmapData import com.github.nacabaro.vbhelper.components.TopBanner
import com.github.nacabaro.vbhelper.R
@Composable @Composable
fun ActionScreen( fun ActionScreen(
topBannerText: String, topBannerText: String,
detectedTransportMessage: String?,
transferStatusMessage: String?,
characterPreview: ImageBitmapData? = null,
onClickCancel: () -> Unit, onClickCancel: () -> Unit,
isTransferring: Boolean = true,
) { ) {
val context = LocalContext.current Scaffold (
val transferHaptics = remember(context) { TransferHaptics(context) } topBar = {
TopBanner(
LaunchedEffect(transferStatusMessage) { text = topBannerText,
if (transferStatusMessage != null && transferStatusMessage.contains("progress", ignoreCase = true)) { onBackClick = onClickCancel
transferHaptics.onTransferProgress() )
}
) { innerPadding ->
Column (
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
) {
Text(stringResource(R.string.action_place_near_reader))
Button(
onClick = onClickCancel,
modifier = Modifier.padding(16.dp)
) {
Text(stringResource(R.string.action_cancel))
} }
} }
if (transferStatusMessage?.contains("complete", ignoreCase = true) == true) {
TransferCompleteScreen(
topBannerText = topBannerText,
resultMessage = transferStatusMessage,
onClickOk = onClickCancel,
isSuccess = !transferStatusMessage.contains("fail", ignoreCase = true)
)
} else if (isTransferring) {
TransferAnimationScreen(
topBannerText = topBannerText,
detectedTransportMessage = detectedTransportMessage,
transferStatusMessage = transferStatusMessage,
characterPreview = characterPreview,
onClickCancel = onClickCancel,
isTransferring = true
)
} else {
TransferAnimationScreen(
topBannerText = topBannerText,
detectedTransportMessage = detectedTransportMessage,
transferStatusMessage = transferStatusMessage,
characterPreview = characterPreview,
onClickCancel = onClickCancel,
isTransferring = false
)
} }
} }

View File

@ -1,26 +1,20 @@
package com.github.nacabaro.vbhelper.screens.scanScreen.screens package com.github.nacabaro.vbhelper.screens.scanScreen.screens
import android.util.Log
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import com.github.nacabaro.vbhelper.ActivityLifecycleListener import com.github.nacabaro.vbhelper.ActivityLifecycleListener
import com.github.nacabaro.vbhelper.domain.card.Card import com.github.nacabaro.vbhelper.domain.card.Card
import com.github.nacabaro.vbhelper.screens.cardScreen.ChooseCard import com.github.nacabaro.vbhelper.screens.cardScreen.ChooseCard
import com.github.nacabaro.vbhelper.screens.scanScreen.DetectedTransport
import com.github.nacabaro.vbhelper.screens.scanScreen.SCAN_SCREEN_ACTIVITY_LIFECYCLE_LISTENER import com.github.nacabaro.vbhelper.screens.scanScreen.SCAN_SCREEN_ACTIVITY_LIFECYCLE_LISTENER
import com.github.nacabaro.vbhelper.screens.scanScreen.ScanScreenController import com.github.nacabaro.vbhelper.screens.scanScreen.ScanScreenController
import com.github.nacabaro.vbhelper.screens.scanScreen.TransferHaptics
import com.github.nacabaro.vbhelper.R import com.github.nacabaro.vbhelper.R
private const val TAG = "ReadingScreen"
@Composable @Composable
fun ReadingScreen( fun ReadingScreen(
@ -28,17 +22,7 @@ fun ReadingScreen(
onCancel: () -> Unit, onCancel: () -> Unit,
onComplete: () -> Unit onComplete: () -> Unit
) { ) {
val context = LocalContext.current
val transferHaptics = remember(context) { TransferHaptics(context) }
val secrets by scanScreenController.secretsFlow.collectAsState(null) val secrets by scanScreenController.secretsFlow.collectAsState(null)
val transferStatus by scanScreenController.transferStatusFlow.collectAsState(null)
val detectedTransport by scanScreenController.detectedTransportFlow.collectAsState()
val transportMessage = when (detectedTransport) {
DetectedTransport.NFC_A -> stringResource(R.string.scan_transport_bandai_bracelet)
DetectedTransport.ISO_DEP -> stringResource(R.string.scan_transport_vitalwear_hce)
DetectedTransport.UNKNOWN -> null
}
var cardsRead by remember { mutableStateOf<List<Card>?>(null) } var cardsRead by remember { mutableStateOf<List<Card>?>(null) }
@ -46,38 +30,22 @@ fun ReadingScreen(
var isDoneReadingCharacter by remember { mutableStateOf(false) } var isDoneReadingCharacter by remember { mutableStateOf(false) }
var cardSelectScreen by remember { mutableStateOf(false) } var cardSelectScreen by remember { mutableStateOf(false) }
LaunchedEffect(readingScreen) { DisposableEffect(readingScreen) {
if (readingScreen) { if(readingScreen) {
transferHaptics.onTransferStart() scanScreenController.registerActivityLifecycleListener(
} SCAN_SCREEN_ACTIVITY_LIFECYCLE_LISTENER,
object: ActivityLifecycleListener {
override fun onPause() {
scanScreenController.cancelRead()
} }
fun startReadIfNeeded() { override fun onResume() {
val availableSecrets = secrets
if (availableSecrets == null) {
Log.d(TAG, "startReadIfNeeded: skipped (no secrets)")
return
}
if (!readingScreen || isDoneReadingCharacter || cardSelectScreen) {
Log.d(
TAG,
"startReadIfNeeded: skipped (readingScreen=$readingScreen, done=$isDoneReadingCharacter, cardSelect=$cardSelectScreen)"
)
return
}
Log.d(TAG, "startReadIfNeeded: arming onClickRead")
scanScreenController.onClickRead( scanScreenController.onClickRead(
secrets = availableSecrets, secrets = secrets!!,
onComplete = { onComplete = {
Log.d(TAG, "onClickRead.onComplete: marking read complete")
transferHaptics.onTransferComplete()
readingScreen = false
isDoneReadingCharacter = true isDoneReadingCharacter = true
}, },
onMultipleCards = { cards -> onMultipleCards = { cards ->
Log.d(TAG, "onClickRead.onMultipleCards: showing card select (count=${cards.size})")
transferHaptics.onTransferProgress()
cardsRead = cards cardsRead = cards
readingScreen = false readingScreen = false
cardSelectScreen = true cardSelectScreen = true
@ -85,41 +53,32 @@ fun ReadingScreen(
} }
) )
} }
DisposableEffect(readingScreen) {
if(readingScreen) {
Log.d(TAG, "DisposableEffect: readingScreen=true, registering lifecycle listener")
scanScreenController.registerActivityLifecycleListener(
SCAN_SCREEN_ACTIVITY_LIFECYCLE_LISTENER,
object: ActivityLifecycleListener {
override fun onPause() {
Log.d(TAG, "lifecycle.onPause: cancelRead")
scanScreenController.cancelRead()
}
override fun onResume() {
Log.d(TAG, "lifecycle.onResume: attempting startReadIfNeeded")
startReadIfNeeded()
}
} }
) )
startReadIfNeeded() scanScreenController.onClickRead(
secrets = secrets!!,
onComplete = {
isDoneReadingCharacter = true
},
onMultipleCards = { cards ->
cardsRead = cards
readingScreen = false
cardSelectScreen = true
isDoneReadingCharacter = true
}
)
} }
onDispose { onDispose {
if(readingScreen) { if(readingScreen) {
Log.d(TAG, "DisposableEffect.onDispose: unregister listener + cancelRead")
scanScreenController.unregisterActivityLifecycleListener( scanScreenController.unregisterActivityLifecycleListener(
SCAN_SCREEN_ACTIVITY_LIFECYCLE_LISTENER SCAN_SCREEN_ACTIVITY_LIFECYCLE_LISTENER
) )
scanScreenController.cancelRead() scanScreenController.cancelRead()
} else {
Log.d(TAG, "DisposableEffect.onDispose: readingScreen=false, nothing to cancel")
} }
} }
} }
if (isDoneReadingCharacter && !cardSelectScreen) { if (isDoneReadingCharacter && !cardSelectScreen) {
Log.d(TAG, "state gate: done read without cardSelect, calling onComplete")
readingScreen = false readingScreen = false
onComplete() onComplete()
} }
@ -127,33 +86,24 @@ fun ReadingScreen(
if (!readingScreen) { if (!readingScreen) {
ReadCharacterScreen( ReadCharacterScreen(
onClickConfirm = { onClickConfirm = {
Log.d(TAG, "ReadCharacterScreen.onClickConfirm: entering reading screen")
readingScreen = true readingScreen = true
}, },
onClickCancel = { onClickCancel = {
Log.d(TAG, "ReadCharacterScreen.onClickCancel: user cancelled")
onCancel() onCancel()
} }
) )
} }
if (readingScreen) { if (readingScreen) {
ActionScreen( ActionScreen(topBannerText = stringResource(R.string.reading_character_title),) {
topBannerText = stringResource(R.string.reading_character_title),
detectedTransportMessage = transportMessage,
transferStatusMessage = transferStatus,
onClickCancel = {
Log.d(TAG, "ActionScreen.onCancel: cancelRead + onCancel")
readingScreen = false readingScreen = false
scanScreenController.cancelRead() scanScreenController.cancelRead()
onCancel() onCancel()
} }
)
} else if (cardSelectScreen) { } else if (cardSelectScreen) {
ChooseCard( ChooseCard(
cards = cardsRead!!, cards = cardsRead!!,
onCardSelected = { card -> onCardSelected = { card ->
Log.d(TAG, "ChooseCard.onCardSelected: selected cardId=${card.id}")
cardSelectScreen = false cardSelectScreen = false
scanScreenController.flushCharacter(card.id) scanScreenController.flushCharacter(card.id)
} }

View File

@ -7,21 +7,14 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.produceState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import com.github.cfogrady.vbnfc.data.NfcCharacter import com.github.cfogrady.vbnfc.data.NfcCharacter
import com.github.nacabaro.vbhelper.ActivityLifecycleListener import com.github.nacabaro.vbhelper.ActivityLifecycleListener
import com.github.nacabaro.vbhelper.di.VBHelper import com.github.nacabaro.vbhelper.di.VBHelper
import com.github.nacabaro.vbhelper.screens.scanScreen.DetectedTransport
import com.github.nacabaro.vbhelper.screens.scanScreen.SCAN_SCREEN_ACTIVITY_LIFECYCLE_LISTENER import com.github.nacabaro.vbhelper.screens.scanScreen.SCAN_SCREEN_ACTIVITY_LIFECYCLE_LISTENER
import com.github.nacabaro.vbhelper.screens.scanScreen.ScanScreenController import com.github.nacabaro.vbhelper.screens.scanScreen.ScanScreenController
import com.github.nacabaro.vbhelper.screens.scanScreen.ScanScreenController.WriteResult
import com.github.nacabaro.vbhelper.screens.scanScreen.TransferHaptics
import com.github.nacabaro.vbhelper.source.StorageRepository import com.github.nacabaro.vbhelper.source.StorageRepository
import com.github.nacabaro.vbhelper.utils.BitmapData
import com.github.nacabaro.vbhelper.utils.ImageBitmapData
import com.github.nacabaro.vbhelper.utils.getImageBitmap
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -36,53 +29,18 @@ fun WritingScreen(
onCancel: () -> Unit, onCancel: () -> Unit,
) { ) {
val secrets by scanScreenController.secretsFlow.collectAsState(null) val secrets by scanScreenController.secretsFlow.collectAsState(null)
val transferStatus by scanScreenController.transferStatusFlow.collectAsState(null)
val detectedTransport by scanScreenController.detectedTransportFlow.collectAsState()
val transportMessage = when (detectedTransport) {
DetectedTransport.NFC_A -> stringResource(R.string.scan_transport_bandai_bracelet)
DetectedTransport.ISO_DEP -> stringResource(R.string.scan_transport_vitalwear_hce)
DetectedTransport.UNKNOWN -> null
}
val application = LocalContext.current.applicationContext as VBHelper val application = LocalContext.current.applicationContext as VBHelper
val storageRepository = StorageRepository(application.container.db) val storageRepository = StorageRepository(application.container.db)
val context = LocalContext.current
val characterPreview by produceState<ImageBitmapData?>(initialValue = null, key1 = characterId) {
value = withContext(Dispatchers.IO) {
runCatching {
storageRepository.getSingleCharacter(characterId)
}.getOrNull()?.let { character ->
if (character.spriteWidth > 0 && character.spriteHeight > 0 && character.spriteIdle.isNotEmpty()) {
BitmapData(
bitmap = character.spriteIdle,
width = character.spriteWidth,
height = character.spriteHeight
).getImageBitmap(
context = context,
multiplier = 4,
obscure = false
)
} else {
null
}
}
}
}
var writing by remember { mutableStateOf(false) } var writing by remember { mutableStateOf(false) }
var writingScreen by remember { mutableStateOf(false) } var writingScreen by remember { mutableStateOf(false) }
var writingConfirmScreen by remember { mutableStateOf(false) } var writingConfirmScreen by remember { mutableStateOf(false) }
var isDoneSendingCard by remember { mutableStateOf(false) } var isDoneSendingCard by remember { mutableStateOf(false) }
var isDoneWritingCharacter by remember { mutableStateOf(false) } var isDoneWritingCharacter by remember { mutableStateOf(false) }
var writeResult by remember { mutableStateOf<WriteResult?>(null) }
DisposableEffect(writing) { DisposableEffect(writing) {
if (writing) { if (writing) {
val transferHaptics = TransferHaptics(application)
transferHaptics.onTransferStart()
scanScreenController.registerActivityLifecycleListener( scanScreenController.registerActivityLifecycleListener(
SCAN_SCREEN_ACTIVITY_LIFECYCLE_LISTENER, SCAN_SCREEN_ACTIVITY_LIFECYCLE_LISTENER,
object : ActivityLifecycleListener { object : ActivityLifecycleListener {
@ -94,13 +52,10 @@ fun WritingScreen(
if (!isDoneSendingCard) { if (!isDoneSendingCard) {
scanScreenController.onClickCheckCard(secrets!!, nfcCharacter) { scanScreenController.onClickCheckCard(secrets!!, nfcCharacter) {
isDoneSendingCard = true isDoneSendingCard = true
transferHaptics.onTransferProgress()
} }
} else if (!isDoneWritingCharacter) { } else if (!isDoneWritingCharacter) {
scanScreenController.onClickWrite(secrets!!, nfcCharacter, characterId) { result -> scanScreenController.onClickWrite(secrets!!, nfcCharacter) {
writeResult = result
isDoneWritingCharacter = true isDoneWritingCharacter = true
transferHaptics.onTransferProgress()
} }
} }
} }
@ -111,13 +66,10 @@ fun WritingScreen(
if (!isDoneSendingCard) { if (!isDoneSendingCard) {
scanScreenController.onClickCheckCard(secrets!!, nfcCharacter) { scanScreenController.onClickCheckCard(secrets!!, nfcCharacter) {
isDoneSendingCard = true isDoneSendingCard = true
transferHaptics.onTransferProgress()
} }
} else if (!isDoneWritingCharacter) { } else if (!isDoneWritingCharacter) {
scanScreenController.onClickWrite(secrets!!, nfcCharacter, characterId) { result -> scanScreenController.onClickWrite(secrets!!, nfcCharacter) {
writeResult = result
isDoneWritingCharacter = true isDoneWritingCharacter = true
transferHaptics.onTransferProgress()
} }
} }
} }
@ -147,16 +99,10 @@ fun WritingScreen(
) )
} else if (!isDoneSendingCard) { } else if (!isDoneSendingCard) {
writing = true writing = true
ActionScreen( ActionScreen( topBannerText = stringResource(R.string.sending_card_title)) {
topBannerText = stringResource(R.string.sending_card_title),
detectedTransportMessage = transportMessage,
transferStatusMessage = transferStatus,
characterPreview = characterPreview,
onClickCancel = {
scanScreenController.cancelRead() scanScreenController.cancelRead()
onCancel() onCancel()
} }
)
} else if (!writingConfirmScreen) { } else if (!writingConfirmScreen) {
writing = false writing = false
WriteCharacterScreen ( WriteCharacterScreen (
@ -171,17 +117,11 @@ fun WritingScreen(
) )
} else if (!isDoneWritingCharacter) { } else if (!isDoneWritingCharacter) {
writing = true writing = true
ActionScreen( ActionScreen(topBannerText = stringResource(R.string.writing_character_action_title)) {
topBannerText = stringResource(R.string.writing_character_action_title),
detectedTransportMessage = transportMessage,
transferStatusMessage = transferStatus,
characterPreview = characterPreview,
onClickCancel = {
isDoneSendingCard = false isDoneSendingCard = false
scanScreenController.cancelRead() scanScreenController.cancelRead()
onCancel() onCancel()
} }
)
} }
var completedWriting by remember { mutableStateOf(false) } var completedWriting by remember { mutableStateOf(false) }
@ -189,11 +129,8 @@ fun WritingScreen(
LaunchedEffect(isDoneSendingCard, isDoneWritingCharacter) { LaunchedEffect(isDoneSendingCard, isDoneWritingCharacter) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
if (isDoneSendingCard && isDoneWritingCharacter) { if (isDoneSendingCard && isDoneWritingCharacter) {
// Only delete source character if transfer was confirmed as move. storageRepository
// COPIED means transfer was sent but not confirmed, so keep source. .deleteCharacter(characterId)
if (writeResult == WriteResult.MOVE_CONFIRMED) {
storageRepository.deleteCharacter(characterId)
}
completedWriting = true completedWriting = true
} }
} }

View File

@ -5,7 +5,6 @@ import android.net.Uri
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -13,12 +12,8 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -72,7 +67,6 @@ fun SettingsScreen(
settingsScreenController.onClickImportCard() settingsScreenController.onClickImportCard()
} }
SettingsSection(title = stringResource(R.string.settings_section_about)) SettingsSection(title = stringResource(R.string.settings_section_about))
SettingsEntry( SettingsEntry(
title = stringResource(R.string.settings_credits_title), title = stringResource(R.string.settings_credits_title),
@ -104,38 +98,6 @@ fun SettingsScreen(
) { ) {
settingsScreenController.onClickImportDatabase() settingsScreenController.onClickImportDatabase()
} }
SettingsSection(title = stringResource(R.string.settings_section_companion_tools))
SettingsEntry(
title = stringResource(R.string.settings_companion_import_card_image_title),
description = stringResource(R.string.settings_companion_import_card_image_desc)
) {
settingsScreenController.onClickCompanionImportCardImage()
}
SettingsEntry(
title = stringResource(R.string.settings_companion_import_firmware_title),
description = stringResource(R.string.settings_companion_import_firmware_desc)
) {
settingsScreenController.onClickCompanionImportFirmware()
}
SettingsEntry(
title = stringResource(R.string.settings_companion_send_watch_logs_title),
description = stringResource(R.string.settings_companion_send_watch_logs_desc)
) {
settingsScreenController.onClickCompanionSendWatchLogs()
}
SettingsEntry(
title = stringResource(R.string.settings_companion_send_phone_logs_title),
description = stringResource(R.string.settings_companion_send_phone_logs_desc)
) {
settingsScreenController.onClickCompanionSendPhoneLogs()
}
SettingsSwitchEntry(
title = stringResource(R.string.settings_enable_dim_to_bem_title),
description = stringResource(R.string.settings_enable_dim_to_bem_desc),
isEnabled = settingsScreenController,
onToggle = { settingsScreenController.onToggleDimToBemConversion(it) }
)
} }
} }
} }
@ -161,40 +123,6 @@ fun SettingsEntry(
} }
} }
@Composable
fun SettingsSwitchEntry(
title: String,
description: String,
isEnabled: SettingsScreenControllerImpl,
onToggle: (Boolean) -> Unit
) {
val dimToBemEnabled by isEnabled.dimToBemConversionEnabled.collectAsState(false)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(end = 16.dp)
) {
Text(text = title)
Text(
text = description,
fontSize = 12.sp,
color = MaterialTheme.colorScheme.outline
)
}
Switch(
checked = dimToBemEnabled,
onCheckedChange = onToggle
)
}
}
@Composable @Composable
fun SettingsSection( fun SettingsSection(
title: String title: String

View File

@ -5,8 +5,4 @@ interface SettingsScreenController {
fun onClickImportDatabase() fun onClickImportDatabase()
fun onClickImportApk() fun onClickImportApk()
fun onClickImportCard() fun onClickImportCard()
fun onClickCompanionImportCardImage()
fun onClickCompanionImportFirmware()
fun onClickCompanionSendWatchLogs()
fun onClickCompanionSendPhoneLogs()
} }

View File

@ -1,6 +1,5 @@
package com.github.nacabaro.vbhelper.screens.settingsScreen package com.github.nacabaro.vbhelper.screens.settingsScreen
import android.content.Intent
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import android.net.Uri import android.net.Uri
@ -8,13 +7,8 @@ import android.widget.Toast
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import com.github.nacabaro.vbhelper.companion.card.CompanionImportCardActivity
import com.github.nacabaro.vbhelper.companion.firmware.CompanionFirmwareImportActivity
import com.github.nacabaro.vbhelper.companion.logs.CompanionWatchLogsActivity
import com.github.nacabaro.vbhelper.companion.logging.TinyLogTree
import com.github.nacabaro.vbhelper.database.AppDatabase import com.github.nacabaro.vbhelper.database.AppDatabase
import com.github.nacabaro.vbhelper.di.VBHelper import com.github.nacabaro.vbhelper.di.VBHelper
import com.github.nacabaro.vbhelper.R
import com.github.nacabaro.vbhelper.screens.settingsScreen.controllers.CardImportController import com.github.nacabaro.vbhelper.screens.settingsScreen.controllers.CardImportController
import com.github.nacabaro.vbhelper.screens.settingsScreen.controllers.DatabaseManagementController import com.github.nacabaro.vbhelper.screens.settingsScreen.controllers.DatabaseManagementController
import com.github.nacabaro.vbhelper.source.ApkSecretsImporter import com.github.nacabaro.vbhelper.source.ApkSecretsImporter
@ -22,7 +16,6 @@ import com.github.nacabaro.vbhelper.source.SecretsImporter
import com.github.nacabaro.vbhelper.source.SecretsRepository import com.github.nacabaro.vbhelper.source.SecretsRepository
import com.github.nacabaro.vbhelper.source.proto.Secrets import com.github.nacabaro.vbhelper.source.proto.Secrets
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
class SettingsScreenControllerImpl( class SettingsScreenControllerImpl(
@ -36,14 +29,11 @@ class SettingsScreenControllerImpl(
private val application = context.applicationContext as VBHelper private val application = context.applicationContext as VBHelper
private val secretsRepository: SecretsRepository = application.container.dataStoreSecretsRepository private val secretsRepository: SecretsRepository = application.container.dataStoreSecretsRepository
private val database: AppDatabase = application.container.db private val database: AppDatabase = application.container.db
private val cardSettingsRepository = application.container.cardSettingsRepository
private val databaseManagementController = DatabaseManagementController( private val databaseManagementController = DatabaseManagementController(
componentActivity = context, componentActivity = context,
application = application application = application
) )
val dimToBemConversionEnabled = cardSettingsRepository.enableDimToBemConversion
init { init {
filePickerLauncher = context.registerForActivityResult( filePickerLauncher = context.registerForActivityResult(
ActivityResultContracts.CreateDocument("application/octet-stream") ActivityResultContracts.CreateDocument("application/octet-stream")
@ -111,41 +101,13 @@ class SettingsScreenControllerImpl(
filePickerCard.launch(arrayOf("*/*")) filePickerCard.launch(arrayOf("*/*"))
} }
override fun onClickCompanionImportCardImage() {
context.startActivity(Intent(context, CompanionImportCardActivity::class.java))
}
override fun onClickCompanionImportFirmware() {
context.startActivity(Intent(context, CompanionFirmwareImportActivity::class.java))
}
override fun onClickCompanionSendWatchLogs() {
context.startActivity(Intent(context, CompanionWatchLogsActivity::class.java))
}
override fun onClickCompanionSendPhoneLogs() {
val file = TinyLogTree.getMostRecentLogFile(context.applicationContext)
if (file == null) {
Toast.makeText(context, context.getString(R.string.companion_logs_no_files), Toast.LENGTH_SHORT).show()
return
}
application.companionLogService.sendLogFile(context.applicationContext, file, context)
}
fun onToggleDimToBemConversion(enabled: Boolean) {
context.lifecycleScope.launch(Dispatchers.IO) {
cardSettingsRepository.setEnableDimToBemConversion(enabled)
}
}
private fun importCard(uri: Uri) { private fun importCard(uri: Uri) {
context.lifecycleScope.launch(Dispatchers.IO) { context.lifecycleScope.launch(Dispatchers.IO) {
val contentResolver = context.contentResolver val contentResolver = context.contentResolver
val inputStream = contentResolver.openInputStream(uri) val inputStream = contentResolver.openInputStream(uri)
inputStream.use { fileReader -> inputStream.use { fileReader ->
val dimToBemEnabled = cardSettingsRepository.enableDimToBemConversion.first() val cardImportController = CardImportController(database)
val cardImportController = CardImportController(database, dimToBemEnabled)
cardImportController.importCard(fileReader) cardImportController.importCard(fileReader)
} }

View File

@ -13,8 +13,7 @@ import com.github.nacabaro.vbhelper.domain.characters.Sprite
import java.io.InputStream import java.io.InputStream
class CardImportController( class CardImportController(
private val database: AppDatabase, private val database: AppDatabase
private val enableDimToBemConversion: Boolean = false
) { ) {
suspend fun importCard( suspend fun importCard(
fileReader: InputStream? fileReader: InputStream?
@ -118,16 +117,6 @@ class CardImportController(
.spriteDao() .spriteDao()
.insertSprite(domainSprite) .insertSprite(domainSprite)
// Apply DIM-to-BEM conversion if enabled and card is a DIM card (not BEM)
var baseHp = characters[index].hp
var baseBp = characters[index].dp
var baseAp = characters[index].ap
if (enableDimToBemConversion && card is DimCard) {
baseHp = convertDimStatToBem(baseHp, characters[index].stage, Stat.HP)
baseBp = convertDimStatToBem(baseBp, characters[index].stage, Stat.BP)
baseAp = convertDimStatToBem(baseAp, characters[index].stage, Stat.AP)
}
domainCharacters.add( domainCharacters.add(
CardCharacter( CardCharacter(
@ -137,9 +126,9 @@ class CardImportController(
nameSprite = card.spriteData.sprites[spriteCounter].pixelData, nameSprite = card.spriteData.sprites[spriteCounter].pixelData,
stage = characters[index].stage, stage = characters[index].stage,
attribute = NfcCharacter.Attribute.entries[characters[index].attribute], attribute = NfcCharacter.Attribute.entries[characters[index].attribute],
baseHp = baseHp, baseHp = characters[index].hp,
baseBp = baseBp, baseBp = characters[index].dp,
baseAp = baseAp, baseAp = characters[index].ap,
nameWidth = card.spriteData.sprites[spriteCounter].spriteDimensions.width, nameWidth = card.spriteData.sprites[spriteCounter].spriteDimensions.width,
nameHeight = card.spriteData.sprites[spriteCounter].spriteDimensions.height nameHeight = card.spriteData.sprites[spriteCounter].spriteDimensions.height
) )
@ -161,40 +150,6 @@ class CardImportController(
.insertCharacter(*domainCharacters.toTypedArray()) .insertCharacter(*domainCharacters.toTypedArray())
} }
private enum class Stat { HP, BP, AP }
private fun convertDimStatToBem(dimStat: Int, phase: Int, statType: Stat): Int {
// DIM-to-BEM conversion factors based on phase and stat type
// These are approximations based on the average conversion ratios
val conversionFactor = when (phase) {
1 -> 0.0f
2 -> 0.0f
3 -> when (statType) {
Stat.HP -> 4657.5f
Stat.BP -> 4657.5f
Stat.AP -> 1022.5f
}
4 -> when (statType) {
Stat.HP -> 5171.528f
Stat.BP -> 5028.528f
Stat.AP -> 1222.9167f
}
5 -> when (statType) {
Stat.HP -> 5664.4f
Stat.BP -> 5036.4f
Stat.AP -> 1505.7333f
}
6 -> when (statType) {
Stat.HP -> 5972.2974f
Stat.BP -> 5256.2974f
Stat.AP -> 1976.7568f
}
else -> 0.0f
}
return (dimStat * conversionFactor).toInt()
}
private suspend fun importAdventureMissions( private suspend fun importAdventureMissions(
cardId: Long, cardId: Long,
card: com.github.cfogrady.vb.dim.card.Card<*, *, *, *, *, *> card: com.github.cfogrady.vb.dim.card.Card<*, *, *, *, *, *>

View File

@ -1,14 +1,11 @@
package com.github.nacabaro.vbhelper.screens.settingsScreen.controllers package com.github.nacabaro.vbhelper.screens.settingsScreen.controllers
import androidx.room.Room
import android.database.sqlite.SQLiteDatabase
import android.net.Uri import android.net.Uri
import android.provider.OpenableColumns import android.provider.OpenableColumns
import android.util.Log import android.util.Log
import android.widget.Toast import android.widget.Toast
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.github.nacabaro.vbhelper.database.AppDatabase
import com.github.nacabaro.vbhelper.di.VBHelper import com.github.nacabaro.vbhelper.di.VBHelper
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -21,19 +18,6 @@ class DatabaseManagementController(
val application: VBHelper val application: VBHelper
) { ) {
private val roomDbName = "internalDb" private val roomDbName = "internalDb"
private val requiredLegacyVersions = setOf(1, 2, 3, 4, 5)
private val requiredCoreTables = setOf(
"UserCharacter",
"Character",
"Card",
"CardCharacter",
"TransformationHistory",
)
private data class ValidationResult(
val isValid: Boolean,
val reason: String? = null,
)
fun exportDatabase( destinationUri: Uri) { fun exportDatabase( destinationUri: Uri) {
componentActivity.lifecycleScope.launch(Dispatchers.IO) { componentActivity.lifecycleScope.launch(Dispatchers.IO) {
@ -75,19 +59,6 @@ class DatabaseManagementController(
return@launch return@launch
} }
val validationResult = validateImportBackup(sourceUri)
if (!validationResult.isValid) {
componentActivity.runOnUiThread {
Toast.makeText(
componentActivity,
validationResult.reason
?: "Import blocked: backup is incompatible with this VBH-VW version.",
Toast.LENGTH_LONG
).show()
}
return@launch
}
application.container.db.close() application.container.db.close()
val dbPath = componentActivity.getDatabasePath(roomDbName) val dbPath = componentActivity.getDatabasePath(roomDbName)
@ -121,118 +92,6 @@ class DatabaseManagementController(
} }
} }
private fun validateImportBackup(sourceUri: Uri): ValidationResult {
val tempDbName = "import_validation_temp.db"
val tempDbPath = componentActivity.getDatabasePath(tempDbName)
val tempShm = File(tempDbPath.parentFile, "$tempDbName-shm")
val tempWal = File(tempDbPath.parentFile, "$tempDbName-wal")
try {
if (tempDbPath.exists()) tempDbPath.delete()
if (tempShm.exists()) tempShm.delete()
if (tempWal.exists()) tempWal.delete()
componentActivity.contentResolver.openInputStream(sourceUri)?.use { inputStream ->
tempDbPath.outputStream().use { outputStream ->
copyFile(inputStream, outputStream)
}
} ?: return ValidationResult(false, "Import blocked: unable to read selected backup file.")
// Fast static checks before attempting full Room open/migration.
val legacyVersion = readPragmaUserVersion(tempDbPath)
if (legacyVersion !in requiredLegacyVersions) {
return ValidationResult(
false,
"Import blocked: unsupported DB version ($legacyVersion). Use a VBH-VW export from a compatible app version."
)
}
val tableNames = readTableNames(tempDbPath)
if (!tableNames.contains("room_master_table")) {
return ValidationResult(
false,
"Import blocked: backup is not a Room VBH-VW database (room metadata missing)."
)
}
val missingTables = requiredCoreTables.filterNot { tableNames.contains(it) }
if (missingTables.isNotEmpty()) {
return ValidationResult(
false,
"Import blocked: backup is missing required tables (${missingTables.joinToString(", ")})."
)
}
// Hard validation: open with the app's exact Room schema + migrations.
val probeDb = Room.databaseBuilder(
componentActivity,
AppDatabase::class.java,
tempDbName
)
.addMigrations(AppDatabase.MIGRATION_1_2)
.addMigrations(AppDatabase.MIGRATION_2_3)
.addMigrations(AppDatabase.MIGRATION_3_5)
.addMigrations(AppDatabase.MIGRATION_4_5)
.addMigrations(AppDatabase.MIGRATION_5_6)
.build()
try {
probeDb.openHelper.writableDatabase.query("SELECT 1").close()
} catch (e: Exception) {
return ValidationResult(
false,
"Import blocked: backup cannot be opened/migrated by this VBH-VW build."
)
} finally {
probeDb.close()
}
return ValidationResult(true)
} finally {
if (tempDbPath.exists()) tempDbPath.delete()
if (tempShm.exists()) tempShm.delete()
if (tempWal.exists()) tempWal.delete()
}
}
private fun readPragmaUserVersion(dbFile: File): Int {
val sqliteDb = SQLiteDatabase.openDatabase(
dbFile.absolutePath,
null,
SQLiteDatabase.OPEN_READONLY
)
try {
sqliteDb.rawQuery("PRAGMA user_version", null).use { cursor ->
if (cursor.moveToFirst()) {
return cursor.getInt(0)
}
}
return -1
} finally {
sqliteDb.close()
}
}
private fun readTableNames(dbFile: File): Set<String> {
val sqliteDb = SQLiteDatabase.openDatabase(
dbFile.absolutePath,
null,
SQLiteDatabase.OPEN_READONLY
)
try {
val tableNames = mutableSetOf<String>()
sqliteDb.rawQuery("SELECT name FROM sqlite_master WHERE type='table'", null)
.use { cursor ->
while (cursor.moveToNext()) {
tableNames.add(cursor.getString(0))
}
}
return tableNames
} finally {
sqliteDb.close()
}
}
private fun copyFile(inputStream: InputStream, outputStream: OutputStream) { private fun copyFile(inputStream: InputStream, outputStream: OutputStream) {
val buffer = ByteArray(1024) val buffer = ByteArray(1024)
var bytesRead: Int var bytesRead: Int

View File

@ -44,18 +44,12 @@ class AuthRepository(
preferences[IS_AUTHENTICATED] = isAuthenticated preferences[IS_AUTHENTICATED] = isAuthenticated
if (nacatechToken != null) { if (nacatechToken != null) {
preferences[AUTH_TOKEN] = nacatechToken preferences[AUTH_TOKEN] = nacatechToken
} else {
preferences.remove(AUTH_TOKEN)
} }
if (sessionToken != null) { if (sessionToken != null) {
preferences[SESSION_TOKEN] = sessionToken preferences[SESSION_TOKEN] = sessionToken
} else {
preferences.remove(SESSION_TOKEN)
} }
if (userId != null) { if (userId != null) {
preferences[USER_ID] = userId preferences[USER_ID] = userId
} else {
preferences.remove(USER_ID)
} }
} }
} }

View File

@ -1,127 +1,55 @@
package com.github.nacabaro.vbhelper.source package com.github.nacabaro.vbhelper.source
import android.content.ClipData
import android.content.Context
import android.content.Intent
import androidx.core.content.FileProvider
import com.github.cfogrady.vitalwear.protos.Character import com.github.cfogrady.vitalwear.protos.Character
import com.github.nacabaro.vbhelper.database.AppDatabase import com.github.nacabaro.vbhelper.database.AppDatabase
import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData
import com.github.nacabaro.vbhelper.utils.DeviceType import com.github.nacabaro.vbhelper.utils.DeviceType
import com.google.protobuf.ByteString
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.runBlocking
internal const val DEFAULT_VITALWEAR_TRAINING_TIME_SECONDS = 100L * 60L * 60L import java.io.File
internal fun resolveTrainingSeconds(
deviceType: DeviceType,
beData: BECharacterData?,
): Long {
val remainingTrainingTimeInMinutes = beData?.remainingTrainingTimeInMinutes
if (remainingTrainingTimeInMinutes != null) {
return remainingTrainingTimeInMinutes.toLong() * 60L
}
// VBHelper only stores the explicit training timer on BE payloads. When exporting non-BE
// characters to VitalWear, preserve their ability to train by seeding the same default
// window VitalWear uses for freshly created partners instead of sending an immediate zero.
return if (deviceType == DeviceType.BEDevice) {
0L
} else {
DEFAULT_VITALWEAR_TRAINING_TIME_SECONDS
}
}
class VitalWearCharacterExporter( class VitalWearCharacterExporter(
private val context: Context,
private val database: AppDatabase private val database: AppDatabase
) { ) {
/** fun buildShareIntent(characterId: Long): Intent {
* Builds a Character proto from the stored character data. return runBlocking {
* Used by the HCE ISO-DEP transfer path.
*
* @param characterId ID of the character to export
* @param forcedTransferProfile If specified (e.g., DeviceType.BEDevice for HCE), overrides stored device type
* and ensures stats are serialized for the target ecosystem.
* Used to lock VitalWear (HCE) transfers to BE profile.
*/
suspend fun buildCharacterProto(
characterId: Long,
forcedTransferProfile: DeviceType? = null,
): Character {
val characterWithSprites = database.userCharacterDao().getCharacterWithSprites(characterId) val characterWithSprites = database.userCharacterDao().getCharacterWithSprites(characterId)
val userCharacter = database.userCharacterDao().getCharacter(characterId) val userCharacter = database.userCharacterDao().getCharacter(characterId)
val cardCharacter = database.characterDao().getCharacterById(userCharacter.charId)
?: error("Card character not found for user character $characterId")
val sprite = database.spriteDao().getSpriteById(cardCharacter.spriteId)
?: error("Sprite not found for user character $characterId")
val characterInfo = database.characterDao().getCharacterInfo(userCharacter.charId)
val vwSettings = database.vitalWearSettingsDao().getByCharacterId(characterId)
val card = database.cardDao().getCardByCharacterIdSync(characterId) val card = database.cardDao().getCardByCharacterIdSync(characterId)
?: error("Card not found for character $characterId") ?: error("Card not found for character $characterId")
val cardProgress = database.cardProgressDao().getCardProgressSync(card.id) ?: 0 val cardProgress = database.cardProgressDao().getCardProgressSync(card.id) ?: 0
val beData = database.userCharacterDao().getBeDataOrNull(characterId) val proto = Character.newBuilder()
val vbData = database.userCharacterDao().getVbDataOrNull(characterId)
val normalizedTransformationCountdownMinutes = normalizeTransformationCountdownMinutes(
transformationCountdownMinutes = userCharacter.transformationCountdown,
hasPossibleTransformations = hasPossibleTransformations(userCharacter.charId),
)
// Use forced profile if specified (e.g., BE for HCE route), otherwise use stored type.
// For HCE/VitalWear routes, forcedTransferProfile = BE ensures BE stats are serialized.
val transferDeviceType = forcedTransferProfile ?: userCharacter.characterType
val settingsBuilder = Character.Settings.newBuilder()
.setTrainingInBackground(vwSettings?.trainingInBackground ?: false)
.setAllowedBattles(resolveAllowedBattles(vwSettings?.allowedBattles))
// VitalWear chooses DIM-vs-BE runtime path from card type + assumedFranchise.
// For forced BE HCE exports of DIM cards, provide a stable assumed franchise hint.
if (forcedTransferProfile == DeviceType.BEDevice && !card.isBEm) {
settingsBuilder.setAssumedFranchise(0)
}
return Character.newBuilder()
.setCardId(card.cardId) .setCardId(card.cardId)
.setCardName(card.name) .setCardName(card.name)
.setCharacterStats( .setCharacterStats(
Character.CharacterStats.newBuilder() Character.CharacterStats.newBuilder()
.setSlotId(characterInfo.charId) .setSlotId(database.userCharacterDao().getCharacterInfo(characterId).charaIndex)
.setVitals(characterWithSprites.vitalPoints) .setVitals(characterWithSprites.vitalPoints)
.setTrainingTimeRemainingInSeconds(resolveTrainingSeconds(transferDeviceType, beData)) .setTrainingTimeRemainingInSeconds(resolveTrainingSeconds(characterId, userCharacter.characterType))
.setTimeUntilNextTransformation(normalizedTransformationCountdownMinutes.toLong() * 60L) .setTimeUntilNextTransformation(characterWithSprites.transformationCountdown.toLong() * 60L)
.setTrainedBp(resolveTrainedBp(beData)) .setTrainedBp(resolveTrainedBp(characterId, userCharacter.characterType))
.setTrainedHp(resolveTrainedHp(beData)) .setTrainedHp(resolveTrainedHp(characterId, userCharacter.characterType))
.setTrainedAp(resolveTrainedAp(beData)) .setTrainedAp(resolveTrainedAp(characterId, userCharacter.characterType))
.setTrainedPp(characterWithSprites.trophies) .setTrainedPp(characterWithSprites.trophies)
.setInjured(characterWithSprites.injuryStatus.name.lowercase().contains("inj")) .setInjured(characterWithSprites.injuryStatus.name.lowercase().contains("inj"))
.setAccumulatedDailyInjuries(vwSettings?.accumulatedDailyInjuries ?: 0) .setAccumulatedDailyInjuries(0)
.setTotalBattles(characterWithSprites.totalBattlesWon + characterWithSprites.totalBattlesLost) .setTotalBattles(characterWithSprites.totalBattlesWon + characterWithSprites.totalBattlesLost)
.setCurrentPhaseBattles(characterWithSprites.currentPhaseBattlesWon + characterWithSprites.currentPhaseBattlesLost) .setCurrentPhaseBattles(characterWithSprites.currentPhaseBattlesWon + characterWithSprites.currentPhaseBattlesLost)
.setTotalWins(characterWithSprites.totalBattlesWon) .setTotalWins(characterWithSprites.totalBattlesWon)
.setCurrentPhaseWins(characterWithSprites.currentPhaseBattlesWon) .setCurrentPhaseWins(characterWithSprites.currentPhaseBattlesWon)
.setMood(characterWithSprites.mood) .setMood(characterWithSprites.mood)
.setDeviceType(transferDeviceType.toTransferDeviceType())
.setAgeInDays(userCharacter.ageInDays.coerceAtLeast(0))
.setActivityLevel(userCharacter.activityLevel.coerceAtLeast(0))
.setHeartRateCurrent(userCharacter.heartRateCurrent.coerceAtLeast(0))
.setGeneration(vbData?.generation ?: 0)
.setTotalTrophies(vbData?.totalTrophies ?: userCharacter.trophies.coerceAtLeast(0))
.setNextAdventureMissionStage(characterInfo.currentStage.coerceAtLeast(0))
.setItemEffectMentalStateValue(beData?.itemEffectMentalStateValue ?: 0)
.setItemEffectMentalStateMinutesRemaining(beData?.itemEffectMentalStateMinutesRemaining ?: 0)
.setItemEffectActivityLevelValue(beData?.itemEffectActivityLevelValue ?: 0)
.setItemEffectActivityLevelMinutesRemaining(beData?.itemEffectActivityLevelMinutesRemaining ?: 0)
.setItemEffectVitalPointsChangeValue(beData?.itemEffectVitalPointsChangeValue ?: 0)
.setItemEffectVitalPointsChangeMinutesRemaining(beData?.itemEffectVitalPointsChangeMinutesRemaining ?: 0)
.setAbilityRarity(beData?.abilityRarity?.ordinal ?: 0)
.setAbilityType(beData?.abilityType ?: 0)
.setAbilityBranch(beData?.abilityBranch ?: 0)
.setAbilityReset(beData?.abilityReset ?: 0)
.setRank(beData?.rank ?: 0)
.setItemType(beData?.itemType ?: 0)
.setItemMultiplier(beData?.itemMultiplier ?: 0)
.setItemRemainingTime(beData?.itemRemainingTime ?: 0)
.setFirmwareMinorVersion(beData?.minorVersion ?: 0)
.setFirmwareMajorVersion(beData?.majorVersion ?: 0)
.build() .build()
) )
.setSettings(settingsBuilder.build()) .setSettings(
Character.Settings.newBuilder()
.setTrainingInBackground(false)
.setAllowedBattles(Character.Settings.AllowedBattles.CARD_ONLY)
.build()
)
.putMaxAdventureCompletedByCard(card.name, (cardProgress - 1).coerceAtLeast(0)) .putMaxAdventureCompletedByCard(card.name, (cardProgress - 1).coerceAtLeast(0))
.addAllTransformationHistory( .addAllTransformationHistory(
database.userCharacterDao().getTransformationHistoryForExport(characterId).map { database.userCharacterDao().getTransformationHistoryForExport(characterId).map {
@ -132,68 +60,55 @@ class VitalWearCharacterExporter(
.build() .build()
} }
) )
.setTransferCard(
Character.TransferCard.newBuilder()
.setCardId(card.cardId)
.setCardName(card.name)
.setIsBem(card.isBEm)
.setStageCount(card.stageCount)
.setFranchise(0)
.build()
)
.setTransferSpecies(
Character.TransferSpecies.newBuilder()
.setSlotId(characterInfo.charId)
.setStage(cardCharacter.stage)
.setAttribute(cardCharacter.attribute.ordinal)
.setBaseHp(cardCharacter.baseHp)
.setBaseBp(cardCharacter.baseBp)
.setBaseAp(cardCharacter.baseAp)
.setNameSprite(ByteString.copyFrom(cardCharacter.nameSprite))
.setNameSpriteWidth(cardCharacter.nameWidth)
.setNameSpriteHeight(cardCharacter.nameHeight)
.setIdle1(ByteString.copyFrom(sprite.spriteIdle1))
.setIdle2(ByteString.copyFrom(sprite.spriteIdle2))
.setWalk1(ByteString.copyFrom(sprite.spriteWalk1))
.setWalk2(ByteString.copyFrom(sprite.spriteWalk2))
.setRun1(ByteString.copyFrom(sprite.spriteRun1))
.setRun2(ByteString.copyFrom(sprite.spriteRun2))
.setTrain1(ByteString.copyFrom(sprite.spriteTrain1))
.setTrain2(ByteString.copyFrom(sprite.spriteTrain2))
.setWin(ByteString.copyFrom(sprite.spriteHappy))
.setDown(ByteString.copyFrom(sprite.spriteSleep))
.setAttack(ByteString.copyFrom(sprite.spriteAttack))
.setDodge(ByteString.copyFrom(sprite.spriteDodge))
.setSplash(ByteString.copyFrom(sprite.spriteIdle1))
.setSpriteWidth(sprite.width)
.setSpriteHeight(sprite.height)
.build()
)
.build() .build()
val exportDir = File(context.cacheDir, "exports").apply { mkdirs() }
val exportFile = File(exportDir, "vbhelper_character_$characterId.vitalwear")
exportFile.writeBytes(proto.toByteArray())
val exportUri = FileProvider.getUriForFile(context, "${context.packageName}.provider", exportFile)
Intent(Intent.ACTION_SEND).apply {
`package` = "com.github.cfogrady.vitalwear"
type = VITALWEAR_CHARACTER_MIME
putExtra(Intent.EXTRA_STREAM, exportUri)
clipData = ClipData.newRawUri("", exportUri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
}
} }
private fun resolveTrainingSeconds(characterId: Long, deviceType: DeviceType): Long {
private fun resolveTrainedBp(beData: com.github.nacabaro.vbhelper.domain.device_data.BECharacterData?): Int { if (deviceType != DeviceType.BEDevice) {
return beData?.trainingBp ?: 0 return 0L
}
return database.userCharacterDao().getBeData(characterId).valueOrNull()?.remainingTrainingTimeInMinutes?.toLong()?.times(60L) ?: 0L
} }
private fun resolveTrainedHp(beData: com.github.nacabaro.vbhelper.domain.device_data.BECharacterData?): Int { private fun resolveTrainedBp(characterId: Long, deviceType: DeviceType): Int {
return beData?.trainingHp ?: 0 return if (deviceType == DeviceType.BEDevice) {
database.userCharacterDao().getBeData(characterId).valueOrNull()?.trainingBp ?: 0
} else 0
} }
private fun resolveTrainedAp(beData: com.github.nacabaro.vbhelper.domain.device_data.BECharacterData?): Int { private fun resolveTrainedHp(characterId: Long, deviceType: DeviceType): Int {
return beData?.trainingAp ?: 0 return if (deviceType == DeviceType.BEDevice) {
database.userCharacterDao().getBeData(characterId).valueOrNull()?.trainingHp ?: 0
} else 0
} }
private suspend fun hasPossibleTransformations(cardCharacterId: Long): Boolean { private fun resolveTrainedAp(characterId: Long, deviceType: DeviceType): Int {
return database.characterDao().getEvolutionRequirementsForCard(cardCharacterId).firstOrNull()?.isNotEmpty() == true return if (deviceType == DeviceType.BEDevice) {
database.userCharacterDao().getBeData(characterId).valueOrNull()?.trainingAp ?: 0
} else 0
} }
private fun <T> kotlinx.coroutines.flow.Flow<T>.valueOrNull(): T? {
private fun resolveAllowedBattles(rawValue: Int?): Character.Settings.AllowedBattles { return runBlocking {
if (rawValue == null) return Character.Settings.AllowedBattles.CARD_ONLY firstOrNull()
return Character.Settings.AllowedBattles.forNumber(rawValue) }
?: Character.Settings.AllowedBattles.CARD_ONLY
} }
companion object {
const val VITALWEAR_CHARACTER_MIME = "application/x-vitalwear-character"
}
} }

View File

@ -1,25 +1,15 @@
package com.github.nacabaro.vbhelper.source package com.github.nacabaro.vbhelper.source
import com.github.cfogrady.vbnfc.data.NfcCharacter import com.github.cfogrady.vbnfc.data.NfcCharacter
import com.github.cfogrady.vitalwear.common.data.SharedTransferSeenDao
import com.github.cfogrady.vitalwear.protos.Character import com.github.cfogrady.vitalwear.protos.Character
import com.github.nacabaro.vbhelper.database.AppDatabase import com.github.nacabaro.vbhelper.database.AppDatabase
import com.github.nacabaro.vbhelper.domain.card.Card
import com.github.nacabaro.vbhelper.domain.card.CardCharacter
import com.github.nacabaro.vbhelper.domain.card.CardProgress
import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData
import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter
import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData
import com.github.nacabaro.vbhelper.domain.device_data.VitalWearCharacterSettings
import com.github.nacabaro.vbhelper.domain.characters.Sprite
import com.github.nacabaro.vbhelper.utils.DeviceType import com.github.nacabaro.vbhelper.utils.DeviceType
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.runBlocking
import kotlin.math.max import kotlin.math.max
class VitalWearCharacterImporter( class VitalWearCharacterImporter(
private val database: AppDatabase, private val database: AppDatabase
private val transferSeenDao: SharedTransferSeenDao,
) { ) {
data class ImportResult( data class ImportResult(
val success: Boolean, val success: Boolean,
@ -28,16 +18,15 @@ class VitalWearCharacterImporter(
fun importCharacter(character: Character): ImportResult { fun importCharacter(character: Character): ImportResult {
val importedCard = resolveCard(character) val importedCard = resolveCard(character)
?: createPlaceholderCard(character)
?: return ImportResult( ?: return ImportResult(
success = false, success = false,
message = "Matching card not found in VBHelper. Import that card first." message = "Matching card not found in VBHelper. Import that card first."
) )
val slotId = character.characterStats.slotId val slotId = character.characterStats.slotId
val cardCharacter = database.characterDao().getCharacterByMonIndexOrNull(slotId, importedCard.id) val cardCharacter = runCatching {
?: createPlaceholderCharacter(importedCard, character) database.characterDao().getCharacterByMonIndex(slotId, importedCard.id)
?: return ImportResult( }.getOrNull() ?: return ImportResult(
success = false, success = false,
message = "Character slot $slotId was not found on card ${importedCard.name}." message = "Character slot $slotId was not found on card ${importedCard.name}."
) )
@ -48,70 +37,27 @@ class VitalWearCharacterImporter(
val totalWins = character.characterStats.totalWins.coerceAtMost(totalBattles) val totalWins = character.characterStats.totalWins.coerceAtMost(totalBattles)
val currentPhaseBattles = character.characterStats.currentPhaseBattles val currentPhaseBattles = character.characterStats.currentPhaseBattles
val currentPhaseWins = character.characterStats.currentPhaseWins.coerceAtMost(currentPhaseBattles) val currentPhaseWins = character.characterStats.currentPhaseWins.coerceAtMost(currentPhaseBattles)
val normalizedTransformationCountdown = normalizeTransformationCountdownMinutes(
transformationCountdownMinutes = secondsToMinutes(character.characterStats.timeUntilNextTransformation),
hasPossibleTransformations = hasPossibleTransformations(cardCharacter.id),
)
val hasBePayloadStats = character.characterStats.trainedHp > 0 ||
character.characterStats.trainedAp > 0 ||
character.characterStats.trainedBp > 0 ||
character.characterStats.trainingTimeRemainingInSeconds > 0L
val fallbackIsBeCharacter = importedCard.isBEm || hasBePayloadStats
val deviceType = resolveDeviceType(
transferDeviceType = character.characterStats.deviceType,
fallbackIsBeCharacter = fallbackIsBeCharacter,
)
val isBeCharacter = deviceType == DeviceType.BEDevice
val userCharacterId = database.userCharacterDao().insertCharacterData( val userCharacterId = database.userCharacterDao().insertCharacterData(
UserCharacter( UserCharacter(
charId = cardCharacter.id, charId = cardCharacter.id,
ageInDays = character.characterStats.ageInDays.takeIf { it > 0 } ageInDays = max(character.transformationHistoryCount - 1, 0),
?: max(character.transformationHistoryCount - 1, 0),
mood = character.characterStats.mood, mood = character.characterStats.mood,
vitalPoints = character.characterStats.vitals, vitalPoints = character.characterStats.vitals,
transformationCountdown = normalizedTransformationCountdown, transformationCountdown = secondsToMinutes(character.characterStats.timeUntilNextTransformation),
injuryStatus = resolveInjuryStatus(character.characterStats.injured), injuryStatus = resolveInjuryStatus(character.characterStats.injured),
trophies = character.characterStats.trainedPp, trophies = character.characterStats.trainedPp,
currentPhaseBattlesWon = currentPhaseWins, currentPhaseBattlesWon = currentPhaseWins,
currentPhaseBattlesLost = (currentPhaseBattles - currentPhaseWins).coerceAtLeast(0), currentPhaseBattlesLost = (currentPhaseBattles - currentPhaseWins).coerceAtLeast(0),
totalBattlesWon = totalWins, totalBattlesWon = totalWins,
totalBattlesLost = (totalBattles - totalWins).coerceAtLeast(0), totalBattlesLost = (totalBattles - totalWins).coerceAtLeast(0),
activityLevel = character.characterStats.activityLevel.coerceAtLeast(0), activityLevel = 0,
heartRateCurrent = character.characterStats.heartRateCurrent.coerceAtLeast(0), heartRateCurrent = 0,
characterType = deviceType, characterType = DeviceType.BEDevice,
isActive = true isActive = true
) )
) )
runBlocking {
database.vitalWearSettingsDao().upsert(
VitalWearCharacterSettings(
characterId = userCharacterId,
trainingInBackground = character.settings.trainingInBackground,
allowedBattles = character.settings.allowedBattles.number,
accumulatedDailyInjuries = character.characterStats.accumulatedDailyInjuries,
)
)
}
val vbProfile = VBCharacterData(
id = userCharacterId,
generation = if (character.characterStats.generation > 0) {
character.characterStats.generation
} else {
max(character.transformationHistoryCount - 1, 0)
},
totalTrophies = if (character.characterStats.totalTrophies > 0) {
character.characterStats.totalTrophies
} else {
character.characterStats.trainedPp.coerceAtLeast(0)
}
)
database.userCharacterDao().insertVBCharacterData(vbProfile)
if (isBeCharacter) {
val abilityRarity = resolveAbilityRarity(character.characterStats.abilityRarity)
database.userCharacterDao().insertBECharacterData( database.userCharacterDao().insertBECharacterData(
BECharacterData( BECharacterData(
id = userCharacterId, id = userCharacterId,
@ -119,39 +65,32 @@ class VitalWearCharacterImporter(
trainingAp = character.characterStats.trainedAp, trainingAp = character.characterStats.trainedAp,
trainingBp = character.characterStats.trainedBp, trainingBp = character.characterStats.trainedBp,
remainingTrainingTimeInMinutes = secondsToMinutes(character.characterStats.trainingTimeRemainingInSeconds), remainingTrainingTimeInMinutes = secondsToMinutes(character.characterStats.trainingTimeRemainingInSeconds),
itemEffectMentalStateValue = character.characterStats.itemEffectMentalStateValue, itemEffectMentalStateValue = 0,
itemEffectMentalStateMinutesRemaining = character.characterStats.itemEffectMentalStateMinutesRemaining, itemEffectMentalStateMinutesRemaining = 0,
itemEffectActivityLevelValue = character.characterStats.itemEffectActivityLevelValue, itemEffectActivityLevelValue = 0,
itemEffectActivityLevelMinutesRemaining = character.characterStats.itemEffectActivityLevelMinutesRemaining, itemEffectActivityLevelMinutesRemaining = 0,
itemEffectVitalPointsChangeValue = character.characterStats.itemEffectVitalPointsChangeValue, itemEffectVitalPointsChangeValue = 0,
itemEffectVitalPointsChangeMinutesRemaining = character.characterStats.itemEffectVitalPointsChangeMinutesRemaining, itemEffectVitalPointsChangeMinutesRemaining = 0,
abilityRarity = abilityRarity, abilityRarity = resolveDefaultAbilityRarity(),
abilityType = character.characterStats.abilityType, abilityType = 0,
abilityBranch = character.characterStats.abilityBranch, abilityBranch = 0,
abilityReset = character.characterStats.abilityReset, abilityReset = 0,
rank = character.characterStats.rank, rank = 0,
itemType = character.characterStats.itemType, itemType = 0,
itemMultiplier = character.characterStats.itemMultiplier, itemMultiplier = 0,
itemRemainingTime = character.characterStats.itemRemainingTime, itemRemainingTime = 0,
otp0 = "", otp0 = "",
otp1 = "", otp1 = "",
minorVersion = character.characterStats.firmwareMinorVersion, minorVersion = 0,
majorVersion = character.characterStats.firmwareMajorVersion majorVersion = 0
) )
) )
}
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
markSeen(importedCard.name, slotId, importedCard.id, now) database.dexDao().insertCharacter(slotId, importedCard.id, now)
var insertedTransformationCount = 0
for (transformation in character.transformationHistoryList) { for (transformation in character.transformationHistoryList) {
val transformationCard = resolveRelatedCard( val transformationCard = resolveCard(transformation.cardName, character.cardId)
incomingCardName = transformation.cardName,
incomingCardId = character.cardId,
matchedRootCard = importedCard,
incomingRootCardName = character.cardName,
)
if (transformationCard != null) { if (transformationCard != null) {
database.userCharacterDao().insertTransformation( database.userCharacterDao().insertTransformation(
userCharacterId, userCharacterId,
@ -159,28 +98,12 @@ class VitalWearCharacterImporter(
transformationCard.id, transformationCard.id,
now now
) )
insertedTransformationCount++ database.dexDao().insertCharacter(transformation.slotId, transformationCard.id, now)
markSeen(transformationCard.name, transformation.slotId, transformationCard.id, now)
} }
} }
if (insertedTransformationCount == 0) {
// Keep HomeScreen renderable for freshly imported characters with empty history.
database.userCharacterDao().insertTransformation(
userCharacterId,
slotId,
importedCard.id,
now
)
}
for ((cardName, maxAdventureCompleted) in character.maxAdventureCompletedByCardMap) { for ((cardName, maxAdventureCompleted) in character.maxAdventureCompletedByCardMap) {
val adventureCard = resolveRelatedCard( val adventureCard = resolveCard(cardName, null) ?: continue
incomingCardName = cardName,
incomingCardId = null,
matchedRootCard = importedCard,
incomingRootCardName = character.cardName,
) ?: continue
val currentStage = (maxAdventureCompleted + 1).coerceAtLeast(0) val currentStage = (maxAdventureCompleted + 1).coerceAtLeast(0)
database.cardProgressDao().updateCardProgress( database.cardProgressDao().updateCardProgress(
currentStage = currentStage, currentStage = currentStage,
@ -189,189 +112,6 @@ class VitalWearCharacterImporter(
) )
} }
val nextAdventureMissionStage = character.characterStats.nextAdventureMissionStage.coerceAtLeast(0)
if (nextAdventureMissionStage > 0) {
database.cardProgressDao().updateCardProgress(
currentStage = nextAdventureMissionStage,
cardId = importedCard.id,
unlocked = nextAdventureMissionStage > importedCard.stageCount,
)
}
return ImportResult(
success = true,
message = "Imported ${importedCard.name} slot $slotId from VitalWear."
)
}
/**
* Import a VitalWear character with a forced device type.
* Used by HCE (VitalWear) read path to ensure imports are marked as BE.
*
* @param character Protobuf character from VitalWear HCE
* @param forcedDeviceType Always use this device type for the imported character (e.g., BEDevice for HCE)
*/
fun importCharacter(character: Character, forcedDeviceType: DeviceType): ImportResult {
val importedCard = resolveCard(character)
?: createPlaceholderCard(character)
?: return ImportResult(
success = false,
message = "Matching card not found in VBHelper. Import that card first."
)
val slotId = character.characterStats.slotId
val cardCharacter = database.characterDao().getCharacterByMonIndexOrNull(slotId, importedCard.id)
?: createPlaceholderCharacter(importedCard, character)
?: return ImportResult(
success = false,
message = "Character slot $slotId was not found on card ${importedCard.name}."
)
database.userCharacterDao().clearActiveCharacter()
val totalBattles = character.characterStats.totalBattles
val totalWins = character.characterStats.totalWins.coerceAtMost(totalBattles)
val currentPhaseBattles = character.characterStats.currentPhaseBattles
val currentPhaseWins = character.characterStats.currentPhaseWins.coerceAtMost(currentPhaseBattles)
val normalizedTransformationCountdown = normalizeTransformationCountdownMinutes(
transformationCountdownMinutes = secondsToMinutes(character.characterStats.timeUntilNextTransformation),
hasPossibleTransformations = hasPossibleTransformations(cardCharacter.id),
)
val userCharacterId = database.userCharacterDao().insertCharacterData(
UserCharacter(
charId = cardCharacter.id,
ageInDays = character.characterStats.ageInDays.takeIf { it > 0 }
?: max(character.transformationHistoryCount - 1, 0),
mood = character.characterStats.mood,
vitalPoints = character.characterStats.vitals,
transformationCountdown = normalizedTransformationCountdown,
injuryStatus = resolveInjuryStatus(character.characterStats.injured),
trophies = character.characterStats.trainedPp,
currentPhaseBattlesWon = currentPhaseWins,
currentPhaseBattlesLost = (currentPhaseBattles - currentPhaseWins).coerceAtLeast(0),
totalBattlesWon = totalWins,
totalBattlesLost = (totalBattles - totalWins).coerceAtLeast(0),
activityLevel = character.characterStats.activityLevel.coerceAtLeast(0),
heartRateCurrent = character.characterStats.heartRateCurrent.coerceAtLeast(0),
characterType = forcedDeviceType,
isActive = true
)
)
runBlocking {
database.vitalWearSettingsDao().upsert(
VitalWearCharacterSettings(
characterId = userCharacterId,
trainingInBackground = character.settings.trainingInBackground,
allowedBattles = character.settings.allowedBattles.number,
accumulatedDailyInjuries = character.characterStats.accumulatedDailyInjuries,
)
)
}
val vbProfile = VBCharacterData(
id = userCharacterId,
generation = if (character.characterStats.generation > 0) {
character.characterStats.generation
} else {
max(character.transformationHistoryCount - 1, 0)
},
totalTrophies = if (character.characterStats.totalTrophies > 0) {
character.characterStats.totalTrophies
} else {
character.characterStats.trainedPp.coerceAtLeast(0)
}
)
database.userCharacterDao().insertVBCharacterData(vbProfile)
if (forcedDeviceType == DeviceType.BEDevice) {
val abilityRarity = resolveAbilityRarity(character.characterStats.abilityRarity)
database.userCharacterDao().insertBECharacterData(
BECharacterData(
id = userCharacterId,
trainingHp = character.characterStats.trainedHp,
trainingAp = character.characterStats.trainedAp,
trainingBp = character.characterStats.trainedBp,
remainingTrainingTimeInMinutes = secondsToMinutes(character.characterStats.trainingTimeRemainingInSeconds),
itemEffectMentalStateValue = character.characterStats.itemEffectMentalStateValue,
itemEffectMentalStateMinutesRemaining = character.characterStats.itemEffectMentalStateMinutesRemaining,
itemEffectActivityLevelValue = character.characterStats.itemEffectActivityLevelValue,
itemEffectActivityLevelMinutesRemaining = character.characterStats.itemEffectActivityLevelMinutesRemaining,
itemEffectVitalPointsChangeValue = character.characterStats.itemEffectVitalPointsChangeValue,
itemEffectVitalPointsChangeMinutesRemaining = character.characterStats.itemEffectVitalPointsChangeMinutesRemaining,
abilityRarity = abilityRarity,
abilityType = character.characterStats.abilityType,
abilityBranch = character.characterStats.abilityBranch,
abilityReset = character.characterStats.abilityReset,
rank = character.characterStats.rank,
itemType = character.characterStats.itemType,
itemMultiplier = character.characterStats.itemMultiplier,
itemRemainingTime = character.characterStats.itemRemainingTime,
otp0 = "",
otp1 = "",
minorVersion = character.characterStats.firmwareMinorVersion,
majorVersion = character.characterStats.firmwareMajorVersion
)
)
}
val now = System.currentTimeMillis()
markSeen(importedCard.name, slotId, importedCard.id, now)
var insertedTransformationCount = 0
for (transformation in character.transformationHistoryList) {
val transformationCard = resolveRelatedCard(
incomingCardName = transformation.cardName,
incomingCardId = character.cardId,
matchedRootCard = importedCard,
incomingRootCardName = character.cardName,
)
if (transformationCard != null) {
database.userCharacterDao().insertTransformation(
userCharacterId,
transformation.slotId,
transformationCard.id,
now
)
insertedTransformationCount++
markSeen(transformationCard.name, transformation.slotId, transformationCard.id, now)
}
}
if (insertedTransformationCount == 0) {
database.userCharacterDao().insertTransformation(
userCharacterId,
slotId,
importedCard.id,
now
)
}
for ((cardName, maxAdventureCompleted) in character.maxAdventureCompletedByCardMap) {
val adventureCard = resolveRelatedCard(
incomingCardName = cardName,
incomingCardId = null,
matchedRootCard = importedCard,
incomingRootCardName = character.cardName,
) ?: continue
val currentStage = (maxAdventureCompleted + 1).coerceAtLeast(0)
database.cardProgressDao().updateCardProgress(
currentStage = currentStage,
cardId = adventureCard.id,
unlocked = currentStage > adventureCard.stageCount
)
}
val nextAdventureMissionStage = character.characterStats.nextAdventureMissionStage.coerceAtLeast(0)
if (nextAdventureMissionStage > 0) {
database.cardProgressDao().updateCardProgress(
currentStage = nextAdventureMissionStage,
cardId = importedCard.id,
unlocked = nextAdventureMissionStage > importedCard.stageCount,
)
}
return ImportResult( return ImportResult(
success = true, success = true,
message = "Imported ${importedCard.name} slot $slotId from VitalWear." message = "Imported ${importedCard.name} slot $slotId from VitalWear."
@ -380,172 +120,26 @@ class VitalWearCharacterImporter(
private fun resolveCard(character: Character) = resolveCard(character.cardName, character.cardId) private fun resolveCard(character: Character) = resolveCard(character.cardName, character.cardId)
private fun resolveCard(cardName: String?, cardId: Int?): Card? { private fun resolveCard(cardName: String?, cardId: Int?): com.github.nacabaro.vbhelper.domain.card.Card? {
return selectImportedCard( if (!cardName.isNullOrBlank()) {
candidates = database.cardDao().getAllCards(), database.cardDao().getCardByName(cardName)?.let { return it }
incomingCardName = cardName,
incomingCardId = cardId,
)
} }
private fun resolveRelatedCard( if (cardId != null) {
incomingCardName: String?, val matches = database.cardDao().getCardByCardId(cardId)
incomingCardId: Int?, if (matches.size == 1) {
matchedRootCard: Card, return matches.first()
incomingRootCardName: String,
): Card? {
if (incomingCardName.isNullOrBlank()) {
return if (incomingCardId != null && incomingCardId > 0 && matchedRootCard.cardId == incomingCardId) {
matchedRootCard
} else {
null
} }
} }
if (cardNamesMatch(incomingCardName, incomingRootCardName)) {
return matchedRootCard
}
return resolveCard(incomingCardName, incomingCardId)
}
private fun createPlaceholderCard(character: Character): Card? {
if (!character.hasTransferCard() || !character.hasTransferSpecies()) {
return null return null
} }
val transferCard = character.transferCard
selectImportedCard(
candidates = database.cardDao().getAllCards(),
incomingCardName = transferCard.cardName,
incomingCardId = transferCard.cardId,
)?.let { return it }
val transferSpecies = character.transferSpecies
val baseName = transferCard.cardName
.takeIf { it.isNotBlank() }
?: character.cardName.takeIf { it.isNotBlank() }
?: "Transferred Card ${transferCard.cardId}"
val placeholderName = buildPlaceholderCardName(baseName, transferCard.cardId)
val logoBytes = when {
!transferSpecies.nameSprite.isEmpty -> transferSpecies.nameSprite.toByteArray()
!transferSpecies.idle1.isEmpty -> transferSpecies.idle1.toByteArray()
else -> byteArrayOf()
}
val logoWidth = when {
transferSpecies.nameSpriteWidth > 0 -> transferSpecies.nameSpriteWidth
transferSpecies.spriteWidth > 0 -> transferSpecies.spriteWidth
else -> 0
}
val logoHeight = when {
transferSpecies.nameSpriteHeight > 0 -> transferSpecies.nameSpriteHeight
transferSpecies.spriteHeight > 0 -> transferSpecies.spriteHeight
else -> 0
}
val insertedId = runBlocking {
database.cardDao().insertNewCard(
Card(
cardId = transferCard.cardId,
logo = logoBytes,
logoWidth = logoWidth,
logoHeight = logoHeight,
name = placeholderName,
stageCount = transferCard.stageCount.coerceAtLeast(0),
isBEm = transferCard.isBem,
)
)
}
val resolvedCard = when {
insertedId > 0L -> database.cardDao().getCardById(insertedId)
else -> database.cardDao().getCardByName(placeholderName)
} ?: return null
if (database.cardProgressDao().getCardProgressSync(resolvedCard.id) == null) {
database.cardProgressDao().insertCardProgress(
CardProgress(
cardId = resolvedCard.id,
currentStage = 1,
unlocked = false,
)
)
}
return resolvedCard
}
private fun createPlaceholderCharacter(card: Card, character: Character): CardCharacter? {
if (!character.hasTransferSpecies()) {
return null
}
val transferSpecies = character.transferSpecies
database.characterDao().getCharacterByMonIndexOrNull(transferSpecies.slotId, card.id)?.let { return it }
val spriteId = database.spriteDao().insertSprite(
Sprite(
spriteIdle1 = transferSpecies.idle1.toByteArray(),
spriteIdle2 = transferSpecies.idle2.toByteArray(),
spriteWalk1 = transferSpecies.walk1.toByteArray(),
spriteWalk2 = transferSpecies.walk2.toByteArray(),
spriteRun1 = transferSpecies.run1.toByteArray(),
spriteRun2 = transferSpecies.run2.toByteArray(),
spriteTrain1 = transferSpecies.train1.toByteArray(),
spriteTrain2 = transferSpecies.train2.toByteArray(),
spriteHappy = transferSpecies.win.toByteArray(),
spriteSleep = transferSpecies.down.toByteArray(),
spriteAttack = transferSpecies.attack.toByteArray(),
spriteDodge = transferSpecies.dodge.toByteArray(),
width = transferSpecies.spriteWidth,
height = transferSpecies.spriteHeight,
)
)
val attribute = enumValues<NfcCharacter.Attribute>().getOrElse(transferSpecies.attribute) {
enumValues<NfcCharacter.Attribute>().first()
}
runBlocking {
database.characterDao().insertCharacter(
CardCharacter(
cardId = card.id,
spriteId = spriteId,
charaIndex = transferSpecies.slotId,
stage = transferSpecies.stage,
attribute = attribute,
baseHp = transferSpecies.baseHp,
baseBp = transferSpecies.baseBp,
baseAp = transferSpecies.baseAp,
nameSprite = transferSpecies.nameSprite.toByteArray(),
nameWidth = transferSpecies.nameSpriteWidth,
nameHeight = transferSpecies.nameSpriteHeight,
)
)
}
return database.characterDao().getCharacterByMonIndexOrNull(transferSpecies.slotId, card.id)
}
private fun buildPlaceholderCardName(baseName: String, cardId: Int): String {
val trimmedBaseName = baseName.trim().ifBlank { "Transferred Card" }
database.cardDao().getCardByName(trimmedBaseName)?.let {
return if (cardId > 0) "$trimmedBaseName [Transfer $cardId]" else "$trimmedBaseName [Transfer]"
}
return trimmedBaseName
}
private fun secondsToMinutes(seconds: Long): Int { private fun secondsToMinutes(seconds: Long): Int {
if (seconds <= 0L) { if (seconds <= 0L) {
return 0 return 0
} }
// Preserve non-zero timers from HCE payloads instead of flooring 1..59s to 0. return (seconds / 60L).coerceAtMost(Int.MAX_VALUE.toLong()).toInt()
return ((seconds + 59L) / 60L).coerceAtMost(Int.MAX_VALUE.toLong()).toInt()
}
private fun hasPossibleTransformations(cardCharacterId: Long): Boolean {
return runBlocking {
database.characterDao().getEvolutionRequirementsForCard(cardCharacterId).firstOrNull()?.isNotEmpty() == true
}
} }
private fun resolveInjuryStatus(injured: Boolean): NfcCharacter.InjuryStatus { private fun resolveInjuryStatus(injured: Boolean): NfcCharacter.InjuryStatus {
@ -566,66 +160,4 @@ class VitalWearCharacterImporter(
private fun resolveDefaultAbilityRarity(): NfcCharacter.AbilityRarity { private fun resolveDefaultAbilityRarity(): NfcCharacter.AbilityRarity {
return enumValues<NfcCharacter.AbilityRarity>().first() return enumValues<NfcCharacter.AbilityRarity>().first()
} }
private fun resolveAbilityRarity(rawValue: Int): NfcCharacter.AbilityRarity {
val rarities = enumValues<NfcCharacter.AbilityRarity>()
return rarities.getOrElse(rawValue) { resolveDefaultAbilityRarity() }
}
private fun markSeen(cardName: String, slotId: Int, cardId: Long, timestamp: Long) {
database.dexDao().insertCharacter(slotId, cardId, timestamp)
transferSeenDao.markSeen(cardName, slotId, timestamp)
}
} }
internal fun selectImportedCard(
candidates: List<Card>,
incomingCardName: String?,
incomingCardId: Int?,
): Card? {
val requestedName = incomingCardName?.takeIf { it.isNotBlank() }
val idMatches = if (incomingCardId != null && incomingCardId > 0) {
candidates.filter { it.cardId == incomingCardId }
} else {
emptyList()
}
requestedName?.let { requestedNameValue ->
candidates.firstOrNull {
it.name == requestedNameValue && (incomingCardId == null || incomingCardId <= 0 || it.cardId == incomingCardId)
}?.let { return it }
val ignoreCaseMatches = candidates.filter {
it.name.equals(requestedNameValue, ignoreCase = true) &&
(incomingCardId == null || incomingCardId <= 0 || it.cardId == incomingCardId)
}
if (ignoreCaseMatches.size == 1) {
return ignoreCaseMatches.single()
}
}
if (idMatches.size == 1) {
return idMatches.single()
}
requestedName?.let { requestedNameValue ->
val normalizedMatches = candidates.filter {
cardNamesMatch(it.name, requestedNameValue) &&
(incomingCardId == null || incomingCardId <= 0 || it.cardId == incomingCardId)
}
if (normalizedMatches.size == 1) {
return normalizedMatches.single()
}
}
return null
}
internal fun cardNamesMatch(left: String, right: String): Boolean {
return left.equals(right, ignoreCase = true) || left.toNormalizedCardLookupKey() == right.toNormalizedCardLookupKey()
}
private fun String.toNormalizedCardLookupKey(): String {
return lowercase().filter { it.isLetterOrDigit() }
}

View File

@ -42,7 +42,7 @@ fun BitmapData.getImageBitmap(
} }
fun BitmapData.getBitmap(): Bitmap { fun BitmapData.getBitmap(): Bitmap {
return Bitmap.createBitmap(createARGBIntArray(), this.width, this.height, Bitmap.Config.ARGB_8888) return Bitmap.createBitmap(createARGBIntArray(), this.width, this.height, Bitmap.Config.HARDWARE)
} }
object ARGBMasks { object ARGBMasks {
@ -64,7 +64,7 @@ fun BitmapData.getObscuredBitmap(): Bitmap {
argbPixels[i] = BLACK argbPixels[i] = BLACK
} }
} }
return Bitmap.createBitmap(argbPixels, this.width, this.height, Bitmap.Config.ARGB_8888) return Bitmap.createBitmap(argbPixels, this.width, this.height, Bitmap.Config.HARDWARE)
} }

View File

@ -7,48 +7,7 @@ message Character {
string card_name = 1; string card_name = 1;
int32 card_id = 2; int32 card_id = 2;
message TransferCard {
int32 card_id = 1;
string card_name = 2;
bool is_bem = 3;
int32 stage_count = 4;
int32 franchise = 5;
}
message TransferSpecies {
int32 slot_id = 1;
int32 stage = 2;
int32 attribute = 3;
int32 base_hp = 4;
int32 base_bp = 5;
int32 base_ap = 6;
bytes name_sprite = 7;
int32 name_sprite_width = 8;
int32 name_sprite_height = 9;
bytes idle1 = 10;
bytes idle2 = 11;
bytes walk1 = 12;
bytes walk2 = 13;
bytes run1 = 14;
bytes run2 = 15;
bytes train1 = 16;
bytes train2 = 17;
bytes win = 18;
bytes down = 19;
bytes attack = 20;
bytes dodge = 21;
bytes splash = 22;
int32 sprite_width = 23;
int32 sprite_height = 24;
}
message CharacterStats { message CharacterStats {
enum TransferDeviceType {
TRANSFER_DEVICE_TYPE_UNSPECIFIED = 0;
TRANSFER_DEVICE_TYPE_VB = 1;
TRANSFER_DEVICE_TYPE_BE = 2;
}
int32 slot_id = 1; int32 slot_id = 1;
int32 vitals = 2; int32 vitals = 2;
int64 training_time_remaining_in_seconds = 3; int64 training_time_remaining_in_seconds = 3;
@ -64,29 +23,6 @@ message Character {
int32 total_wins = 13; int32 total_wins = 13;
int32 current_phase_wins = 14; int32 current_phase_wins = 14;
int32 mood = 15; int32 mood = 15;
TransferDeviceType device_type = 16;
int32 age_in_days = 17;
int32 activity_level = 18;
int32 heart_rate_current = 19;
int32 generation = 20;
int32 total_trophies = 21;
int32 next_adventure_mission_stage = 22;
int32 item_effect_mental_state_value = 23;
int32 item_effect_mental_state_minutes_remaining = 24;
int32 item_effect_activity_level_value = 25;
int32 item_effect_activity_level_minutes_remaining = 26;
int32 item_effect_vital_points_change_value = 27;
int32 item_effect_vital_points_change_minutes_remaining = 28;
int32 ability_rarity = 29;
int32 ability_type = 30;
int32 ability_branch = 31;
int32 ability_reset = 32;
int32 rank = 33;
int32 item_type = 34;
int32 item_multiplier = 35;
int32 item_remaining_time = 36;
int32 firmware_minor_version = 37;
int32 firmware_major_version = 38;
} }
CharacterStats character_stats = 3; CharacterStats character_stats = 3;
@ -112,8 +48,4 @@ message Character {
repeated TransformationEvent transformation_history = 5; repeated TransformationEvent transformation_history = 5;
map<string, int32> max_adventure_completed_by_card = 6; map<string, int32> max_adventure_completed_by_card = 6;
optional TransferCard transfer_card = 7;
optional TransferSpecies transfer_species = 8;
} }

View File

@ -56,14 +56,6 @@
<string name="scan_error_generic">Ops!</string> <string name="scan_error_generic">Ops!</string>
<string name="scan_sent_dim_success">DIM enviado com sucesso!</string> <string name="scan_sent_dim_success">DIM enviado com sucesso!</string>
<string name="scan_nfc_must_be_enabled">O NFC precisa estar ativado.</string> <string name="scan_nfc_must_be_enabled">O NFC precisa estar ativado.</string>
<string name="scan_target_device_full">O dispositivo de destino está cheio (ativo + backup). Remova um personagem antes de enviar.</string>
<string name="scan_transfer_skipped_storage_state">Transferência ignorada. O envio só é permitido quando o brinquedo tem um personagem ativo e o slot de backup está vazio.</string>
<string name="scan_transfer_waiting_tap">Pronto. Encoste e mantenha o celular e o relógio juntos até concluir.</string>
<string name="scan_transfer_detected_keep_tap">Conexão detectada. Mantenha os dispositivos juntos...</string>
<string name="scan_transfer_in_progress">Transferindo agora. Não afaste o celular ainda.</string>
<string name="scan_transfer_complete_remove">Transferência concluída. Agora você pode afastar o celular.</string>
<string name="scan_transfer_failed_try_again">Falha na transferência. Reposicione os dispositivos e tente novamente.</string>
<string name="scan_transfer_cancelled">Transferência cancelada.</string>
<string name="settings_title">Configurações</string> <string name="settings_title">Configurações</string>
@ -178,7 +170,7 @@
<string name="dex_chara_name_icon_description">Nome do personagem</string> <string name="dex_chara_name_icon_description">Nome do personagem</string>
<string name="dex_chara_stats">HP: %1$d, BP: %2$d, AP: %3$d</string> <string name="dex_chara_stats">HP: %1$d, BP: %2$d, AP: %3$d</string>
<string name="dex_chara_stage_attribute">Stg: %1$s, Atr: %2$s</string> <string name="dex_chara_stage_attribute">Stg: %1$s, Atr: %2$s</string>
<string name="dex_chara_unknown_name">________________</string> <string name="dex_chara_unknown_name">\?\?\?\?\?\?\?\?\?\?\?\?\?\?\?\?</string>
<string name="dex_chara_stage_attribute_unknown">Stg: -, Atr: -</string> <string name="dex_chara_stage_attribute_unknown">Stg: -, Atr: -</string>
<string name="dex_chara_stats_unknown">HP: -, BP: -, AP: -</string> <string name="dex_chara_stats_unknown">HP: -, BP: -, AP: -</string>
<string name="dex_chara_requirements"> <string name="dex_chara_requirements">

View File

@ -60,25 +60,14 @@
<string name="scan_error_generic">Whoops</string> <string name="scan_error_generic">Whoops</string>
<string name="scan_sent_dim_success">Sent DIM successfully!</string> <string name="scan_sent_dim_success">Sent DIM successfully!</string>
<string name="scan_nfc_must_be_enabled">NFC must be enabled</string> <string name="scan_nfc_must_be_enabled">NFC must be enabled</string>
<string name="scan_target_device_full">Target device is full (active + backup). Remove one character before sending.</string>
<string name="scan_transfer_skipped_storage_state">Transfer skipped. Send is allowed only when the toy has an active character and an empty backup slot.</string>
<string name="scan_transfer_waiting_tap">Ready. Tap and hold phone/watch together until complete.</string>
<string name="scan_transfer_detected_keep_tap">Connection detected. Keep devices together...</string>
<string name="scan_transfer_in_progress">Transferring now. Do not remove phone yet.</string>
<string name="scan_transfer_complete_remove">Transfer complete. You can remove the phone now.</string>
<string name="scan_transfer_failed_try_again">Transfer failed. Reposition devices and try again.</string>
<string name="scan_transfer_cancelled">Transfer cancelled.</string>
<string name="scan_transport_bandai_bracelet">Detected target: Bandai Vital Bracelet (NFC-A)</string>
<string name="scan_transport_vitalwear_hce">Detected target: VitalWear (HCE)</string>
<string name="settings_title">Settings</string> <string name="settings_title">Settings</string>
<string name="settings_section_nfc">NFC Communication</string> <string name="settings_section_nfc">NFC Communication</string>
<string name="settings_section_dim_bem">VBH-VW card management</string> <string name="settings_section_dim_bem">DiM/BEm management</string>
<string name="settings_section_about">About and credits</string> <string name="settings_section_about">About and credits</string>
<string name="settings_section_data">Data management</string> <string name="settings_section_data">Data management</string>
<string name="settings_section_companion_tools">VitalWear companion tools</string>
<string name="settings_import_apk_title">Import APK</string> <string name="settings_import_apk_title">Import APK</string>
<string name="settings_import_apk_desc"> <string name="settings_import_apk_desc">
@ -87,12 +76,7 @@
<string name="settings_import_card_title">Import card</string> <string name="settings_import_card_title">Import card</string>
<string name="settings_import_card_desc"> <string name="settings_import_card_desc">
Import DIM/BEM cards into VBHelper\'s Dex. Import DiM/BEm
</string>
<string name="settings_enable_dim_to_bem_title">Scale DIM cards to BEM stats</string>
<string name="settings_enable_dim_to_bem_desc">
Automatically upscale DIM card stats to BEM level when importing
</string> </string>
<string name="settings_credits_title">Credits</string> <string name="settings_credits_title">Credits</string>
@ -111,27 +95,6 @@
Import application database Import application database
</string> </string>
<string name="settings_companion_import_card_image_title">Import card to VitalWear</string>
<string name="settings_companion_import_card_image_desc">Import Dim/Bem to Vitalwear</string>
<string name="settings_companion_import_firmware_title">Import firmware to VitalWear</string>
<string name="settings_companion_import_firmware_desc">Import firmware to Watch (10b for now)</string>
<string name="settings_companion_send_watch_logs_title">Send watch logs</string>
<string name="settings_companion_send_watch_logs_desc">Request logs from a connected VitalWear watch and share them</string>
<string name="settings_companion_send_phone_logs_title">Send phone logs</string>
<string name="settings_companion_send_phone_logs_desc">Share the latest VBHelper phone log file</string>
<string name="companion_logs_warning">WARNING: Logs have personal health information such as heart rate measurements and steps per day.</string>
<string name="companion_logs_no_files">No log files found</string>
<string name="companion_logs_failed_request">Failed to send log request to watch</string>
<string name="companion_logs_receive_failed">Error receiving log file</string>
<string name="companion_firmware_no_watch">No connected watch found</string>
<string name="companion_firmware_sent_success">Firmware sent successfully</string>
<string name="companion_card_import_success">Imported Successfully</string>
<string name="companion_validation_connect_vb">Connect to VB</string>
<string name="companion_validation_insert_card">Insert Card in VB and Reconnect</string>
<string name="companion_validation_success">Card Validated Successfully</string>
<string name="companion_loading_select_firmware">Select firmware file</string>
<string name="companion_transfer_disabled_message">Character transfer is disabled in companion. Use watch Transfer instead.</string>
<string name="credits_title">Credits</string> <string name="credits_title">Credits</string>
<string name="credits_section_reverse_engineering">Reverse engineering</string> <string name="credits_section_reverse_engineering">Reverse engineering</string>
<string name="credits_section_app_development">Application development</string> <string name="credits_section_app_development">Application development</string>
@ -216,7 +179,7 @@
<string name="dex_chara_name_icon_description">Character name</string> <string name="dex_chara_name_icon_description">Character name</string>
<string name="dex_chara_stats">HP: %1$d, BP: %2$d, AP: %3$d</string> <string name="dex_chara_stats">HP: %1$d, BP: %2$d, AP: %3$d</string>
<string name="dex_chara_stage_attribute">Stg: %1$s, Atr: %2$s</string> <string name="dex_chara_stage_attribute">Stg: %1$s, Atr: %2$s</string>
<string name="dex_chara_unknown_name">________________</string> <string name="dex_chara_unknown_name">\?\?\?\?\?\?\?\?\?\?\?\?\?\?\?\?</string>
<string name="dex_chara_stage_attribute_unknown">Stg: -, Atr: -</string> <string name="dex_chara_stage_attribute_unknown">Stg: -, Atr: -</string>
<string name="dex_chara_stats_unknown">HP: -, BP: -, AP: -</string> <string name="dex_chara_stats_unknown">HP: -, BP: -, AP: -</string>
<string name="dex_chara_requirements"> <string name="dex_chara_requirements">

View File

@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android"> <paths xmlns:android="http://schemas.android.com/apk/res/android">
<files-path
name="logs"
path="logs/" />
<cache-path <cache-path
name="exports" name="exports"
path="exports" /> path="exports" />

View File

@ -16,13 +16,7 @@ protobufGradlePlugin = "0.9.6"
protobufJavalite = "4.33.4" protobufJavalite = "4.33.4"
roomRuntime = "2.8.4" roomRuntime = "2.8.4"
vbNfcReader = "0.2.0-SNAPSHOT" vbNfcReader = "0.2.0-SNAPSHOT"
dimReader = "2.0.0" dimReader = "2.1.0"
playServicesWearable = "19.0.0"
kotlinxCoroutinesPlayServices = "1.4.3-native-mt"
timber = "5.0.1"
tinyLog = "2.4.1"
slf4jTimber = "1.0.0"
slf4jApi = "2.0.16"
[libraries] [libraries]
androidx-compose-material = { module = "androidx.compose.material:material" } androidx-compose-material = { module = "androidx.compose.material:material" }
@ -52,13 +46,6 @@ protobuf-gradle-plugin = { module = "com.google.protobuf:protobuf-gradle-plugin"
protobuf-javalite = { module = "com.google.protobuf:protobuf-javalite", version.ref = "protobufJavalite" } protobuf-javalite = { module = "com.google.protobuf:protobuf-javalite", version.ref = "protobufJavalite" }
vb-nfc-reader = { module = "com.github.cfogrady:vb-nfc-reader", version.ref = "vbNfcReader" } vb-nfc-reader = { module = "com.github.cfogrady:vb-nfc-reader", version.ref = "vbNfcReader" }
dim-reader = { module = "com.github.cfogrady:vb-dim-reader", version.ref = "dimReader" } dim-reader = { module = "com.github.cfogrady:vb-dim-reader", version.ref = "dimReader" }
play-services-wearable = { module = "com.google.android.gms:play-services-wearable", version.ref = "playServicesWearable" }
kotlinx-coroutines-play-services = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-play-services", version.ref = "kotlinxCoroutinesPlayServices" }
timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
tiny-log = { module = "org.tinylog:tinylog-api", version.ref = "tinyLog" }
tiny-log-impl = { module = "org.tinylog:tinylog-impl", version.ref = "tinyLog" }
slf4j-timber = { module = "at.favre.lib:slf4j-timber", version.ref = "slf4jTimber" }
slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4jApi" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }

View File

@ -17,16 +17,8 @@ dependencyResolutionManagement {
mavenLocal() mavenLocal()
google() google()
mavenCentral() mavenCentral()
maven { url = uri("https://jitpack.io") }
} }
} }
rootProject.name = "VBHelper" rootProject.name = "VBHelper"
include(":app") include(":app")
includeBuild("../vb-nfc-reader") {
dependencySubstitution {
substitute(module("com.github.cfogrady:vb-nfc-reader")).using(project(":"))
}
}