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 {
implementation(libs.androidx.room.runtime)
implementation(libs.vb.nfc.reader)
// Temporarily commented out due to Lombok compilation issues in VB-DIM-Reader
// implementation(libs.dim.reader)
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.dim.reader)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.room.ktx)
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.navigation.compose)
@ -94,13 +90,6 @@ dependencies {
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
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.test.manifest)
@ -129,7 +118,4 @@ dependencies {
// HTTP request logging
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"
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-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.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
@ -39,25 +31,6 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
</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
android:name=".MainActivity"
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="127.0.0.1" android:port="8080" android:pathPrefix="/authenticate" />
</intent-filter>
</activity>
<service
android:name=".companion.logs.WatchCommunicationService"
android:exported="true"
android:enabled="true">
<intent-filter>
<action android:name="com.google.android.gms.wearable.CHANNEL_EVENT" />
<action android:name="com.google.android.gms.wearable.MESSAGE_RECEIVED" />
<data android:scheme="wear" android:host="*" />
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/x-vitalwear-character" />
</intent-filter>
</service>
</activity>
</application>
</manifest>

View File

@ -1,19 +1,19 @@
package com.github.nacabaro.vbhelper
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.util.Log
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.lifecycleScope
import com.github.cfogrady.vitalwear.protos.Character
import com.github.nacabaro.vbhelper.navigation.AppNavigation
import com.github.nacabaro.vbhelper.di.VBHelper
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.itemsScreen.ItemsScreenControllerImpl
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.spriteViewer.SpriteViewerControllerImpl
import com.github.nacabaro.vbhelper.screens.storageScreen.StorageScreenControllerImpl
import com.github.nacabaro.vbhelper.source.VitalWearCharacterImporter
import com.github.nacabaro.vbhelper.ui.theme.VBHelperTheme
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
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) {
if( onActivityLifecycleListeners[key] != null) {
@ -79,6 +82,7 @@ class MainActivity : ComponentActivity() {
}
Log.i("MainActivity", "Activity onCreated")
handleImportIntent(intent)
}
override fun onPause() {
@ -101,25 +105,62 @@ class MainActivity : ComponentActivity() {
super.onNewIntent(intent)
setIntent(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? {
if (intent == null) return null
val data = intent.data
if (intent.action == Intent.ACTION_VIEW && data != null) {
val isAppAuthCallback = data.scheme == "vbhelper" && data.host == "auth"
val isLocalhostAuthCallback =
(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
if (data.scheme == "vbhelper" && data.host == "auth") {
return "Battle"
}
}
return null
@ -151,4 +192,8 @@ class MainActivity : ComponentActivity() {
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.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import kotlinx.coroutines.launch
import kotlinx.coroutines.delay
@Composable
fun AnimatedSpriteImage(
@ -13,9 +15,11 @@ fun AnimatedSpriteImage(
animationType: DigimonAnimationType = DigimonAnimationType.IDLE,
modifier: Modifier = Modifier,
contentScale: ContentScale = ContentScale.Fit,
reloadMappings: Boolean = false,
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
// 750ms is the idle animation duration, so we calculate how many frames to offset
@ -26,11 +30,18 @@ fun AnimatedSpriteImage(
0
}
val animationStateMachine = remember { DigimonAnimationStateMachine(characterId, frameOffset, animationOffset) }
val animationStateMachine = remember { DigimonAnimationStateMachine(characterId, context, frameOffset, animationOffset) }
val coroutineScope = rememberCoroutineScope()
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
LaunchedEffect(characterId) {
coroutineScope.launch {

View File

@ -1,10 +1,17 @@
package com.github.nacabaro.vbhelper.battle
import android.util.Log
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.State
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,
// 3=Opponent attack on opponent screen, 4=Opponent attack on player screen
private var _attackPhase by mutableStateOf(0)
@ -37,6 +44,19 @@ class ArenaBattleSystem {
private var _critBarProgress by mutableStateOf(0)
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)
val hitProgress: Float get() = _hitProgress
@ -160,6 +180,10 @@ class ArenaBattleSystem {
_isPlayerAttacking = false
_attackIsHit = false
_currentView = 0
_isDodging = false
_dodgeProgress = 0f
_dodgeDirection = 1f
_isHit = false
_hitProgress = 0f
_isPlayerDodging = false
_isOpponentDodging = false
@ -191,10 +215,41 @@ class ArenaBattleSystem {
//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) {
_hitProgress = progress
}
fun endHit() {
_isHit = false
_hitProgress = 0f
}
// Player-specific dodge methods
fun startPlayerDodge() {
_isPlayerDodging = true
@ -290,6 +345,17 @@ class ArenaBattleSystem {
_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
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
val authenticatedRequest = originalRequest.newBuilder()
.header("X-Session-Token", token)
.header("Authorization", "Bearer $token")
.build()
// 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
import com.google.gson.annotations.SerializedName
data class AdditionalInfo(
@SerializedName(value = "avatar", alternate = ["avatar_url"])
val avatar: String? = null,
@SerializedName(value = "id", alternate = ["user_id"])
val id: Long? = null,
@SerializedName(value = "name", alternate = ["username", "display_name"])
val name: String? = null,
val status: String? = null
)
data class UserInfo(
@SerializedName(value = "userId", alternate = ["user_id", "id"])
val userId: String? = null,
@SerializedName(value = "additionalInfo", alternate = ["additional_info"])
val additionalInfo: AdditionalInfo? = null
)
data class AuthenticateResponse(
@SerializedName(value = "success", alternate = ["ok"])
val success: Boolean,
val message: String? = null,
@SerializedName(value = "userInfo", alternate = ["user_info", "user"])
val userInfo: UserInfo? = null,
@SerializedName(value = "sessionToken", alternate = ["session_token", "token", "session"])
val sessionToken: String? = null,
@SerializedName(value = "userId", alternate = ["user_id"])
val topLevelUserId: String? = null,
@SerializedName(value = "id", alternate = ["uid"])
val topLevelId: Long? = null,
val sessionToken: String? = 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.setValue
import kotlinx.coroutines.delay
import android.content.Context
import java.io.File
enum class DigimonAnimationType {
IDLE,
IDLE2,
WALK,
WALK2,
RUN,
RUN2,
WORKOUT,
WORKOUT2,
HAPPY,
SLEEP,
ATTACK
ATTACK,
FLEE
}
data class AnimationState(
@ -22,6 +31,7 @@ data class AnimationState(
class DigimonAnimationStateMachine(
private val characterId: String,
private val context: Context,
private val initialFrameOffset: Int = 0, // New parameter for offsetting the starting frame
private val timingOffset: Long = 0L // New parameter for offsetting the timing
) {
@ -34,13 +44,21 @@ class DigimonAnimationStateMachine(
var isPlaying by mutableStateOf(false)
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(
1 to DigimonAnimationType.IDLE,
2 to DigimonAnimationType.IDLE2,
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,
11 to DigimonAnimationType.ATTACK
11 to DigimonAnimationType.ATTACK,
12 to DigimonAnimationType.FLEE
)
// Reverse mapping for getting frame numbers for each animation type
@ -51,8 +69,15 @@ class DigimonAnimationStateMachine(
DigimonAnimationType.IDLE to 750L,
DigimonAnimationType.IDLE2 to 750L,
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.ATTACK to 650L
DigimonAnimationType.ATTACK to 650L,
DigimonAnimationType.FLEE to 150L
)
/*
@ -130,4 +155,13 @@ class DigimonAnimationStateMachine(
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.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
@ -24,9 +25,10 @@ fun HitEffectOverlay(
) {
if (!isVisible) return
val context = LocalContext.current
val configuration = LocalConfiguration.current
val isLandscapeMode = configuration.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE
val hitEffectManager = remember { HitEffectSpriteManager() }
val hitEffectManager = remember { HitEffectSpriteManager(context) }
val coroutineScope = rememberCoroutineScope()
var currentFrame by remember { mutableStateOf(0) }

View File

@ -1,10 +1,13 @@
package com.github.nacabaro.vbhelper.battle
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Rect
import android.os.Environment
import java.io.File
class HitEffectSpriteManager {
class HitEffectSpriteManager(private val context: Context) {
private val spriteCache = mutableMapOf<String, Bitmap>()
// 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
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.Environment
import java.io.File
class IndividualSpriteManager {
class IndividualSpriteManager(private val context: Context) {
private val spriteCache = mutableMapOf<String, Bitmap>()
// 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.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import com.github.nacabaro.vbhelper.battle.BattleAuthContainer
import java.net.SocketTimeoutException
import java.io.InterruptedIOException
import java.util.concurrent.TimeUnit
class RetrofitHelper {
@ -32,22 +28,9 @@ class RetrofitHelper {
return OkHttpClient.Builder()
.addInterceptor(AuthInterceptor(token))
.addInterceptor(loggingInterceptor)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.callTimeout(45, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.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.
* 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? {
return try {
val authContainer = BattleAuthContainer(context)
runBlocking(Dispatchers.IO) {
withTimeoutOrNull(5000L) {
runBlocking {
// Prefer session token, fall back to nacatech token for backward compatibility
val sessionToken = authContainer.authRepository.sessionToken.first()
if (!sessionToken.isNullOrEmpty()) {
@ -70,10 +52,6 @@ class RetrofitHelper {
}
nacatechToken
}
} ?: run {
println("RetrofitHelper: Timed out while reading auth token from DataStore")
null
}
}
} catch (e: Exception) {
println("RetrofitHelper: Error getting auth token: ${e.message}")
@ -98,29 +76,6 @@ class RetrofitHelper {
.addConverterFactory(GsonConverterFactory.create())
.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).
@ -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")
try {
// Opponents endpoint requires auth on the current backend.
// Prefer authenticated Retrofit; fall back to public client only if no token is available.
val retrofit = createAuthenticatedRetrofit(context) ?: run {
println("RetrofitHelper: No auth token for opponents request, falling back to public Retrofit")
createPublicRetrofit()
// Create an authenticated Retrofit instance
val retrofit = createAuthenticatedRetrofit(context)
if (retrofit == null) {
println("RetrofitHelper: Cannot create authenticated Retrofit - no token available")
Toast.makeText(context, "Authentication required. Please log in.", Toast.LENGTH_SHORT).show()
return
}
// Create an ApiService instance from the Retrofit instance.
@ -190,11 +146,9 @@ class RetrofitHelper {
// make an asynchronous API request.
call.enqueue(object : Callback<OpponentsDataModel> {
override fun onFailure(call: Call<OpponentsDataModel>, t: Throwable) {
val classified = classifyNetworkError(t)
println("RetrofitHelper: API call failed (${t::class.java.simpleName}): $classified")
println("RetrofitHelper: API call failed: ${t.message}")
t.printStackTrace()
Toast.makeText(context, classified, Toast.LENGTH_SHORT).show()
onComplete?.invoke()
Toast.makeText(context, "Request Fail", Toast.LENGTH_SHORT).show()
}
override fun onResponse(call: Call<OpponentsDataModel>, response: Response<OpponentsDataModel>) {
@ -202,19 +156,13 @@ class RetrofitHelper {
println("RetrofitHelper: Response body: ${response.body()}")
if(response.isSuccessful){
val opponentsList = response.body()
if (opponentsList != null) {
//println("RetrofitHelper: Response successful, calling callback")
val opponentsList: OpponentsDataModel = response.body() as OpponentsDataModel
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 {
val errorBody = response.errorBody()?.string()
println("RetrofitHelper: Opponents response not successful - Code: ${response.code()}, Error: $errorBody")
handleErrorResponse(context, response, errorBody ?: "Unable to load opponents right now.")
onComplete?.invoke()
println("RetrofitHelper: Response not successful - Error: $errorBody")
handleErrorResponse(context, response, errorBody ?: "Unknown error")
}
}
})
@ -222,7 +170,6 @@ class RetrofitHelper {
println("RetrofitHelper: Exception in getOpponents: ${e.message}")
e.printStackTrace()
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) {
// This method is called when the API request fails.
val classified = classifyNetworkError(t)
println("RetrofitHelper: PVP API call failed (${t::class.java.simpleName}): $classified")
println("RetrofitHelper: PVP API call failed: ${t.message}")
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>) {
@ -348,13 +294,13 @@ class RetrofitHelper {
println("RetrofitHelper: PVP API response received - Code: ${response.code()}")
if(response.isSuccessful){
val apiResults = response.body()
if (apiResults != null) {
// If the response is successful, parse the
// 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)
} else {
println("RetrofitHelper: Successful PVP response had empty body")
Toast.makeText(context, "Empty response from server", Toast.LENGTH_SHORT).show()
}
} else {
val errorBody = response.errorBody()?.string()
println("RetrofitHelper: PVP API response not successful - Code: ${response.code()}, Error: $errorBody")
@ -369,20 +315,12 @@ class RetrofitHelper {
}
}
fun authenticate(
context: Context,
token: String,
showUserFacingErrors: Boolean = true,
callback: (AuthenticateResponse) -> Unit,
) {
fun authenticate(context: Context, token: String, callback: (AuthenticateResponse) -> Unit) {
//println("RetrofitHelper: Starting validate API call with token: $token")
if (token.isEmpty()) {
println("RetrofitHelper: ERROR - Token is empty!")
if (showUserFacingErrors) {
Toast.makeText(context, "Authentication failed: Token is empty", Toast.LENGTH_SHORT).show()
}
callback(AuthenticateResponse(success = false, message = "Token is empty"))
return
}
@ -393,10 +331,6 @@ class RetrofitHelper {
}
val okHttpClient = OkHttpClient.Builder()
.addInterceptor(loggingInterceptor)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.callTimeout(45, TimeUnit.SECONDS)
.build()
val retrofit: Retrofit = Retrofit.Builder()
@ -414,11 +348,7 @@ class RetrofitHelper {
override fun onFailure(call: Call<AuthenticateResponse>, t: Throwable) {
println("RetrofitHelper: Validate API call failed: ${t.message}")
t.printStackTrace()
val message = classifyNetworkError(t)
if (showUserFacingErrors) {
Toast.makeText(context, "Authentication failed: $message", Toast.LENGTH_SHORT).show()
}
callback(AuthenticateResponse(success = false, message = message))
Toast.makeText(context, "Authentication failed: ${t.message}", Toast.LENGTH_SHORT).show()
}
override fun onResponse(call: Call<AuthenticateResponse>, response: Response<AuthenticateResponse>) {
@ -430,25 +360,11 @@ class RetrofitHelper {
} else {
println("RetrofitHelper: Validation failed: Invalid response body")
Toast.makeText(context, "Authentication failed: Invalid response", Toast.LENGTH_SHORT).show()
callback(AuthenticateResponse(success = false, message = "Invalid response body"))
}
} else {
val errorBody = response.errorBody()?.string()
println("RetrofitHelper: Validate response not successful - Code: ${response.code()}, Error: $errorBody")
val userMessage = when {
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"}"
)
)
Toast.makeText(context, "Authentication failed: ${response.code()}", Toast.LENGTH_SHORT).show()
}
}
})
@ -456,7 +372,6 @@ class RetrofitHelper {
println("RetrofitHelper: Exception in validate: ${e.message}")
e.printStackTrace()
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
import android.content.Context
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.FileInputStream
class SpriteFileManager {
private fun getExternalSpriteBaseDir(): File {
class SpriteFileManager(private val context: Context) {
// Get the external storage directory where files are already located
fun getExternalSpriteBaseDir(): File {
val externalDir = android.os.Environment.getExternalStorageDirectory()
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 {
val battleSpritesDir = getExternalSpriteBaseDir()
val extractedAssetsDir = File(battleSpritesDir, "extracted_assets")
@ -32,4 +294,35 @@ class SpriteFileManager {
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.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
@Composable
fun SpriteImage(
@ -13,7 +14,8 @@ fun SpriteImage(
modifier: Modifier = Modifier,
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) }

View File

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

View File

@ -13,9 +13,6 @@ interface CardDao {
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertNewCard(card: Card): Long
@Query("SELECT * FROM Card")
fun getAllCards(): List<Card>
@Query("SELECT * FROM Card WHERE cardId = :id")
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.Query
import androidx.room.RewriteQueriesToDropUnusedColumns
import com.github.cfogrady.vbnfc.data.NfcCharacter
import com.github.nacabaro.vbhelper.dtos.CharacterDtos
import kotlinx.coroutines.flow.Flow
@ -28,12 +27,12 @@ interface CardFusionsDao {
toCharaId: Int
)
@RewriteQueriesToDropUnusedColumns
@Query("""
SELECT
cf.toCharaId as charaId,
cf.fromCharaId as fromCharaId,
s.spriteIdle1 as spriteIdle,
cc.attribute as attribute,
s.width as spriteWidth,
s.height as spriteHeight,
d.discoveredOn as discoveredOn,

View File

@ -13,15 +13,9 @@ interface CharacterDao {
@Insert
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")
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
suspend fun insertSprite(vararg sprite: Sprite)

View File

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

View File

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

View File

@ -2,8 +2,6 @@ package com.github.nacabaro.vbhelper.database
import androidx.room.Database
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.CardAdventureDao
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.SpriteDao
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.CardCharacter
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.VBCharacterData
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
@Database(
version = 6,
exportSchema = false,
version = 1,
entities = [
Card::class,
CardProgress::class,
@ -55,8 +50,7 @@ import com.github.nacabaro.vbhelper.domain.items.Items
Items::class,
Adventure::class,
Background::class,
PossibleTransformations::class,
VitalWearCharacterSettings::class,
PossibleTransformations::class
]
)
abstract class AppDatabase : RoomDatabase() {
@ -71,92 +65,4 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun specialMissionDao(): SpecialMissionDao
abstract fun cardAdventureDao(): CardAdventureDao
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
import com.github.cfogrady.vitalwear.common.data.SharedTransferSeenDao
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.DataStoreSecretsRepository
interface AppContainer {
val db: AppDatabase
val transferSeenDao: SharedTransferSeenDao
val dataStoreSecretsRepository: DataStoreSecretsRepository
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.preferencesDataStore
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.source.CardSettingsRepository
import com.github.nacabaro.vbhelper.di.AppContainer
import com.github.nacabaro.vbhelper.source.CurrencyRepository
import com.github.nacabaro.vbhelper.source.DataStoreSecretsRepository
import com.github.nacabaro.vbhelper.source.SecretsSerializer
@ -34,22 +31,11 @@ class DefaultAppContainer(private val context: Context) : AppContainer {
"internalDb"
)
.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()
}
override val transferSeenDao: SharedTransferSeenDao by lazy {
SharedDatabaseFactory.getDatabase(context).transferSeenDao()
}
override val dataStoreSecretsRepository = DataStoreSecretsRepository(context.secretsStore)
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 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() {
lateinit var container: DefaultAppContainer
lateinit var validatedCardDatabase: CompanionValidatedDatabase
lateinit var validatedCardManager: ValidatedCardManager
val companionLogService = CompanionLogService()
override fun onCreate() {
super.onCreate()
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.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
indices = [Index(value = ["cardId"])],
foreignKeys = [
ForeignKey(
entity = Card::class,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,7 +6,6 @@ import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -15,7 +14,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.navigation.compose.NavHost
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.github.nacabaro.vbhelper.di.VBHelper
@ -60,21 +58,6 @@ fun AppNavigation(
) {
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(
bottomBar = {
BottomNavigationBar(navController = navController)

View File

@ -7,7 +7,6 @@ import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.painterResource
import androidx.navigation.NavController
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.compose.ui.res.stringResource
@ -31,14 +30,17 @@ fun BottomNavigationBar(navController: NavController) {
label = { Text(text = stringResource(item.label)) },
selected = currentRoute == item.route,
onClick = {
navController.navigate(item.route) {
// Always route tab clicks to each tab's root screen.
popUpTo(navController.graph.findStartDestination().id) {
inclusive = false
saveState = false
}
if (item == NavigationItems.Home) {
navController.navigate(NavigationItems.Home.route) {
popUpTo(0) { inclusive = false }
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(
val route: String,
@param:DrawableRes val icon: Int,
@param:StringRes val label: Int
@DrawableRes val icon: Int,
@StringRes val label: Int
) {
object Scan : NavigationItems(
"Scan/{characterId}",

View File

@ -51,7 +51,6 @@ import android.content.Intent
import android.net.Uri
import android.media.MediaPlayer
import android.os.Environment
import android.widget.Toast
//import androidx.compose.animation.core.animateFloatAsState
//import androidx.compose.animation.core.tween
import kotlinx.coroutines.delay
@ -65,8 +64,6 @@ import com.github.nacabaro.vbhelper.battle.APIBattleCharacter
import android.util.Log
import com.github.nacabaro.vbhelper.components.TopBanner
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.SpriteFileManager
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.HitEffectOverlay
import com.github.nacabaro.vbhelper.battle.BattleAuthContainer
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.collect
import androidx.compose.foundation.lazy.LazyColumn
@ -903,6 +899,7 @@ fun MiddleBattleView(
y = enemyVerticalOffset + 40.dp
),
contentScale = ContentScale.Fit,
reloadMappings = false,
animationOffset = 375L // Offset enemy animation by half the idle duration
)
@ -989,6 +986,7 @@ fun MiddleBattleView(
y = playerVerticalOffset - 40.dp
),
contentScale = ContentScale.Fit,
reloadMappings = false,
animationOffset = 0L // Player animation starts immediately
)
@ -1074,30 +1072,19 @@ fun MiddleBattleView(
// Capture userId for use in this lambda
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)
val critBarProgressFloat = battleSystem.critBarProgress.toFloat()
// 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) {
2 -> 0 // rookie
3 -> 1 // champion
4 -> 2 // ultimate
5 -> 3 // mega
0 -> 0 // rookie
1 -> 1 // champion
2 -> 2 // ultimate
3 -> 3 // mega
else -> 0
}
// opponentCharacter.stage comes from the API and is already 0-3
val opponentStage = when (opponentCharacter?.stage) {
0 -> 0 // rookie
1 -> 1 // champion
@ -1114,11 +1101,11 @@ fun MiddleBattleView(
RetrofitHelper().getPVPWinner(
ctx,
1,
playerUserId,
activeCharacter?.charaId ?: "dim011_mon01",
playerUserId ?: 2L,
activeCharacter?.name ?: "Player",
playerStage,
battleSystem.critBarProgress,
opponentCharacter?.charaId ?: "dim011_mon01",
opponentStage,
opponentCharacter?.name ?: "Opponent",
opponentStage
) { apiResult ->
// Handle API response here
@ -1322,6 +1309,7 @@ fun PlayerBattleView(
y = verticalOffset
),
contentScale = ContentScale.Fit,
reloadMappings = false,
animationOffset = 0L // Player animation starts immediately
)
@ -1503,6 +1491,7 @@ fun EnemyBattleView(
y = verticalOffset
),
contentScale = ContentScale.Fit,
reloadMappings = false,
animationOffset = 375L // Offset enemy animation by half the idle duration
)
@ -1652,63 +1641,8 @@ fun BattlesScreen() {
// Track processed tokens to prevent duplicate API calls
// Use rememberSaveable to persist across configuration changes (screen rotation)
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 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 selectedOpponent by remember { mutableStateOf<APIBattleCharacter?>(null) }
@ -1790,85 +1724,39 @@ fun BattlesScreen() {
// Determine if player can battle based on stage (derived from activeUserCharacter)
val canBattle = activeUserCharacter?.stage?.let { it >= 2 } ?: false
val loadOpponentsIfReady: () -> Unit = loadOpponents@{
val currentCharacter = activeUserCharacter
val readyToken = authReadyToken
// Load opponents automatically based on player's stage
// Only load if authenticated and character is ready
LaunchedEffect(activeUserCharacter, isAuthenticated) {
// Wait for authentication to complete before loading opponents
if (!isAuthenticated) {
if (opponentsList.isNotEmpty()) {
opponentsList = ArrayList()
}
return@loadOpponents
return@LaunchedEffect
}
if (readyToken.isNullOrBlank()) {
println("BATTLESCREEN: Skipping opponent load - auth token not persisted yet")
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
}
val currentCharacter = activeUserCharacter
if (currentCharacter != null && canBattle && playerBattleType != null) {
try {
isLoadingOpponents = true
RetrofitHelper().getOpponents(context, battleType, onComplete = {
isLoadingOpponents = false
}) { opponents ->
RetrofitHelper().getOpponents(context, playerBattleType!!) { opponents ->
try {
// Create a new list to trigger UI recomposition
opponentsList = ArrayList(opponents.opponentsList)
println("BATTLESCREEN: Loaded ${opponents.opponentsList.size} opponents for stage $battleType")
} catch (e: Exception) {
Log.d(TAG, "Error processing opponents data: ${e.message}")
e.printStackTrace()
}
}
} catch (e: Exception) {
isLoadingOpponents = false
Log.d(TAG,"Error calling getOpponents: ${e.message}")
e.printStackTrace()
}
} else {
if (opponentsList.isNotEmpty()) {
opponentsList = ArrayList()
println("BATTLESCREEN: Cannot load opponents - activeUserCharacter: $currentCharacter")
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)) {
// Mark token as being processed IMMEDIATELY to prevent duplicate API calls
processedTokens = processedTokens + token
isProcessingAuthCallback = true
authBrowserLaunchInFlight = false
// Exchange token with battle server
RetrofitHelper().authenticate(context, token) { response ->
if (response.success) {
// Extract userId and sessionToken from response
val extractedUserId = response.extractUserId()
val sessionToken = response.extractSessionToken()
val extractedUserId = response.userInfo?.userId?.toLongOrNull()
val sessionToken = response.sessionToken
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 {
battleAuthContainer.authRepository.setAuthenticated(
isAuthenticated = true,
@ -1907,17 +1793,15 @@ fun BattlesScreen() {
sessionToken = sessionToken,
userId = extractedUserId
)
withContext(Dispatchers.Main) {
}
// Update UI state on main thread
kotlinx.coroutines.CoroutineScope(Dispatchers.Main).launch {
isAuthenticated = true
isCheckingAuth = false
userId = extractedUserId
isProcessingAuthCallback = false
authBrowserLaunchInFlight = false
println("BATTLESCREEN: Authentication successful, userId: $extractedUserId")
android.widget.Toast.makeText(context, "Authentication successful!", android.widget.Toast.LENGTH_SHORT).show()
}
}
} else {
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
@ -1931,16 +1815,23 @@ fun BattlesScreen() {
kotlinx.coroutines.CoroutineScope(Dispatchers.Main).launch {
isAuthenticated = false
isCheckingAuth = false
isProcessingAuthCallback = false
authBrowserLaunchInFlight = false
println("BATTLESCREEN: Auth callback token was already used; avoiding automatic browser relaunch loop")
// Small delay to ensure state is updated
kotlinx.coroutines.delay(100)
// 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 {
// 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")
processedTokens = processedTokens - token
isProcessingAuthCallback = false
authBrowserLaunchInFlight = false
}
// Show toast on main thread
kotlinx.coroutines.CoroutineScope(Dispatchers.Main).launch {
@ -1948,47 +1839,12 @@ fun BattlesScreen() {
}
}
}
} else {
println("BATTLESCREEN: Ignoring already processed auth token")
}
} else {
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
LaunchedEffect(Unit) {
try {
@ -1996,7 +1852,6 @@ fun BattlesScreen() {
val localAuthState = authRepository.isAuthenticated.first()
val storedToken = authRepository.authToken.first()
val storedUserId = authRepository.userId.first()
val storedSessionToken = authRepository.sessionToken.first()
// Load stored userId if available
if (storedUserId != null) {
@ -2012,35 +1867,40 @@ fun BattlesScreen() {
userId = storedUserId
}
if (consumeAuthCallbackIntent()) {
return@LaunchedEffect
// Only check for token in intent if it's a fresh deep link (ACTION_VIEW intent)
// This prevents processing stale tokens from previous sessions
val activity = context as? ComponentActivity
val intent = activity?.intent
if (intent?.action == Intent.ACTION_VIEW) {
intent.data?.let { uri ->
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
// 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
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
// 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
isCheckingAuth = false
userId = storedUserId
// 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
// This should only happen on first login or if userId was lost
println("BATTLESCREEN: Have token but no userId, validating once to get userId...")
RetrofitHelper().authenticate(
context = context,
token = storedToken,
showUserFacingErrors = false,
) { response ->
RetrofitHelper().authenticate(context, storedToken) { response ->
// Update UI on main thread
kotlinx.coroutines.CoroutineScope(Dispatchers.Main).launch {
if (response.success) {
val extractedUserId = response.extractUserId()
val sessionToken = response.extractSessionToken()
val extractedUserId = response.userInfo?.userId?.toLongOrNull()
val sessionToken = response.sessionToken
// Update stored userId and sessionToken
if (extractedUserId != null) {
@ -2064,22 +1924,8 @@ fun BattlesScreen() {
response.message?.contains("nonce") == true ||
response.message?.contains("invalid") == true ||
response.message?.contains("expired") == true
val latestSessionToken = withContext(Dispatchers.IO) {
authRepository.sessionToken.first()
}
val latestUserId = withContext(Dispatchers.IO) {
authRepository.userId.first()
}
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
println("BATTLESCREEN: Critical authentication error, clearing state and redirecting")
kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch {
@ -2087,7 +1933,11 @@ fun BattlesScreen() {
}
isAuthenticated = 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 {
// Non-critical error (e.g., network issue) - keep authenticated state
println("BATTLESCREEN: Non-critical validation error, keeping authenticated state")
@ -2102,7 +1952,10 @@ fun BattlesScreen() {
isAuthenticated = false
isCheckingAuth = false
// 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) {
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) {
// Small delay to ensure activity is fully initialized
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
@ -2125,10 +2009,15 @@ fun BattlesScreen() {
val lifecycleOwner = activity as? LifecycleOwner
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
authBrowserLaunchInFlight = false
if (!isAuthenticated) {
consumeAuthCallbackIntent()
if (event == Lifecycle.Event.ON_RESUME && !isAuthenticated) {
// Check intent data when activity resumes - only if it's a fresh ACTION_VIEW intent
val intent = activity?.intent
if (intent?.action == Intent.ACTION_VIEW) {
intent.data?.let { uri ->
if (uri.getQueryParameter("c") != null || uri.getQueryParameter("token") != null) {
handleTokenFromUri(uri)
}
}
}
}
}
@ -2149,16 +2038,45 @@ fun BattlesScreen() {
isAuthenticated = false
isCheckingAuth = false
// 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) {
if (!isAuthenticated) {
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
LaunchedEffect(hasStoragePermission) {
if (hasStoragePermission) {
val spriteFileManager = SpriteFileManager()
if (!spriteFileManager.checkSpriteFilesExist()) {
val spriteFileManager = SpriteFileManager(context)
if (spriteFileManager.checkSpriteFilesExist()) {
} else {
println("BATTLESCREEN: Sprite files not found in external storage")
}
} else {
@ -2314,10 +2233,6 @@ fun BattlesScreen() {
color = Color.Gray,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { openBattleAuthPage() }) {
Text("Open Login")
}
}
}
}
@ -2352,12 +2267,6 @@ fun BattlesScreen() {
items(opponentsList) { opponent ->
Button(
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 ->
selectedOpponent = opponent
// Randomly select background set (0, 1, or 2)
@ -2372,7 +2281,7 @@ fun BattlesScreen() {
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
when {
apiResult.status.contains("Existing match found", ignoreCase = true) -> {
@ -2473,17 +2382,14 @@ fun BattlesScreen() {
LaunchedEffect(Unit) {
// 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) {
2 -> 0 // rookie
3 -> 1 // champion
4 -> 2 // ultimate
5 -> 3 // mega
0 -> 0 // rookie
1 -> 1 // champion
2 -> 2 // ultimate
3 -> 3 // mega
else -> 0
}
// selectedOpponent.stage comes from the API and is already 0-3
val opponentStage = when (selectedOpponent?.stage) {
0 -> 0 // rookie
1 -> 1 // champion
@ -2496,16 +2402,11 @@ fun BattlesScreen() {
RetrofitHelper().getPVPWinner(
context,
1,
userId ?: run {
Toast.makeText(context, "Session missing user id. Please re-authenticate.", Toast.LENGTH_SHORT).show()
isWinnerLoaded = true
winnerName = "Unknown"
return@LaunchedEffect
},
activeCharacter?.charaId ?: "dim011_mon01",
userId ?: 2L,
activeCharacter?.name ?: "Player",
playerStage,
0,
selectedOpponent?.charaId ?: "dim011_mon01",
opponentStage,
selectedOpponent?.name ?: "Opponent",
opponentStage
) { apiResult ->
// 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
val playerWonFromWinner = activeCardId?.let { cardId ->
val winner = apiResult.winner
val winner = apiResult.winner ?: ""
if (winner.isNotEmpty()) {
if (winner.contains("|")) {
// 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")
// 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
// Then send the cleanup call - this will have the actual winner name
RetrofitHelper().getPVPWinner(
context,
2,
userId ?: run {
Toast.makeText(context, "Session missing user id. Please re-authenticate.", Toast.LENGTH_SHORT).show()
return@getPVPWinner
},
activeCharacter?.charaId ?: "dim011_mon01",
userId ?: 2L,
activeCharacter?.name ?: "Player",
playerStage,
0,
selectedOpponent?.charaId ?: "dim011_mon01",
opponentStage,
selectedOpponent?.name ?: "Opponent",
opponentStage
) { cleanupResult ->
// 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)
// Secondary method: Check winner name (if winner doesn't match opponent, player won)
val opponentName = selectedOpponent?.name ?: ""
val winner = cleanupResult.winner
val winner = cleanupResult.winner ?: ""
// Primary: HP-based determination (most reliable)
// If opponentHP <= 0, opponent is dead = player won
@ -2704,12 +2602,7 @@ fun BattlesScreen() {
RetrofitHelper().getPVPWinner(
context,
0,
userId ?: run {
Toast.makeText(context, "Session missing user id. Please re-authenticate.", Toast.LENGTH_SHORT).show()
showResumeDialog = false
existingMatchState = null
return@let
},
userId ?: 2L,
cardId,
apiStage,
0,
@ -2834,12 +2727,7 @@ fun BattlesScreen() {
RetrofitHelper().getPVPWinner(
context,
0,
userId ?: run {
Toast.makeText(context, "Session missing user id. Please re-authenticate.", Toast.LENGTH_SHORT).show()
showResumeDialog = false
existingMatchState = null
return@let
},
userId ?: 2L,
cardId,
apiStage,
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.dtos.ItemDtos
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.itemsScreen.ObtainedItemDialog
import com.github.nacabaro.vbhelper.source.StorageRepository
import com.github.nacabaro.vbhelper.R
import com.github.nacabaro.vbhelper.dtos.CardDtos
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 kotlinx.coroutines.flow.flowOf
import kotlin.collections.emptyList
@ -72,8 +76,9 @@ fun HomeScreen(
?: flowOf(emptyList())
).collectAsState(initial = emptyList())
val specialMissions by (
val vbSpecialMissions by (
activeMon
?.takeIf { it.characterType == DeviceType.VBDevice }
?.let { chara ->
storageRepository.getSpecialMissions(chara.id)
}
@ -141,20 +146,29 @@ fun HomeScreen(
height = cardIconData!!.cardIconHeight
)
val activeCharacter = activeMon!!
val displaySpecialMissions = specialMissions
if (activeCharacter.characterType == DeviceType.BEDevice || vbData != null) {
VBDiMHomeScreen(
activeMon = activeCharacter,
vbData = vbData ?: VBCharacterData(
id = activeCharacter.id,
generation = 0,
totalTrophies = activeCharacter.trophies,
),
if (activeMon!!.isBemCard && beData != null) {
BEBEmHomeScreen(
activeMon = activeMon!!,
beData = beData!!,
transformationHistory = transformationHistory,
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,
onClickCollect = { item, currency ->
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.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.dp
import com.github.nacabaro.vbhelper.dtos.ItemDtos
import com.github.nacabaro.vbhelper.domain.items.ItemType
import com.github.nacabaro.vbhelper.R
@Composable
@ -33,12 +28,6 @@ fun ItemElement(
.aspectRatio(1f)
) {
Box(modifier = Modifier.fillMaxSize()) {
ItemCompatibilityBadge(
itemType = item.itemType,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp)
)
Icon(
painter = painterResource(id = getIconResource(item.itemIcon)),
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.material3.Scaffold
import androidx.compose.material3.Tab
import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@ -34,7 +34,7 @@ fun ItemsScreen(
topBar = {
Column {
TopBanner(text = stringResource(R.string.items_title))
PrimaryTabRow(
TabRow(
selectedTabIndex = selectedTabItem,
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.di.VBHelper
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.utils.DeviceType
import kotlinx.coroutines.Dispatchers
@ -50,12 +51,19 @@ class ItemsScreenControllerImpl (
val item = getItem(itemId)
val characterData = database.userCharacterDao().getCharacter(characterId)
var beCharacterData: BECharacterData? = null
var vbCharacterData: VBCharacterData? = null
if (characterData.characterType == DeviceType.BEDevice) {
beCharacterData = database
.userCharacterDao()
.getBeData(characterId)
.firstOrNull()
} else if (characterData.characterType == DeviceType.VBDevice) {
vbCharacterData = database
.userCharacterDao()
.getVbData(characterId)
.firstOrNull()
}
if (
@ -114,7 +122,8 @@ class ItemsScreenControllerImpl (
.updateCharacter(characterData)
} 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)
}
@ -171,12 +180,12 @@ class ItemsScreenControllerImpl (
.getSpecialMissions(characterId)
.first()
val existingMission = availableSpecialMissions.firstOrNull { it.watchId == specialMissionSlot }
val newSpecialMission = SpecialMissions(
id = existingMission?.id ?: 0,
characterId = characterId,
var newSpecialMission = availableSpecialMissions[specialMissionSlot]
newSpecialMission = SpecialMissions(
id = newSpecialMission.id,
characterId = newSpecialMission.characterId,
goal = specialMissionGoal,
watchId = specialMissionSlot,
watchId = newSpecialMission.watchId,
progress = 0,
status = SpecialMission.Status.AVAILABLE,
timeElapsedInMinutes = 0,

View File

@ -43,7 +43,7 @@ fun ChooseConnectOption(
.padding(contentPadding)
) {
Text(
text = "VitalWear/VBH",
text = "Bandai Toys = Vital Bracelet, Vital Hero, BE Bracelet",
fontSize = 14.sp,
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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.withContext
import com.github.nacabaro.vbhelper.R
@ -132,8 +131,6 @@ fun ScanScreenPreview() {
navController = rememberNavController(),
scanScreenController = object: ScanScreenController {
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 registerActivityLifecycleListener(
key: String,
@ -144,12 +141,7 @@ fun ScanScreenPreview() {
override fun flushCharacter(cardId: Long) {}
override fun onClickRead(secrets: Secrets, onComplete: ()->Unit, onMultipleCards: (List<Card>) -> Unit) {}
override fun onClickCheckCard(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: () -> Unit) {}
override fun onClickWrite(
secrets: Secrets,
nfcCharacter: NfcCharacter,
characterId: Long?,
onComplete: (ScanScreenController.WriteResult) -> Unit
) {}
override fun onClickWrite(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: () -> Unit) {}
override fun cancelRead() {}
override fun characterFromNfc(nfcCharacter: NfcCharacter, onMultipleCards: (List<Card>, NfcCharacter) -> Unit): String { return "" }
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.source.proto.Secrets
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
interface ScanScreenController {
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 onClickCheckCard(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: () -> Unit)
fun onClickWrite(
secrets: Secrets,
nfcCharacter: NfcCharacter,
characterId: Long? = null,
onComplete: (WriteResult) -> Unit
)
fun onClickWrite(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: () -> Unit)
fun cancelRead()

View File

@ -3,7 +3,6 @@ package com.github.nacabaro.vbhelper.screens.scanScreen
import android.content.Intent
import android.nfc.NfcAdapter
import android.nfc.Tag
import android.nfc.tech.IsoDep
import android.nfc.tech.NfcA
import android.os.Bundle
import android.provider.Settings
@ -12,34 +11,21 @@ import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.lifecycle.lifecycleScope
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.data.NfcCharacter
import com.github.cfogrady.vbnfc.data.DeviceType as NfcDeviceTypeId
import com.github.cfogrady.vbnfc.vb.VBNfcCharacter
import com.github.nacabaro.vbhelper.ActivityLifecycleListener
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.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.isMissingSecrets
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.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import java.util.Arrays
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import com.github.nacabaro.vbhelper.R
class ScanScreenControllerImpl(
override val secretsFlow: Flow<Secrets>,
@ -47,36 +33,8 @@ class ScanScreenControllerImpl(
private val registerActivityLifecycleListener: (String, ActivityLifecycleListener)->Unit,
private val unregisterActivityLifecycleListener: (String)->Unit,
): 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 lastRequestedCharacterId: Long? = null
private var lastWriteCharacterId: Long? = null
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 {
val maybeNfcAdapter = NfcAdapter.getDefaultAdapter(componentActivity)
@ -85,66 +43,10 @@ class ScanScreenControllerImpl(
}
nfcAdapter = maybeNfcAdapter
checkSecrets()
registerNfcSuppressor()
}
/**
* Registers a lifecycle listener that keeps reader mode active (with a no-op callback)
* 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 ->
handleTag(secrets) { tagCommunicator ->
try {
val character = tagCommunicator.receiveCharacter()
val resultMessage = characterFromNfc(character) { cards, nfcCharacter ->
@ -161,202 +63,19 @@ class ScanScreenControllerImpl(
onComplete.invoke()
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() {
_detectedTransportFlow.value = DetectedTransport.UNKNOWN
setTransferStatus(R.string.scan_transfer_cancelled)
activeReaderSessionId = readerSessionCounter.incrementAndGet()
isHandlingTag.set(false)
synchronized(tagStateLock) {
lastTagId = null
lastTagHandledAtMs = 0L
if(nfcAdapter.isEnabled) {
nfcAdapter.disableReaderMode(componentActivity)
}
// 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)
}
@ -364,229 +83,40 @@ class ScanScreenControllerImpl(
unregisterActivityLifecycleListener.invoke(key)
}
// ---- NFC adapter wiring -------------------------------------------------------
/**
* 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
) {
// EXTRACTED DIRECTLY FROM EXAMPLE APP
private fun handleTag(secrets: Secrets, handlerFunc: (TagCommunicator)->String) {
if (!nfcAdapter.isEnabled) {
showWirelessSettings()
return
}
} else {
val options = Bundle()
// Work around for some broken Nfc firmware implementations that poll the card too fast
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
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,
nfcAdapter.enableReaderMode(componentActivity, buildOnReadTag(secrets, handlerFunc), NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK,
options
)
}
}
private fun buildOnReadTag(
secrets: Secrets,
nfcAHandler: (TagCommunicator) -> String,
isoDepHandler: ((IsoDep) -> String)?,
sessionId: Long,
): (Tag) -> Unit {
// EXTRACTED DIRECTLY FROM EXAMPLE APP
private fun buildOnReadTag(secrets: Secrets, handlerFunc: (TagCommunicator)->String): (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)) {
val nfcData = NfcA.get(tag)
if (nfcData == null) {
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)
}
}
} 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.use {
val tagCommunicator = TagCommunicator.getInstance(nfcData, secrets.getCryptographicTransformerMap())
setTransferStatus(R.string.scan_transfer_in_progress)
val successText = nfcAHandler(tagCommunicator)
val successText = handlerFunc(tagCommunicator)
componentActivity.runOnUiThread {
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() {
componentActivity.lifecycleScope.launch(Dispatchers.IO) {
if(secretsFlow.stateIn(componentActivity.lifecycleScope).value.isMissingSecrets()) {
@ -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() {
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_nfc_must_be_enabled), Toast.LENGTH_SHORT).show()
componentActivity.startActivity(Intent(Settings.ACTION_WIRELESS_SETTINGS))
}
/**
* Best-effort, non-destructive slot-capacity check for NFC-A devices.
*/
private fun readNfcASlotState(tagCommunicator: TagCommunicator): NfcASlotState {
val communicatorClass = tagCommunicator.javaClass
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."
override fun characterFromNfc(
nfcCharacter: NfcCharacter,
onMultipleCards: (List<Card>, NfcCharacter) -> Unit
): String {
val nfcConverter = FromNfcConverter(
componentActivity = componentActivity
)
}
// 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)
return nfcConverter.addCharacter(nfcCharacter, onMultipleCards)
}
override suspend fun characterToNfc(characterId: Long): NfcCharacter {
lastRequestedCharacterId = characterId
val character = ToNfcConverter(componentActivity = componentActivity).characterToNfc(characterId)
val nfcGenerator = ToNfcConverter(
componentActivity = componentActivity
)
val character = nfcGenerator.characterToNfc(characterId)
Log.d("CharacterType", character.toString())
return character
}
override fun flushCharacter(cardId: Long) {
val nfcConverter = FromNfcConverter(componentActivity = componentActivity)
val nfcConverter = FromNfcConverter(
componentActivity = componentActivity
)
componentActivity.lifecycleScope.launch(Dispatchers.IO) {
if (lastScannedCharacter != null) {
nfcConverter.addCharacterUsingCard(lastScannedCharacter!!, cardId)

View File

@ -3,7 +3,6 @@ package com.github.nacabaro.vbhelper.screens.scanScreen.converters
import androidx.activity.ComponentActivity
import com.github.cfogrady.vbnfc.be.BENfcCharacter
import com.github.cfogrady.vbnfc.data.NfcCharacter
import com.github.cfogrady.vbnfc.vb.SpecialMission
import com.github.cfogrady.vbnfc.vb.VBNfcCharacter
import com.github.nacabaro.vbhelper.di.VBHelper
import com.github.nacabaro.vbhelper.domain.card.Card
@ -20,7 +19,6 @@ class FromNfcConverter (
) {
private val application = componentActivity.applicationContext as VBHelper
private val database = application.container.db
private val transferSeenDao = application.container.transferSeenDao
fun addCharacterUsingCard(
@ -120,16 +118,11 @@ class FromNfcConverter (
.userCharacterDao()
.insertCharacterData(characterData)
val seenTimestamp = System.currentTimeMillis()
markSeen(cardData, nfcCharacter.charIndex.toInt(), seenTimestamp)
if (nfcCharacter is BENfcCharacter) {
addBeCharacterToDatabase(
characterId = characterId,
nfcCharacter = nfcCharacter
)
// Keep a VB profile alongside BE stats for later VB-target exports.
addVbCharacterProfileFromBe(characterId, nfcCharacter)
} else if (nfcCharacter is VBNfcCharacter) {
addVbCharacterToDatabase(
characterId = characterId,
@ -236,8 +229,8 @@ class FromNfcConverter (
itemType = nfcCharacter.itemType.toInt(),
itemMultiplier = nfcCharacter.itemMultiplier.toInt(),
itemRemainingTime = nfcCharacter.itemRemainingTime.toInt(),
otp0 = nfcCharacter.getOtp0().joinToString("") { "%02x".format(it) },
otp1 = nfcCharacter.getOtp1().joinToString("") { "%02x".format(it) },
otp0 = "", //nfcCharacter.value!!.otp0.toString(),
otp1 = "", //nfcCharacter.value!!.otp1.toString(),
minorVersion = nfcCharacter.characterCreationFirmwareVersion.minorVersion.toInt(),
majorVersion = nfcCharacter.characterCreationFirmwareVersion.majorVersion.toInt(),
)
@ -247,42 +240,6 @@ class FromNfcConverter (
.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(
@ -335,14 +292,12 @@ class FromNfcConverter (
database
.dexDao()
.insertCharacter(item.toCharIndex.toInt(), dimData.id, date)
transferSeenDao.markSeen(dimData.name, item.toCharIndex.toInt(), date)
.insertCharacter(
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.nacabaro.vbhelper.database.AppDatabase
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.VBCharacterData
import com.github.nacabaro.vbhelper.dtos.CharacterDtos
import com.github.nacabaro.vbhelper.utils.DeviceType
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import java.util.Date
class ToNfcConverter(
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 database: AppDatabase = application.container.db
suspend fun characterToNfc(
characterId: Long,
forcedProfile: DeviceType? = null,
characterId: Long
): NfcCharacter {
val app = componentActivity.applicationContext as VBHelper
val database = app.container.db
@ -59,13 +39,7 @@ class ToNfcConverter(
.characterDao()
.getCharacterInfo(userCharacter.charId)
// Forced profile overrides stored device type for transfer semantics:
// - 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)
return if (userCharacter.characterType == DeviceType.BEDevice)
nfcToBENfc(characterId, characterInfo, userCharacter)
else
nfcToVBNfc(characterId, characterInfo, userCharacter)
@ -81,12 +55,7 @@ class ToNfcConverter(
val vbData = database
.userCharacterDao()
.getVbData(characterId)
.firstOrNull()
?: VBCharacterData(
id = characterId,
generation = 0,
totalTrophies = userCharacter.trophies
)
.first()
val paddedTransformationArray = generateTransformationHistory(characterId, 9)
@ -207,47 +176,11 @@ class ToNfcConverter(
): BENfcCharacter {
val beData = database
.userCharacterDao()
.getBeDataOrNull(characterId)
?: BECharacterData(
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,
)
.getBeData(characterId)
.first()
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(
dimId = characterInfo.cardId.toUShort(),
charIndex = characterInfo.charId.toUShort(),
@ -288,8 +221,8 @@ class ToNfcConverter(
itemType = beData.itemType.toByte(),
itemMultiplier = beData.itemMultiplier.toByte(),
itemRemainingTime = beData.itemRemainingTime.toByte(),
otp0 = otp0ByteArray,
otp1 = otp1ByteArray,
otp0 = byteArrayOf(8),
otp1 = byteArrayOf(8),
characterCreationFirmwareVersion = FirmwareVersion(
minorVersion = beData.minorVersion.toByte(),
majorVersion = beData.majorVersion.toByte()
@ -321,15 +254,11 @@ class ToNfcConverter(
"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(
toCharIndex = it.monIndex.toUByte(),
year = normalizedYear.toUShort(),
year = calendar
.get(Calendar.YEAR)
.toUShort(),
month = (calendar
.get(Calendar.MONTH) + 1)
.toUByte(),

View File

@ -1,54 +1,47 @@
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.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import com.github.nacabaro.vbhelper.screens.scanScreen.TransferHaptics
import com.github.nacabaro.vbhelper.utils.ImageBitmapData
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.github.nacabaro.vbhelper.components.TopBanner
import com.github.nacabaro.vbhelper.R
@Composable
fun ActionScreen(
topBannerText: String,
detectedTransportMessage: String?,
transferStatusMessage: String?,
characterPreview: ImageBitmapData? = null,
onClickCancel: () -> Unit,
isTransferring: Boolean = true,
) {
val context = LocalContext.current
val transferHaptics = remember(context) { TransferHaptics(context) }
LaunchedEffect(transferStatusMessage) {
if (transferStatusMessage != null && transferStatusMessage.contains("progress", ignoreCase = true)) {
transferHaptics.onTransferProgress()
}
}
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
Scaffold (
topBar = {
TopBanner(
text = topBannerText,
onBackClick = onClickCancel
)
}
) { 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))
}
}
}
}

View File

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

View File

@ -7,21 +7,14 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.produceState
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import com.github.cfogrady.vbnfc.data.NfcCharacter
import com.github.nacabaro.vbhelper.ActivityLifecycleListener
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.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.utils.BitmapData
import com.github.nacabaro.vbhelper.utils.ImageBitmapData
import com.github.nacabaro.vbhelper.utils.getImageBitmap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import androidx.compose.ui.res.stringResource
@ -36,53 +29,18 @@ fun WritingScreen(
onCancel: () -> Unit,
) {
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 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 writingScreen by remember { mutableStateOf(false) }
var writingConfirmScreen by remember { mutableStateOf(false) }
var isDoneSendingCard by remember { mutableStateOf(false) }
var isDoneWritingCharacter by remember { mutableStateOf(false) }
var writeResult by remember { mutableStateOf<WriteResult?>(null) }
DisposableEffect(writing) {
if (writing) {
val transferHaptics = TransferHaptics(application)
transferHaptics.onTransferStart()
scanScreenController.registerActivityLifecycleListener(
SCAN_SCREEN_ACTIVITY_LIFECYCLE_LISTENER,
object : ActivityLifecycleListener {
@ -94,13 +52,10 @@ fun WritingScreen(
if (!isDoneSendingCard) {
scanScreenController.onClickCheckCard(secrets!!, nfcCharacter) {
isDoneSendingCard = true
transferHaptics.onTransferProgress()
}
} else if (!isDoneWritingCharacter) {
scanScreenController.onClickWrite(secrets!!, nfcCharacter, characterId) { result ->
writeResult = result
scanScreenController.onClickWrite(secrets!!, nfcCharacter) {
isDoneWritingCharacter = true
transferHaptics.onTransferProgress()
}
}
}
@ -111,13 +66,10 @@ fun WritingScreen(
if (!isDoneSendingCard) {
scanScreenController.onClickCheckCard(secrets!!, nfcCharacter) {
isDoneSendingCard = true
transferHaptics.onTransferProgress()
}
} else if (!isDoneWritingCharacter) {
scanScreenController.onClickWrite(secrets!!, nfcCharacter, characterId) { result ->
writeResult = result
scanScreenController.onClickWrite(secrets!!, nfcCharacter) {
isDoneWritingCharacter = true
transferHaptics.onTransferProgress()
}
}
}
@ -147,16 +99,10 @@ fun WritingScreen(
)
} else if (!isDoneSendingCard) {
writing = true
ActionScreen(
topBannerText = stringResource(R.string.sending_card_title),
detectedTransportMessage = transportMessage,
transferStatusMessage = transferStatus,
characterPreview = characterPreview,
onClickCancel = {
ActionScreen( topBannerText = stringResource(R.string.sending_card_title)) {
scanScreenController.cancelRead()
onCancel()
}
)
} else if (!writingConfirmScreen) {
writing = false
WriteCharacterScreen (
@ -171,17 +117,11 @@ fun WritingScreen(
)
} else if (!isDoneWritingCharacter) {
writing = true
ActionScreen(
topBannerText = stringResource(R.string.writing_character_action_title),
detectedTransportMessage = transportMessage,
transferStatusMessage = transferStatus,
characterPreview = characterPreview,
onClickCancel = {
ActionScreen(topBannerText = stringResource(R.string.writing_character_action_title)) {
isDoneSendingCard = false
scanScreenController.cancelRead()
onCancel()
}
)
}
var completedWriting by remember { mutableStateOf(false) }
@ -189,11 +129,8 @@ fun WritingScreen(
LaunchedEffect(isDoneSendingCard, isDoneWritingCharacter) {
withContext(Dispatchers.IO) {
if (isDoneSendingCard && isDoneWritingCharacter) {
// Only delete source character if transfer was confirmed as move.
// COPIED means transfer was sent but not confirmed, so keep source.
if (writeResult == WriteResult.MOVE_CONFIRMED) {
storageRepository.deleteCharacter(characterId)
}
storageRepository
.deleteCharacter(characterId)
completedWriting = true
}
}

View File

@ -5,7 +5,6 @@ import android.net.Uri
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@ -13,12 +12,8 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
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.platform.LocalContext
import androidx.compose.ui.res.stringResource
@ -72,7 +67,6 @@ fun SettingsScreen(
settingsScreenController.onClickImportCard()
}
SettingsSection(title = stringResource(R.string.settings_section_about))
SettingsEntry(
title = stringResource(R.string.settings_credits_title),
@ -104,38 +98,6 @@ fun SettingsScreen(
) {
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
fun SettingsSection(
title: String

View File

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

View File

@ -1,6 +1,5 @@
package com.github.nacabaro.vbhelper.screens.settingsScreen
import android.content.Intent
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import android.net.Uri
@ -8,13 +7,8 @@ import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.result.ActivityResultLauncher
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.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.DatabaseManagementController
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.proto.Secrets
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
class SettingsScreenControllerImpl(
@ -36,14 +29,11 @@ class SettingsScreenControllerImpl(
private val application = context.applicationContext as VBHelper
private val secretsRepository: SecretsRepository = application.container.dataStoreSecretsRepository
private val database: AppDatabase = application.container.db
private val cardSettingsRepository = application.container.cardSettingsRepository
private val databaseManagementController = DatabaseManagementController(
componentActivity = context,
application = application
)
val dimToBemConversionEnabled = cardSettingsRepository.enableDimToBemConversion
init {
filePickerLauncher = context.registerForActivityResult(
ActivityResultContracts.CreateDocument("application/octet-stream")
@ -111,41 +101,13 @@ class SettingsScreenControllerImpl(
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) {
context.lifecycleScope.launch(Dispatchers.IO) {
val contentResolver = context.contentResolver
val inputStream = contentResolver.openInputStream(uri)
inputStream.use { fileReader ->
val dimToBemEnabled = cardSettingsRepository.enableDimToBemConversion.first()
val cardImportController = CardImportController(database, dimToBemEnabled)
val cardImportController = CardImportController(database)
cardImportController.importCard(fileReader)
}

View File

@ -13,8 +13,7 @@ import com.github.nacabaro.vbhelper.domain.characters.Sprite
import java.io.InputStream
class CardImportController(
private val database: AppDatabase,
private val enableDimToBemConversion: Boolean = false
private val database: AppDatabase
) {
suspend fun importCard(
fileReader: InputStream?
@ -118,16 +117,6 @@ class CardImportController(
.spriteDao()
.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(
CardCharacter(
@ -137,9 +126,9 @@ class CardImportController(
nameSprite = card.spriteData.sprites[spriteCounter].pixelData,
stage = characters[index].stage,
attribute = NfcCharacter.Attribute.entries[characters[index].attribute],
baseHp = baseHp,
baseBp = baseBp,
baseAp = baseAp,
baseHp = characters[index].hp,
baseBp = characters[index].dp,
baseAp = characters[index].ap,
nameWidth = card.spriteData.sprites[spriteCounter].spriteDimensions.width,
nameHeight = card.spriteData.sprites[spriteCounter].spriteDimensions.height
)
@ -161,40 +150,6 @@ class CardImportController(
.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(
cardId: Long,
card: com.github.cfogrady.vb.dim.card.Card<*, *, *, *, *, *>

View File

@ -1,14 +1,11 @@
package com.github.nacabaro.vbhelper.screens.settingsScreen.controllers
import androidx.room.Room
import android.database.sqlite.SQLiteDatabase
import android.net.Uri
import android.provider.OpenableColumns
import android.util.Log
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.lifecycle.lifecycleScope
import com.github.nacabaro.vbhelper.database.AppDatabase
import com.github.nacabaro.vbhelper.di.VBHelper
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@ -21,19 +18,6 @@ class DatabaseManagementController(
val application: VBHelper
) {
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) {
componentActivity.lifecycleScope.launch(Dispatchers.IO) {
@ -75,19 +59,6 @@ class DatabaseManagementController(
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()
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) {
val buffer = ByteArray(1024)
var bytesRead: Int

View File

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

View File

@ -1,127 +1,55 @@
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.nacabaro.vbhelper.database.AppDatabase
import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData
import com.github.nacabaro.vbhelper.utils.DeviceType
import com.google.protobuf.ByteString
import kotlinx.coroutines.flow.firstOrNull
internal const val DEFAULT_VITALWEAR_TRAINING_TIME_SECONDS = 100L * 60L * 60L
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
}
}
import kotlinx.coroutines.runBlocking
import java.io.File
class VitalWearCharacterExporter(
private val context: Context,
private val database: AppDatabase
) {
/**
* Builds a Character proto from the stored character data.
* 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 {
fun buildShareIntent(characterId: Long): Intent {
return runBlocking {
val characterWithSprites = database.userCharacterDao().getCharacterWithSprites(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)
?: error("Card not found for character $characterId")
val cardProgress = database.cardProgressDao().getCardProgressSync(card.id) ?: 0
val beData = database.userCharacterDao().getBeDataOrNull(characterId)
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()
val proto = Character.newBuilder()
.setCardId(card.cardId)
.setCardName(card.name)
.setCharacterStats(
Character.CharacterStats.newBuilder()
.setSlotId(characterInfo.charId)
.setSlotId(database.userCharacterDao().getCharacterInfo(characterId).charaIndex)
.setVitals(characterWithSprites.vitalPoints)
.setTrainingTimeRemainingInSeconds(resolveTrainingSeconds(transferDeviceType, beData))
.setTimeUntilNextTransformation(normalizedTransformationCountdownMinutes.toLong() * 60L)
.setTrainedBp(resolveTrainedBp(beData))
.setTrainedHp(resolveTrainedHp(beData))
.setTrainedAp(resolveTrainedAp(beData))
.setTrainingTimeRemainingInSeconds(resolveTrainingSeconds(characterId, userCharacter.characterType))
.setTimeUntilNextTransformation(characterWithSprites.transformationCountdown.toLong() * 60L)
.setTrainedBp(resolveTrainedBp(characterId, userCharacter.characterType))
.setTrainedHp(resolveTrainedHp(characterId, userCharacter.characterType))
.setTrainedAp(resolveTrainedAp(characterId, userCharacter.characterType))
.setTrainedPp(characterWithSprites.trophies)
.setInjured(characterWithSprites.injuryStatus.name.lowercase().contains("inj"))
.setAccumulatedDailyInjuries(vwSettings?.accumulatedDailyInjuries ?: 0)
.setAccumulatedDailyInjuries(0)
.setTotalBattles(characterWithSprites.totalBattlesWon + characterWithSprites.totalBattlesLost)
.setCurrentPhaseBattles(characterWithSprites.currentPhaseBattlesWon + characterWithSprites.currentPhaseBattlesLost)
.setTotalWins(characterWithSprites.totalBattlesWon)
.setCurrentPhaseWins(characterWithSprites.currentPhaseBattlesWon)
.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()
)
.setSettings(settingsBuilder.build())
.setSettings(
Character.Settings.newBuilder()
.setTrainingInBackground(false)
.setAllowedBattles(Character.Settings.AllowedBattles.CARD_ONLY)
.build()
)
.putMaxAdventureCompletedByCard(card.name, (cardProgress - 1).coerceAtLeast(0))
.addAllTransformationHistory(
database.userCharacterDao().getTransformationHistoryForExport(characterId).map {
@ -132,68 +60,55 @@ class VitalWearCharacterExporter(
.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()
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 resolveTrainedBp(beData: com.github.nacabaro.vbhelper.domain.device_data.BECharacterData?): Int {
return beData?.trainingBp ?: 0
private fun resolveTrainingSeconds(characterId: Long, deviceType: DeviceType): Long {
if (deviceType != DeviceType.BEDevice) {
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 {
return beData?.trainingHp ?: 0
private fun resolveTrainedBp(characterId: Long, deviceType: DeviceType): Int {
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 {
return beData?.trainingAp ?: 0
private fun resolveTrainedHp(characterId: Long, deviceType: DeviceType): Int {
return if (deviceType == DeviceType.BEDevice) {
database.userCharacterDao().getBeData(characterId).valueOrNull()?.trainingHp ?: 0
} else 0
}
private suspend fun hasPossibleTransformations(cardCharacterId: Long): Boolean {
return database.characterDao().getEvolutionRequirementsForCard(cardCharacterId).firstOrNull()?.isNotEmpty() == true
private fun resolveTrainedAp(characterId: Long, deviceType: DeviceType): Int {
return if (deviceType == DeviceType.BEDevice) {
database.userCharacterDao().getBeData(characterId).valueOrNull()?.trainingAp ?: 0
} else 0
}
private fun resolveAllowedBattles(rawValue: Int?): Character.Settings.AllowedBattles {
if (rawValue == null) return Character.Settings.AllowedBattles.CARD_ONLY
return Character.Settings.AllowedBattles.forNumber(rawValue)
?: Character.Settings.AllowedBattles.CARD_ONLY
private fun <T> kotlinx.coroutines.flow.Flow<T>.valueOrNull(): T? {
return runBlocking {
firstOrNull()
}
}
companion object {
const val VITALWEAR_CHARACTER_MIME = "application/x-vitalwear-character"
}
}

View File

@ -1,25 +1,15 @@
package com.github.nacabaro.vbhelper.source
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.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.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 kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.runBlocking
import kotlin.math.max
class VitalWearCharacterImporter(
private val database: AppDatabase,
private val transferSeenDao: SharedTransferSeenDao,
private val database: AppDatabase
) {
data class ImportResult(
val success: Boolean,
@ -28,16 +18,15 @@ class VitalWearCharacterImporter(
fun importCharacter(character: Character): 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(
val cardCharacter = runCatching {
database.characterDao().getCharacterByMonIndex(slotId, importedCard.id)
}.getOrNull() ?: return ImportResult(
success = false,
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 currentPhaseBattles = character.characterStats.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(
UserCharacter(
charId = cardCharacter.id,
ageInDays = character.characterStats.ageInDays.takeIf { it > 0 }
?: max(character.transformationHistoryCount - 1, 0),
ageInDays = max(character.transformationHistoryCount - 1, 0),
mood = character.characterStats.mood,
vitalPoints = character.characterStats.vitals,
transformationCountdown = normalizedTransformationCountdown,
transformationCountdown = secondsToMinutes(character.characterStats.timeUntilNextTransformation),
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 = deviceType,
activityLevel = 0,
heartRateCurrent = 0,
characterType = DeviceType.BEDevice,
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(
BECharacterData(
id = userCharacterId,
@ -119,39 +65,32 @@ class VitalWearCharacterImporter(
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,
itemEffectMentalStateValue = 0,
itemEffectMentalStateMinutesRemaining = 0,
itemEffectActivityLevelValue = 0,
itemEffectActivityLevelMinutesRemaining = 0,
itemEffectVitalPointsChangeValue = 0,
itemEffectVitalPointsChangeMinutesRemaining = 0,
abilityRarity = resolveDefaultAbilityRarity(),
abilityType = 0,
abilityBranch = 0,
abilityReset = 0,
rank = 0,
itemType = 0,
itemMultiplier = 0,
itemRemainingTime = 0,
otp0 = "",
otp1 = "",
minorVersion = character.characterStats.firmwareMinorVersion,
majorVersion = character.characterStats.firmwareMajorVersion
minorVersion = 0,
majorVersion = 0
)
)
}
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) {
val transformationCard = resolveRelatedCard(
incomingCardName = transformation.cardName,
incomingCardId = character.cardId,
matchedRootCard = importedCard,
incomingRootCardName = character.cardName,
)
val transformationCard = resolveCard(transformation.cardName, character.cardId)
if (transformationCard != null) {
database.userCharacterDao().insertTransformation(
userCharacterId,
@ -159,28 +98,12 @@ class VitalWearCharacterImporter(
transformationCard.id,
now
)
insertedTransformationCount++
markSeen(transformationCard.name, transformation.slotId, transformationCard.id, now)
database.dexDao().insertCharacter(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) {
val adventureCard = resolveRelatedCard(
incomingCardName = cardName,
incomingCardId = null,
matchedRootCard = importedCard,
incomingRootCardName = character.cardName,
) ?: continue
val adventureCard = resolveCard(cardName, null) ?: continue
val currentStage = (maxAdventureCompleted + 1).coerceAtLeast(0)
database.cardProgressDao().updateCardProgress(
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(
success = true,
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(cardName: String?, cardId: Int?): Card? {
return selectImportedCard(
candidates = database.cardDao().getAllCards(),
incomingCardName = cardName,
incomingCardId = cardId,
)
private fun resolveCard(cardName: String?, cardId: Int?): com.github.nacabaro.vbhelper.domain.card.Card? {
if (!cardName.isNullOrBlank()) {
database.cardDao().getCardByName(cardName)?.let { return it }
}
private fun resolveRelatedCard(
incomingCardName: String?,
incomingCardId: Int?,
matchedRootCard: Card,
incomingRootCardName: String,
): Card? {
if (incomingCardName.isNullOrBlank()) {
return if (incomingCardId != null && incomingCardId > 0 && matchedRootCard.cardId == incomingCardId) {
matchedRootCard
} else {
null
if (cardId != null) {
val matches = database.cardDao().getCardByCardId(cardId)
if (matches.size == 1) {
return matches.first()
}
}
if (cardNamesMatch(incomingCardName, incomingRootCardName)) {
return matchedRootCard
}
return resolveCard(incomingCardName, incomingCardId)
}
private fun createPlaceholderCard(character: Character): Card? {
if (!character.hasTransferCard() || !character.hasTransferSpecies()) {
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 {
if (seconds <= 0L) {
return 0
}
// Preserve non-zero timers from HCE payloads instead of flooring 1..59s to 0.
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
}
return (seconds / 60L).coerceAtMost(Int.MAX_VALUE.toLong()).toInt()
}
private fun resolveInjuryStatus(injured: Boolean): NfcCharacter.InjuryStatus {
@ -566,66 +160,4 @@ class VitalWearCharacterImporter(
private fun resolveDefaultAbilityRarity(): NfcCharacter.AbilityRarity {
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 {
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 {
@ -64,7 +64,7 @@ fun BitmapData.getObscuredBitmap(): Bitmap {
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;
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 {
enum TransferDeviceType {
TRANSFER_DEVICE_TYPE_UNSPECIFIED = 0;
TRANSFER_DEVICE_TYPE_VB = 1;
TRANSFER_DEVICE_TYPE_BE = 2;
}
int32 slot_id = 1;
int32 vitals = 2;
int64 training_time_remaining_in_seconds = 3;
@ -64,29 +23,6 @@ message Character {
int32 total_wins = 13;
int32 current_phase_wins = 14;
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;
@ -112,8 +48,4 @@ message Character {
repeated TransformationEvent transformation_history = 5;
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_sent_dim_success">DIM enviado com sucesso!</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>
@ -178,7 +170,7 @@
<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_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_stats_unknown">HP: -, BP: -, AP: -</string>
<string name="dex_chara_requirements">

View File

@ -60,25 +60,14 @@
<string name="scan_error_generic">Whoops</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_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_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_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_desc">
@ -87,12 +76,7 @@
<string name="settings_import_card_title">Import card</string>
<string name="settings_import_card_desc">
Import DIM/BEM cards into VBHelper\'s Dex.
</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
Import DiM/BEm
</string>
<string name="settings_credits_title">Credits</string>
@ -111,27 +95,6 @@
Import application database
</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_section_reverse_engineering">Reverse engineering</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_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_unknown_name">________________</string>
<string name="dex_chara_unknown_name">\?\?\?\?\?\?\?\?\?\?\?\?\?\?\?\?</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_requirements">

View File

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

View File

@ -16,13 +16,7 @@ protobufGradlePlugin = "0.9.6"
protobufJavalite = "4.33.4"
roomRuntime = "2.8.4"
vbNfcReader = "0.2.0-SNAPSHOT"
dimReader = "2.0.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"
dimReader = "2.1.0"
[libraries]
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" }
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" }
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]
android-application = { id = "com.android.application", version.ref = "agp" }

View File

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