companion reup

This commit is contained in:
Taveon Nelson 2026-05-18 05:17:08 -05:00
parent 8fd4f6c414
commit 9aa62f0d81
90 changed files with 3889 additions and 1323 deletions

View File

@ -73,8 +73,12 @@ protobuf {
dependencies {
implementation(libs.androidx.room.runtime)
implementation(libs.vb.nfc.reader)
implementation(libs.dim.reader)
implementation(libs.androidx.core.ktx)
// 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.androidx.room.ktx)
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.navigation.compose)
@ -90,6 +94,13 @@ 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)
@ -118,4 +129,7 @@ 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

@ -11,6 +11,7 @@
<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" />
@ -50,11 +51,17 @@
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"
android:label="@string/app_name"
android:launchMode="singleTask"
android:theme="@style/Theme.VBHelper">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@ -75,6 +82,16 @@
<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="*" />
</intent-filter>
</service>
</application>
</manifest>

View File

@ -7,9 +7,13 @@ 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 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
@ -19,14 +23,14 @@ 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.ui.theme.VBHelperTheme
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.Dispatchers
class MainActivity : ComponentActivity() {
private val onActivityLifecycleListeners = HashMap<String, ActivityLifecycleListener>()
private var initialRoute: String? = null
private val deepLinkNavigationEvents = MutableSharedFlow<String>(extraBufferCapacity = 1)
private var initialRoute: String? by mutableStateOf(null)
private fun registerActivityLifecycleListener(key: String, activityLifecycleListener: ActivityLifecycleListener) {
if( onActivityLifecycleListeners[key] != null) {
@ -60,7 +64,6 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge()
initialRoute = getInitialRouteFromIntent(intent)
initialRoute?.let { deepLinkNavigationEvents.tryEmit(it) }
setContent {
VBHelperTheme {
@ -73,8 +76,7 @@ class MainActivity : ComponentActivity() {
storageScreenController = storageScreenController,
spriteViewerController = spriteViewerController,
cardScreenController = cardScreenController,
initialRoute = initialRoute,
deepLinkNavigationEvents = deepLinkNavigationEvents.asSharedFlow(),
initialRoute = initialRoute
)
}
}
@ -102,15 +104,35 @@ class MainActivity : ComponentActivity() {
super.onNewIntent(intent)
setIntent(intent)
initialRoute = getInitialRouteFromIntent(intent)
initialRoute?.let { deepLinkNavigationEvents.tryEmit(it) }
}
private fun getInitialRouteFromIntent(intent: Intent?): String? {
if (intent == null) return null
val data = intent.data
if (intent.action == Intent.ACTION_VIEW && data != null) {
if (data.scheme == "vbhelper" && data.host == "auth") {
return "Battle"
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) {
if (!token.isNullOrEmpty()) {
val application = applicationContext as VBHelper
val authRepository = com.github.nacabaro.vbhelper.battle.BattleAuthContainer(this).authRepository
val userId = data.getQueryParameter("userId")?.toLongOrNull()
lifecycleScope.launch(Dispatchers.IO) {
authRepository.setAuthenticated(
isAuthenticated = true,
nacatechToken = token,
userId = userId
)
}
}
return NavigationItems.Battles.route
}
}
return null
@ -126,8 +148,7 @@ class MainActivity : ComponentActivity() {
homeScreenController: HomeScreenControllerImpl,
spriteViewerController: SpriteViewerControllerImpl,
cardScreenController: CardScreenControllerImpl,
initialRoute: String? = null,
deepLinkNavigationEvents: kotlinx.coroutines.flow.Flow<String>,
initialRoute: String? = null
) {
AppNavigation(
applicationNavigationHandlers = AppNavigationHandlers(
@ -140,8 +161,7 @@ class MainActivity : ComponentActivity() {
spriteViewerController,
cardScreenController
),
initialRoute = initialRoute,
navigationEvents = deepLinkNavigationEvents,
initialRoute = initialRoute
)
}
}

View File

@ -5,9 +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
import kotlinx.coroutines.launch
import kotlinx.coroutines.delay
@Composable
fun AnimatedSpriteImage(
@ -15,12 +13,10 @@ 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 context = LocalContext.current
val spriteManager = remember { IndividualSpriteManager(context) }
val spriteManager = remember { IndividualSpriteManager() }
// Calculate frame offset based on animation offset
// 750ms is the idle animation duration, so we calculate how many frames to offset
val frameOffset = if (animationOffset > 0L) {
@ -30,18 +26,11 @@ fun AnimatedSpriteImage(
0
}
val animationStateMachine = remember { DigimonAnimationStateMachine(characterId, context, frameOffset, animationOffset) }
val animationStateMachine = remember { DigimonAnimationStateMachine(characterId, 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,18 +1,11 @@
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,
// 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)
val attackPhase: Int get() = _attackPhase
@ -44,19 +37,6 @@ 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
@ -180,10 +160,6 @@ class ArenaBattleSystem {
_isPlayerAttacking = false
_attackIsHit = false
_currentView = 0
_isDodging = false
_dodgeProgress = 0f
_dodgeDirection = 1f
_isHit = false
_hitProgress = 0f
_isPlayerDodging = false
_isOpponentDodging = false
@ -215,41 +191,10 @@ 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
@ -345,17 +290,6 @@ 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,6 +20,7 @@ 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,21 +1,46 @@
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,
val sessionToken: String? = 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,
)
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,22 +4,13 @@ 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,
FLEE
ATTACK
}
data class AnimationState(
@ -31,7 +22,6 @@ 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
) {
@ -44,21 +34,13 @@ class DigimonAnimationStateMachine(
var isPlaying by mutableStateOf(false)
private set
// Direct mapping of frame numbers (1-12) to animation types
// This is based on the standard Digimon sprite frame order
// Direct mapping of frame numbers used by current battle animations.
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,
12 to DigimonAnimationType.FLEE
11 to DigimonAnimationType.ATTACK
)
// Reverse mapping for getting frame numbers for each animation type
@ -69,15 +51,8 @@ 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.FLEE to 150L
DigimonAnimationType.ATTACK to 650L
)
/*
@ -155,13 +130,4 @@ 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,7 +10,6 @@ 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
@ -25,10 +24,9 @@ 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(context) }
val hitEffectManager = remember { HitEffectSpriteManager() }
val coroutineScope = rememberCoroutineScope()
var currentFrame by remember { mutableStateOf(0) }

View File

@ -1,13 +1,10 @@
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(private val context: Context) {
class HitEffectSpriteManager {
private val spriteCache = mutableMapOf<String, Bitmap>()
// Get the external storage directory for hit effect sprites
@ -56,117 +53,4 @@ class HitEffectSpriteManager(private val context: Context) {
}
}
/**
* 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,12 +1,10 @@
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(private val context: Context) {
class IndividualSpriteManager {
private val spriteCache = mutableMapOf<String, Bitmap>()
// Get the external storage directory for sprite files
@ -65,68 +63,4 @@ class IndividualSpriteManager(private val context: Context) {
}
}
/**
* 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,10 +12,14 @@ 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 {
/**
* Creates an OkHttpClient with authentication interceptor for game endpoints.
* Requires a non-null, non-empty token.
@ -28,9 +32,22 @@ 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).
@ -38,7 +55,8 @@ class RetrofitHelper {
private fun getAuthToken(context: Context): String? {
return try {
val authContainer = BattleAuthContainer(context)
runBlocking {
runBlocking(Dispatchers.IO) {
withTimeoutOrNull(5000L) {
// Prefer session token, fall back to nacatech token for backward compatibility
val sessionToken = authContainer.authRepository.sessionToken.first()
if (!sessionToken.isNullOrEmpty()) {
@ -52,6 +70,10 @@ 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}")
@ -76,7 +98,30 @@ 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).
* For 401/403, clears authentication state to trigger re-authentication.
@ -121,16 +166,15 @@ class RetrofitHelper {
}
}
fun getOpponents(context: Context, stage: String, callback: (OpponentsDataModel) -> Unit) {
fun getOpponents(context: Context, stage: String, onComplete: (() -> Unit)? = null, callback: (OpponentsDataModel) -> Unit) {
//println("RetrofitHelper: Starting API call for stage: $stage")
try {
// 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
// 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 ApiService instance from the Retrofit instance.
@ -146,9 +190,11 @@ class RetrofitHelper {
// make an asynchronous API request.
call.enqueue(object : Callback<OpponentsDataModel> {
override fun onFailure(call: Call<OpponentsDataModel>, t: Throwable) {
println("RetrofitHelper: API call failed: ${t.message}")
val classified = classifyNetworkError(t)
println("RetrofitHelper: API call failed (${t::class.java.simpleName}): $classified")
t.printStackTrace()
Toast.makeText(context, "Request Fail", Toast.LENGTH_SHORT).show()
Toast.makeText(context, classified, Toast.LENGTH_SHORT).show()
onComplete?.invoke()
}
override fun onResponse(call: Call<OpponentsDataModel>, response: Response<OpponentsDataModel>) {
@ -156,13 +202,19 @@ class RetrofitHelper {
println("RetrofitHelper: Response body: ${response.body()}")
if(response.isSuccessful){
//println("RetrofitHelper: Response successful, calling callback")
val opponentsList: OpponentsDataModel = response.body() as OpponentsDataModel
callback(opponentsList)
val opponentsList = response.body()
if (opponentsList != null) {
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: Response not successful - Error: $errorBody")
handleErrorResponse(context, response, errorBody ?: "Unknown error")
println("RetrofitHelper: Opponents response not successful - Code: ${response.code()}, Error: $errorBody")
handleErrorResponse(context, response, errorBody ?: "Unable to load opponents right now.")
onComplete?.invoke()
}
}
})
@ -170,6 +222,7 @@ class RetrofitHelper {
println("RetrofitHelper: Exception in getOpponents: ${e.message}")
e.printStackTrace()
Toast.makeText(context, "Request failed: ${e.message}", Toast.LENGTH_SHORT).show()
onComplete?.invoke()
}
}
@ -284,9 +337,10 @@ class RetrofitHelper {
override fun onFailure(call: Call<PVPDataModel>, t: Throwable) {
// This method is called when the API request fails.
println("RetrofitHelper: PVP API call failed: ${t.message}")
val classified = classifyNetworkError(t)
println("RetrofitHelper: PVP API call failed (${t::class.java.simpleName}): $classified")
t.printStackTrace()
Toast.makeText(context, "Request Fail", Toast.LENGTH_SHORT).show()
Toast.makeText(context, classified, Toast.LENGTH_SHORT).show()
}
override fun onResponse(call: Call<PVPDataModel>, response: Response<PVPDataModel>) {
@ -294,13 +348,13 @@ class RetrofitHelper {
println("RetrofitHelper: PVP API response received - Code: ${response.code()}")
if(response.isSuccessful){
// 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)
val apiResults = response.body()
if (apiResults != null) {
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")
@ -315,12 +369,20 @@ class RetrofitHelper {
}
}
fun authenticate(context: Context, token: String, callback: (AuthenticateResponse) -> Unit) {
fun authenticate(
context: Context,
token: String,
showUserFacingErrors: Boolean = true,
callback: (AuthenticateResponse) -> Unit,
) {
//println("RetrofitHelper: Starting validate API call with token: $token")
if (token.isEmpty()) {
println("RetrofitHelper: ERROR - Token is empty!")
Toast.makeText(context, "Authentication failed: Token is empty", Toast.LENGTH_SHORT).show()
if (showUserFacingErrors) {
Toast.makeText(context, "Authentication failed: Token is empty", Toast.LENGTH_SHORT).show()
}
callback(AuthenticateResponse(success = false, message = "Token is empty"))
return
}
@ -331,6 +393,10 @@ 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()
@ -348,7 +414,11 @@ class RetrofitHelper {
override fun onFailure(call: Call<AuthenticateResponse>, t: Throwable) {
println("RetrofitHelper: Validate API call failed: ${t.message}")
t.printStackTrace()
Toast.makeText(context, "Authentication failed: ${t.message}", Toast.LENGTH_SHORT).show()
val message = classifyNetworkError(t)
if (showUserFacingErrors) {
Toast.makeText(context, "Authentication failed: $message", Toast.LENGTH_SHORT).show()
}
callback(AuthenticateResponse(success = false, message = message))
}
override fun onResponse(call: Call<AuthenticateResponse>, response: Response<AuthenticateResponse>) {
@ -360,11 +430,25 @@ 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")
Toast.makeText(context, "Authentication failed: ${response.code()}", Toast.LENGTH_SHORT).show()
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"}"
)
)
}
}
})
@ -372,6 +456,7 @@ 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,275 +1,13 @@
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 val context: Context) {
// Get the external storage directory where files are already located
fun getExternalSpriteBaseDir(): File {
class SpriteFileManager {
private 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")
@ -294,35 +32,4 @@ class SpriteFileManager(private val context: Context) {
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,7 +5,6 @@ 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(
@ -14,9 +13,8 @@ fun SpriteImage(
modifier: Modifier = Modifier,
contentScale: ContentScale = ContentScale.Fit
) {
val context = LocalContext.current
val spriteManager = remember { IndividualSpriteManager(context) }
val spriteManager = remember { IndividualSpriteManager() }
var bitmap by remember { mutableStateOf<android.graphics.Bitmap?>(null) }
LaunchedEffect(characterId, frameNumber) {

View File

@ -0,0 +1,46 @@
package com.github.nacabaro.vbhelper.companion.card
enum class Endian {
Big,
Little,
}
fun ByteArray.getUInt32(index: Int = 0, endian: Endian = Endian.Little): UInt {
require(this.size >= index + 4) { "Must be 4 bytes from index to get a UInt" }
var result: UInt = 0u
for (i in 0 until 4) {
result = if (endian == Endian.Big) {
result or ((this[index + i].toUInt() and 0xFFu) shl (8 * (3 - i)))
} else {
result or ((this[index + i].toUInt() and 0xFFu) shl (8 * i))
}
}
return result
}
fun ByteArray.getUInt16(index: Int = 0, endian: Endian = Endian.Little): UShort {
require(this.size >= index + 2) { "Must be 2 bytes from index to get a UInt" }
var result: UInt = 0u
for (i in 0 until 2) {
result = if (endian == Endian.Big) {
result or ((this[index + i].toUInt() and 0xFFu) shl (8 * (1 - i)))
} else {
result or ((this[index + i].toUInt() and 0xFFu) shl (8 * i))
}
}
return result.toUShort()
}
fun UShort.toByteArray(endian: Endian = Endian.Little): ByteArray {
val byteArray = byteArrayOf(0, 0)
val asUInt = this.toUInt()
for (i in 0 until 2) {
if (endian == Endian.Little) {
byteArray[i] = ((asUInt shr (8 * i)) and 255u).toByte()
} else {
byteArray[1 - i] = ((asUInt shr (8 * i)) and 255u).toByte()
}
}
return byteArray
}

View File

@ -0,0 +1,400 @@
package com.github.nacabaro.vbhelper.companion.card
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.lifecycle.lifecycleScope
import androidx.compose.foundation.layout.Arrangement
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
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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.cfogrady.vb.dim.adventure.AdventureLevels
import com.github.cfogrady.vb.dim.adventure.AdventureLevels.AdventureLevel
import com.github.cfogrady.vb.dim.card.Card
import com.github.cfogrady.vb.dim.card.DimReader
import com.github.cfogrady.vb.dim.card.DimWriter
import com.github.cfogrady.vb.dim.character.CharacterStats
import com.github.cfogrady.vb.dim.character.CharacterStats.CharacterStatsEntry
import com.github.cfogrady.vb.dim.fusion.AttributeFusions
import com.github.cfogrady.vb.dim.fusion.SpecificFusions
import com.github.cfogrady.vb.dim.fusion.SpecificFusions.SpecificFusionEntry
import com.github.cfogrady.vb.dim.header.DimHeader
import com.github.cfogrady.vb.dim.transformation.TransformationRequirements
import com.github.cfogrady.vb.dim.transformation.TransformationRequirements.TransformationRequirementsEntry
import com.github.nacabaro.vbhelper.R
import com.github.nacabaro.vbhelper.companion.card.CompanionValidateCardActivity
import com.github.nacabaro.vbhelper.companion.common.ChannelTypes
import com.github.nacabaro.vbhelper.companion.ui.CompanionLoading
import com.github.nacabaro.vbhelper.companion.validation.ValidatedCardManager
import com.github.nacabaro.vbhelper.di.VBHelper
import com.google.android.gms.wearable.Wearable
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.ByteArrayOutputStream
import java.io.DataOutputStream
import java.nio.charset.Charset
class CompanionImportCardActivity : ComponentActivity() {
enum class ImportState {
PickFile,
NameOrUnique,
LoadFile,
UnlockCard,
ImportCard,
Success,
}
private lateinit var filePickLauncher: ActivityResultLauncher<Array<String>>
private lateinit var validationLauncher: ActivityResultLauncher<Intent>
private var uri: Uri? = null
private val importState = MutableStateFlow(ImportState.PickFile)
private val cardName = MutableStateFlow("")
private val uniqueSprites = MutableStateFlow(false)
private val convertToBem = MutableStateFlow(false)
private val importPercent = MutableStateFlow(0)
private val importStatus = MutableStateFlow("Preparing transfer")
private lateinit var card: Card<out DimHeader, out CharacterStats<out CharacterStatsEntry>, out TransformationRequirements<out TransformationRequirementsEntry>, out AdventureLevels<out AdventureLevel>, out AttributeFusions, out SpecificFusions<out SpecificFusionEntry>>
private val validatedCardManager by lazy {
(application as VBHelper).validatedCardManager
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
filePickLauncher = buildFilePickLauncher()
validationLauncher = buildValidationLauncher()
setContent {
val state by importState.collectAsState()
when (state) {
ImportState.PickFile -> {
LaunchedEffect(true) {
filePickLauncher.launch(arrayOf("*/*"))
}
CompanionLoading(loadingText = stringResource(R.string.settings_companion_import_card_image_title))
}
ImportState.NameOrUnique -> NameOrUnique()
ImportState.LoadFile -> {
CompanionLoading(loadingText = "Loading Card Image")
LaunchedEffect(true) {
runCatching {
loadCard()
}.onFailure { error ->
Timber.e(error, "Failed to load card image")
Toast.makeText(
this@CompanionImportCardActivity,
"Card load failed: ${error.message ?: "unknown error"}",
Toast.LENGTH_LONG,
).show()
importState.value = ImportState.NameOrUnique
}
}
}
ImportState.UnlockCard -> {
CompanionLoading(loadingText = stringResource(R.string.companion_validation_connect_vb))
LaunchedEffect(true) {
validationLauncher.launch(
Intent(applicationContext, CompanionValidateCardActivity::class.java).apply {
putExtra(CompanionValidateCardActivity.CARD_VALIDATED_KEY, card.header.dimId)
}
)
}
}
ImportState.ImportCard -> {
val percent by importPercent.collectAsState()
val status by importStatus.collectAsState()
CompanionLoading(loadingText = "$status ($percent%)")
LaunchedEffect(true) {
withContext(Dispatchers.IO) {
try {
val transferred = importCard()
importState.value = if (transferred) ImportState.Success else ImportState.NameOrUnique
} catch (error: Exception) {
Timber.e(error, "Card transfer failed")
withContext(Dispatchers.Main) {
Toast.makeText(
this@CompanionImportCardActivity,
"Card transfer failed: ${error.message ?: "unknown error"}",
Toast.LENGTH_LONG,
).show()
}
importState.value = ImportState.NameOrUnique
}
}
}
}
ImportState.Success -> {
LaunchedEffect(true) {
Handler(Looper.getMainLooper()).postDelayed({
finish()
}, 1000)
}
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(text = stringResource(R.string.companion_card_import_success))
}
}
}
}
}
@Composable
private fun NameOrUnique() {
val selectedUri = uri
val filePath = selectedUri?.path ?: "No file selected"
val name by cardName.collectAsState()
val unique by uniqueSprites.collectAsState()
val convert by convertToBem.collectAsState()
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Button(modifier = Modifier.padding(end = 10.dp), onClick = { importState.value = ImportState.PickFile }) {
Text(text = "File")
}
Text(text = filePath)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Text(text = "Name:", modifier = Modifier.padding(end = 10.dp))
TextField(value = name, onValueChange = { cardName.value = it })
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Text(text = "Unique Sprites:")
Checkbox(checked = unique, onCheckedChange = { uniqueSprites.value = it })
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Text(text = "Convert to BeM:")
Checkbox(checked = convert, onCheckedChange = { convertToBem.value = it })
}
Button(
modifier = Modifier.padding(top = 16.dp),
enabled = selectedUri != null,
onClick = { importState.value = ImportState.LoadFile },
) {
Text(text = "Import")
}
}
}
private fun buildFilePickLauncher(): ActivityResultLauncher<Array<String>> {
return registerForActivityResult(ActivityResultContracts.OpenDocument()) {
if (it == null) {
Toast.makeText(
this,
"Card file selection cancelled.",
Toast.LENGTH_SHORT,
).show()
importState.value = ImportState.NameOrUnique
} else {
uri = it
val path = it.path ?: ""
var name = path.substringAfterLast("/")
if (name.contains('.')) {
name = name.substringBeforeLast('.')
}
cardName.value = name
importState.value = ImportState.NameOrUnique
}
}
}
private fun buildValidationLauncher(): ActivityResultLauncher<Intent> {
return registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val validatedCardId = result.data?.extras?.getInt(CompanionValidateCardActivity.CARD_VALIDATED_KEY, -1)
if (result.resultCode != RESULT_OK || validatedCardId == null || validatedCardId == -1) {
Toast.makeText(
this,
"Card validation was cancelled.",
Toast.LENGTH_SHORT,
).show()
importState.value = ImportState.NameOrUnique
} else {
lifecycleScope.launch(Dispatchers.IO) {
validatedCardManager.addValidatedCard(validatedCardId)
importState.value = ImportState.ImportCard
}
}
}
}
private suspend fun loadCard() {
val selectedUri = uri
if (selectedUri == null) {
withContext(Dispatchers.Main) {
Toast.makeText(
this@CompanionImportCardActivity,
"No card file selected.",
Toast.LENGTH_SHORT,
).show()
}
importState.value = ImportState.NameOrUnique
return
}
val dimReader = DimReader()
val inputStream = contentResolver.openInputStream(selectedUri)
if (inputStream == null) {
withContext(Dispatchers.Main) {
Toast.makeText(
this@CompanionImportCardActivity,
"Unable to open selected card file.",
Toast.LENGTH_LONG,
).show()
}
importState.value = ImportState.NameOrUnique
return
}
inputStream.use {
card = dimReader.readCard(it, false)
if (validatedCardManager.isValidatedCard(card.header.dimId)) {
importState.value = ImportState.ImportCard
} else {
importState.value = ImportState.UnlockCard
}
}
}
private suspend fun importCard(): Boolean {
importPercent.value = 0
importStatus.value = "Preparing card"
val channelClient = Wearable.getChannelClient(this)
val nodes = Wearable.getNodeClient(this).connectedNodes.await()
Timber.i("Card transfer start: connectedNodes=${nodes.size}, ids=${nodes.joinToString { it.id }}")
if (nodes.isEmpty()) {
importStatus.value = getString(R.string.companion_firmware_no_watch)
withContext(Dispatchers.Main) {
Toast.makeText(
this@CompanionImportCardActivity,
getString(R.string.companion_firmware_no_watch),
Toast.LENGTH_LONG,
).show()
}
return false
}
val cardWriter = DimWriter()
val cardPayload = ByteArrayOutputStream().use { outputStream ->
cardWriter.writeCard(card, outputStream)
outputStream.toByteArray()
}
val cardNameBytes = cardName.value.toByteArray(Charset.defaultCharset())
val metadataLength = cardNameBytes.size + 1 + 1 + 1 + 4
val totalBytes = (metadataLength + cardPayload.size).toLong() * nodes.size
var transferredBytes = 0L
var successfulTransfers = 0
val failedNodes = mutableListOf<String>()
for (node in nodes) {
val nodeName = node.displayName.takeIf { it.isNotBlank() } ?: node.id
importStatus.value = "Connecting to $nodeName"
try {
Timber.i("Opening card channel for nodeId=${node.id}, displayName=${node.displayName}")
val channel = channelClient.openChannel(node.id, ChannelTypes.CARD_DATA).await()
channelClient.getOutputStream(channel).await().use { os ->
val output = DataOutputStream(os)
output.write(cardNameBytes)
output.writeByte(0)
output.writeByte(if (uniqueSprites.value) 1 else 0)
output.writeByte(if (convertToBem.value) 1 else 0)
output.writeInt(cardPayload.size)
transferredBytes += metadataLength
val metadataPercent = if (totalBytes == 0L) 0 else ((transferredBytes * 100) / totalBytes).toInt().coerceIn(0, 100)
importStatus.value = "Sending card to $nodeName"
importPercent.value = metadataPercent
val buffer = ByteArray(4096)
var bytesRead: Int
cardPayload.inputStream().use { payloadInput ->
while (payloadInput.read(buffer).also { bytesRead = it } >= 0) {
output.write(buffer, 0, bytesRead)
transferredBytes += bytesRead
val transferPercent = if (totalBytes == 0L) 0 else ((transferredBytes * 100) / totalBytes).toInt().coerceIn(0, 100)
importPercent.value = transferPercent
}
}
output.flush()
}
channelClient.close(channel).await()
successfulTransfers++
Timber.i("Card transfer complete for nodeId=${node.id}, card=${cardName.value}, payloadBytes=${cardPayload.size}")
} catch (error: Exception) {
failedNodes.add("$nodeName: ${error.message ?: "unknown error"}")
Timber.e(error, "Card transfer failed for nodeId=${node.id}, displayName=${node.displayName}")
}
}
if (successfulTransfers == 0) {
importStatus.value = "Transfer failed"
importPercent.value = 0
withContext(Dispatchers.Main) {
val reason = failedNodes.firstOrNull() ?: "No watch accepted the transfer"
Toast.makeText(this@CompanionImportCardActivity, "Card transfer failed: $reason", Toast.LENGTH_LONG).show()
}
return false
}
importStatus.value = "Transfer complete"
importPercent.value = 100
if (failedNodes.isNotEmpty()) {
withContext(Dispatchers.Main) {
Toast.makeText(
this@CompanionImportCardActivity,
"Sent to $successfulTransfers watch(es); ${failedNodes.size} failed.",
Toast.LENGTH_LONG,
).show()
}
}
Timber.i("Companion-style card import sent to watch for ${cardName.value}; success=$successfulTransfers failures=${failedNodes.size}")
return true
}
}

View File

@ -0,0 +1,162 @@
package com.github.nacabaro.vbhelper.companion.card
import android.content.Intent
import android.nfc.NfcAdapter
import android.nfc.Tag
import android.nfc.tech.MifareUltralight
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.provider.Settings
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.res.stringResource
import com.github.nacabaro.vbhelper.R
import com.github.nacabaro.vbhelper.companion.ui.CompanionLoading
import kotlinx.coroutines.flow.MutableStateFlow
import timber.log.Timber
class CompanionValidateCardActivity : ComponentActivity(), NfcAdapter.ReaderCallback {
companion object {
const val CARD_VALIDATED_KEY = "CARD_VALIDATED"
}
enum class CardValidationState {
WaitingForVBConnect,
ValidateCardOnVB,
Success,
}
private lateinit var nfcAdapter: NfcAdapter
private val validationStateFlow = MutableStateFlow(CardValidationState.WaitingForVBConnect)
private var cardIdToValidate: UShort = 0u
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val maybeNfcAdapter = NfcAdapter.getDefaultAdapter(this)
val inputCardId = intent.getIntExtra(CARD_VALIDATED_KEY, -1)
if (inputCardId == -1) {
Timber.e("CompanionValidateCardActivity called without card number!")
finish()
return
}
cardIdToValidate = inputCardId.toUShort()
if (maybeNfcAdapter == null) {
Toast.makeText(this, getString(R.string.scan_no_nfc_on_device), Toast.LENGTH_SHORT).show()
finish()
return
}
nfcAdapter = maybeNfcAdapter
setContent {
DisplayContent()
}
}
@Composable
fun DisplayContent() {
val validationState by validationStateFlow.collectAsState()
when (validationState) {
CardValidationState.WaitingForVBConnect -> {
CompanionLoading(loadingText = stringResource(R.string.companion_validation_connect_vb))
}
CardValidationState.ValidateCardOnVB -> {
CompanionLoading(loadingText = stringResource(R.string.companion_validation_insert_card))
}
CardValidationState.Success -> {
LaunchedEffect(true) {
Handler(Looper.getMainLooper()).postDelayed({
val intent = Intent().apply {
putExtra(CARD_VALIDATED_KEY, cardIdToValidate.toInt())
}
setResult(RESULT_OK, intent)
finish()
}, 1000)
}
Text(text = stringResource(R.string.companion_validation_success))
}
}
}
override fun onResume() {
super.onResume()
if (!nfcAdapter.isEnabled) {
showWirelessSettings()
} else {
nfcAdapter.enableReaderMode(this, this, NfcAdapter.FLAG_READER_NFC_A, Bundle())
}
}
override fun onPause() {
super.onPause()
if (this::nfcAdapter.isInitialized) {
nfcAdapter.disableReaderMode(this)
}
}
private fun showWirelessSettings() {
Toast.makeText(this, getString(R.string.scan_nfc_must_be_enabled), Toast.LENGTH_SHORT).show()
startActivity(Intent(Settings.ACTION_WIRELESS_SETTINGS))
}
override fun onTagDiscovered(tag: Tag?) {
if (tag == null) {
Timber.w("Tag discovery returned null tag")
return
}
when (validationStateFlow.value) {
CardValidationState.WaitingForVBConnect -> {
val nfcData = MifareUltralight.get(tag)
if (nfcData == null) {
Timber.w("Unsupported NFC tech for validation")
return
}
val writeSuccessful = runCatching {
nfcData.connect()
nfcData.use {
val vbData = VBNfcData(nfcData)
vbData.writeCardCheck(nfcData, cardIdToValidate)
}
true
}.onFailure {
Timber.e(it, "Failed during first validation NFC step")
runOnUiThread {
Toast.makeText(
this,
"Failed to validate card on bracelet. Please retry.",
Toast.LENGTH_SHORT,
).show()
}
}.getOrDefault(false)
if (writeSuccessful) {
validationStateFlow.value = CardValidationState.ValidateCardOnVB
}
}
CardValidationState.ValidateCardOnVB -> {
val nfcData = MifareUltralight.get(tag)
if (nfcData == null) {
Timber.w("Unsupported NFC tech for validation")
return
}
runCatching {
nfcData.connect()
nfcData.use {
val vbData = VBNfcData(nfcData)
if (vbData.wasCardIdValidated(cardIdToValidate)) {
validationStateFlow.value = CardValidationState.Success
}
}
}.onFailure {
Timber.e(it, "Failed during second validation NFC step")
}
}
else -> Timber.w("Incorrect state")
}
}
}

View File

@ -0,0 +1,48 @@
package com.github.nacabaro.vbhelper.companion.card
import android.nfc.tech.MifareUltralight
import kotlin.experimental.and
import kotlin.experimental.or
class VBNfcData(nfcData: MifareUltralight) {
companion object {
private const val ITEM_ID_BE: UShort = 4u
private const val STATUS_READY_FLAG: Byte = 0b00000001
private const val STATUS_DIM_READY_FLAG: Byte = 0b00000010
private val STATUS_DIM_IS_READY = STATUS_READY_FLAG or STATUS_DIM_READY_FLAG
private const val OPERATION_READY: Byte = 1
private const val OPERATION_CHECK_DIM: Byte = 3
}
val itemId: UShort
val status: Byte
val operation: Byte
val dimId: UShort
init {
val readData = nfcData.transceive(byteArrayOf(0x30, 0x04))
itemId = readData.getUInt16(4, Endian.Big)
status = readData[8]
if (itemId == ITEM_ID_BE) {
operation = readData[9]
dimId = readData.getUInt16(10, Endian.Big)
} else {
dimId = readData[9].toUShort()
operation = readData[10]
}
}
fun writeCardCheck(nfcData: MifareUltralight, dimId: UShort) {
if (itemId == ITEM_ID_BE) {
val dimData = dimId.toByteArray(endian = Endian.Big)
nfcData.writePage(6, byteArrayOf(STATUS_READY_FLAG, OPERATION_CHECK_DIM, dimData[0], dimData[1]))
} else {
nfcData.writePage(6, byteArrayOf(STATUS_READY_FLAG, dimId.toByte(), OPERATION_CHECK_DIM, 0))
}
}
fun wasCardIdValidated(dimId: UShort): Boolean {
return dimId == this.dimId && operation == OPERATION_READY && (status and STATUS_DIM_IS_READY == STATUS_DIM_IS_READY)
}
}

View File

@ -0,0 +1,10 @@
package com.github.nacabaro.vbhelper.companion.common
object ChannelTypes {
const val CARD_DATA = "com.github.cfogrady.vitalwear/CARD_DATA"
const val FIRMWARE_DATA = "com.github.cfogrady.vitalwear/FIRMWARE_DATA"
const val CHARACTER_DATA = "com.github.cfogrady.vitalwear/CHARACTER_DATA"
const val SEND_LOGS_REQUEST = "SEND_LOGS_REQUEST"
const val LOGS_DATA = "LOG_DATA"
}

View File

@ -0,0 +1,140 @@
package com.github.nacabaro.vbhelper.companion.firmware
import android.net.Uri
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.lifecycle.lifecycleScope
import com.github.nacabaro.vbhelper.R
import com.github.nacabaro.vbhelper.companion.common.ChannelTypes
import com.github.nacabaro.vbhelper.companion.ui.CompanionLoading
import com.google.android.gms.wearable.Wearable
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.DataOutputStream
class CompanionFirmwareImportActivity : ComponentActivity() {
enum class FirmwareImportState {
PickFirmware,
LoadFirmware,
}
private val importState = MutableStateFlow(FirmwareImportState.PickFirmware)
private val transferPercent = MutableStateFlow(0)
private val transferStatus = MutableStateFlow("Preparing transfer")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val firmwareImportActivity = buildFirmwareFilePickLauncher()
setContent {
BuildScreen(firmwareImportActivity)
}
}
@Composable
fun BuildScreen(firmwareImportActivity: ActivityResultLauncher<Array<String>>) {
val state by importState.collectAsState()
val percent by transferPercent.collectAsState()
val status by transferStatus.collectAsState()
if (state == FirmwareImportState.PickFirmware) {
LaunchedEffect(true) {
firmwareImportActivity.launch(arrayOf("*/*"))
}
}
val loadingText = if (state == FirmwareImportState.PickFirmware) {
getString(R.string.companion_loading_select_firmware)
} else {
"$status ($percent%)"
}
CompanionLoading(loadingText = loadingText)
}
private fun buildFirmwareFilePickLauncher(): ActivityResultLauncher<Array<String>> {
return registerForActivityResult(ActivityResultContracts.OpenDocument()) {
importState.value = FirmwareImportState.LoadFirmware
if (it == null) {
finish()
} else {
Timber.i("Path: ${it.path}")
importFirmware(it)
}
}
}
private fun importFirmware(uri: Uri) {
transferPercent.value = 0
transferStatus.value = "Reading firmware file"
val channelClient = Wearable.getChannelClient(this)
val nodeListTask = Wearable.getNodeClient(this).connectedNodes
lifecycleScope.launch(Dispatchers.IO) {
try {
val nodes = nodeListTask.await()
if (nodes.isEmpty()) {
transferStatus.value = getString(R.string.companion_firmware_no_watch)
withContext(Dispatchers.Main) {
Toast.makeText(this@CompanionFirmwareImportActivity, getString(R.string.companion_firmware_no_watch), Toast.LENGTH_LONG).show()
}
importState.value = FirmwareImportState.PickFirmware
return@launch
}
val firmware = contentResolver.openInputStream(uri)?.use { input ->
input.readBytes()
} ?: throw IllegalStateException("Unable to read selected firmware file")
transferPercent.value = 5
transferStatus.value = "Sending firmware"
val totalBytes = (firmware.size + 4).toLong() * nodes.size
var transferredBytes = 0L
for (node in nodes) {
val channel = channelClient.openChannel(node.id, ChannelTypes.FIRMWARE_DATA).await()
try {
channelClient.getOutputStream(channel).await().use {
val output = DataOutputStream(it)
output.writeInt(firmware.size)
transferredBytes += 4
transferPercent.value = if (totalBytes == 0L) 0 else ((transferredBytes * 100) / totalBytes).toInt().coerceIn(0, 100)
val buffer = ByteArray(4096)
var bytesRead: Int
firmware.inputStream().use { firmwareInput ->
while (firmwareInput.read(buffer).also { bytesRead = it } >= 0) {
output.write(buffer, 0, bytesRead)
transferredBytes += bytesRead
transferPercent.value = if (totalBytes == 0L) 0 else ((transferredBytes * 100) / totalBytes).toInt().coerceIn(0, 100)
}
}
output.flush()
}
} finally {
runCatching { channelClient.close(channel).await() }
}
}
transferStatus.value = "Transfer complete"
transferPercent.value = 100
withContext(Dispatchers.Main) {
Toast.makeText(this@CompanionFirmwareImportActivity, getString(R.string.companion_firmware_sent_success), Toast.LENGTH_SHORT).show()
finish()
}
} catch (e: Exception) {
Timber.e(e, "Failed to send firmware to watch")
transferStatus.value = "Transfer failed: ${e.message ?: "unknown"}"
withContext(Dispatchers.Main) {
Toast.makeText(this@CompanionFirmwareImportActivity, "Firmware transfer failed: ${e.message ?: "unknown error"}", Toast.LENGTH_LONG).show()
}
importState.value = FirmwareImportState.PickFirmware
}
}
}
}

View File

@ -0,0 +1,77 @@
package com.github.nacabaro.vbhelper.companion.logging
import android.annotation.SuppressLint
import android.content.Context
import android.util.Log
import org.tinylog.Logger
import org.tinylog.configuration.Configuration
import org.tinylog.provider.ProviderRegistry
import timber.log.Timber
import java.io.File
@SuppressLint("LogNotTimber")
class TinyLogTree(context: Context) : Timber.DebugTree() {
companion object {
private val logFileRegexp = Regex("""log_(\d{4}-\d{2}-\d{2})_(\d+)\.txt""")
private const val START_DATE = "0000-00-00"
fun shutdown() {
ProviderRegistry.getLoggingProvider().shutdown()
}
private fun setupRollingLogs(logsDir: String) {
Configuration.set("writer", "rolling file")
Configuration.set("writer.level", "info")
Configuration.set("writer.backups", "3")
Configuration.set("writer.format", "{date: yyyy-MM-dd HH:mm:ss.SSS}{pipe}{tag}{pipe}{level}{pipe}{message}")
Configuration.set("writer.file", "$logsDir/log_{date:yyyy-MM-dd}_{count}.txt")
Configuration.set("writer.policies", "daily, size: 10mb")
Configuration.set("writer.buffered", "true")
}
private fun getLogsDir(context: Context): File = File(context.filesDir, "logs")
fun getMostRecentLogFile(context: Context): File? {
val logsDir = getLogsDir(context)
val files = logsDir.listFiles() ?: return null
return getMostRecentLogFileFromFiles(files)
}
internal fun getMostRecentLogFileFromFiles(files: Array<out File>): File? {
var date = START_DATE
var count = 0
var mostRecentFile: File? = null
for (child in files) {
val matchResult = logFileRegexp.find(child.name) ?: continue
val childDate = matchResult.groups[1]?.value ?: START_DATE
val childCount = matchResult.groups[2]?.value?.toInt() ?: 0
if (childDate > date || (childDate == date && childCount > count)) {
mostRecentFile = child
date = childDate
count = childCount
}
}
Log.i("TinyLogTree", "Most Recent Log: $mostRecentFile")
return mostRecentFile
}
}
init {
val logsDir = getLogsDir(context).absolutePath
setupRollingLogs(logsDir)
Configuration.set("writingthread", "true")
Log.i("TinyLogTree", "Log Dir: $logsDir")
}
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
val taggedLogger = Logger.tag(tag)
when (priority) {
Log.VERBOSE, Log.DEBUG -> taggedLogger.debug { message }
Log.INFO -> taggedLogger.info { message }
Log.WARN -> taggedLogger.warn { message }
Log.ERROR, Log.ASSERT -> taggedLogger.error { message }
else -> taggedLogger.error { "Invalid priority $priority For Message! $message" }
}
}
}

View File

@ -0,0 +1,122 @@
package com.github.nacabaro.vbhelper.companion.logs
import android.annotation.SuppressLint
import android.app.Activity
import android.content.ClipData
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.util.Log
import android.widget.Toast
import androidx.core.content.FileProvider
import com.github.nacabaro.vbhelper.R
import com.github.nacabaro.vbhelper.companion.common.ChannelTypes
import com.google.android.gms.wearable.CapabilityClient
import com.google.android.gms.wearable.ChannelClient
import com.google.android.gms.wearable.ChannelClient.ChannelCallback
import com.google.android.gms.wearable.Wearable
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await
import timber.log.Timber
import java.io.File
class CompanionLogService {
companion object {
private const val TAG = "CompanionLogService"
}
var initializedActivity: Activity? = null
fun sendLogFile(context: Context, file: File) {
initializedActivity?.let {
sendLogFile(context, file, it)
initializedActivity?.finish()
}
initializedActivity = null
}
@SuppressLint("LogNotTimber")
fun sendLogFile(context: Context, file: File, activity: Activity) {
val emailIntent = Intent(Intent.ACTION_SENDTO).apply {
data = Uri.parse("mailto:")
putExtra(Intent.EXTRA_EMAIL, arrayOf("vitalwear-developers@googlegroups.com", "taveonnelsonn@gmail.com"))
putExtra(Intent.EXTRA_SUBJECT, "VBHelper Log")
putExtra(Intent.EXTRA_TEXT, "Please take a look at these logs to help diagnose the below issue:\n")
}
val fileUri = FileProvider.getUriForFile(context, "${context.packageName}.provider", file)
emailIntent.putExtra(Intent.EXTRA_STREAM, fileUri)
emailIntent.clipData = ClipData.newRawUri("", fileUri)
emailIntent.flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
Log.i(TAG, "Starting activity to send email")
activity.startActivity(Intent.createChooser(emailIntent, "Select Email Client"))
}
fun receiveFile(context: Context, channel: ChannelClient.Channel) {
val channelClient = Wearable.getChannelClient(context)
val logs = File(context.filesDir, "logs")
if (!logs.exists()) {
logs.mkdirs()
}
val file = File(logs, "watch_log.txt")
channelClient.registerChannelCallback(channel, object : ChannelCallback() {
override fun onInputClosed(channel: ChannelClient.Channel, closeReason: Int, appErrorCode: Int) {
super.onInputClosed(channel, closeReason, appErrorCode)
if (closeReason != ChannelCallback.CLOSE_REASON_NORMAL) {
Timber.w("Failed to receive file. Close Reason: ${closeReasonString(closeReason)}")
} else {
Timber.i("Successfully downloaded log file. Input closed.")
sendLogFile(context, file)
}
channelClient.close(channel)
}
})
channelClient.receiveFile(channel, Uri.fromFile(file), false).apply {
addOnFailureListener {
Toast.makeText(context, context.getString(R.string.companion_logs_receive_failed), Toast.LENGTH_SHORT).show()
initializedActivity?.finish()
initializedActivity = null
}
}
}
fun fetchWatchLogs(callingActivity: Activity) {
val messageClient = Wearable.getMessageClient(callingActivity)
val capabilityInfoTask = Wearable.getCapabilityClient(callingActivity)
.getCapability(ChannelTypes.SEND_LOGS_REQUEST, CapabilityClient.FILTER_REACHABLE)
CoroutineScope(Dispatchers.IO).launch {
val capabilityInfo = capabilityInfoTask.await()
if (capabilityInfo.nodes.isEmpty()) {
CoroutineScope(Dispatchers.Main).launch {
Toast.makeText(callingActivity, callingActivity.getString(R.string.companion_firmware_no_watch), Toast.LENGTH_SHORT).show()
callingActivity.finish()
}
return@launch
}
for (node in capabilityInfo.nodes) {
messageClient.sendMessage(node.id, ChannelTypes.SEND_LOGS_REQUEST, null).apply {
addOnFailureListener {
Toast.makeText(callingActivity, callingActivity.getString(R.string.companion_logs_failed_request), Toast.LENGTH_SHORT).show()
callingActivity.finish()
}
addOnSuccessListener {
initializedActivity = callingActivity
}
}
}
}
}
private fun closeReasonString(closeReason: Int): String {
return when (closeReason) {
ChannelCallback.CLOSE_REASON_NORMAL -> "Normal"
ChannelCallback.CLOSE_REASON_LOCAL_CLOSE -> "Closed Locally"
ChannelCallback.CLOSE_REASON_REMOTE_CLOSE -> "Closed Remotely"
ChannelCallback.CLOSE_REASON_DISCONNECTED -> "Disconnected"
ChannelCallback.CLOSE_REASON_CONNECTION_TIMEOUT -> "Timeout"
else -> "Unknown"
}
}
}

View File

@ -0,0 +1,41 @@
package com.github.nacabaro.vbhelper.companion.logs
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.res.stringResource
import com.github.nacabaro.vbhelper.R
import com.github.nacabaro.vbhelper.companion.ui.CompanionConfirmation
import com.github.nacabaro.vbhelper.companion.ui.CompanionLoading
import com.github.nacabaro.vbhelper.di.VBHelper
class CompanionWatchLogsActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val activity = this
setContent {
var accepted by remember { mutableStateOf(false) }
if (!accepted) {
CompanionConfirmation(prompt = stringResource(R.string.companion_logs_warning)) {
if (it) {
accepted = true
} else {
finish()
}
}
} else {
CompanionLoading(loadingText = stringResource(R.string.settings_companion_send_watch_logs_title))
LaunchedEffect(true) {
(application as VBHelper).companionLogService.fetchWatchLogs(activity)
}
}
}
}
}

View File

@ -0,0 +1,21 @@
package com.github.nacabaro.vbhelper.companion.logs
import com.github.nacabaro.vbhelper.companion.common.ChannelTypes
import com.github.nacabaro.vbhelper.di.VBHelper
import com.google.android.gms.wearable.ChannelClient
import com.google.android.gms.wearable.WearableListenerService
import timber.log.Timber
class WatchCommunicationService : WearableListenerService() {
override fun onChannelOpened(channel: ChannelClient.Channel) {
super.onChannelOpened(channel)
when (channel.path) {
ChannelTypes.LOGS_DATA -> {
val logService = (application as VBHelper).companionLogService
logService.receiveFile(this, channel)
}
else -> Timber.i("Unknown channel: ${channel.path}")
}
}
}

View File

@ -0,0 +1,23 @@
package com.github.nacabaro.vbhelper.companion.transfer
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import com.github.nacabaro.vbhelper.R
/**
* Compatibility endpoint kept for legacy links/intents from older companion flows.
* Character transfer remains watch-driven and is not supported from phone companion UI.
*/
class CompanionCharacterImportActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Toast.makeText(
this,
getString(R.string.companion_transfer_disabled_message),
Toast.LENGTH_LONG,
).show()
finish()
}
}

View File

@ -0,0 +1,42 @@
package com.github.nacabaro.vbhelper.companion.ui
import androidx.compose.foundation.layout.Arrangement
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
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun CompanionConfirmation(
prompt: String,
onResult: (Boolean) -> Unit,
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = prompt, modifier = Modifier.padding(bottom = 24.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
Button(onClick = { onResult(true) }) {
Text(text = "Ok")
}
Button(onClick = { onResult(false) }) {
Text(text = "Cancel")
}
}
}
}

View File

@ -0,0 +1,32 @@
package com.github.nacabaro.vbhelper.companion.ui
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.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun CompanionLoading(
loadingText: String,
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
CircularProgressIndicator()
Text(
text = loadingText,
modifier = Modifier.padding(top = 16.dp),
)
}
}

View File

@ -0,0 +1,14 @@
package com.github.nacabaro.vbhelper.companion.validation
import androidx.room.Database
import androidx.room.RoomDatabase
@Database(
entities = [ValidatedCardEntity::class],
version = 1,
exportSchema = false,
)
abstract class CompanionValidatedDatabase : RoomDatabase() {
abstract fun validatedCardDao(): ValidatedCardDao
}

View File

@ -0,0 +1,16 @@
package com.github.nacabaro.vbhelper.companion.validation
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
@Dao
interface ValidatedCardDao {
@Query("select cardId from ${ValidatedCardEntity.TABLE}")
suspend fun getIds(): List<Int>
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insert(validatedCardEntity: ValidatedCardEntity)
}

View File

@ -0,0 +1,13 @@
package com.github.nacabaro.vbhelper.companion.validation
import androidx.room.Entity
@Entity(tableName = ValidatedCardEntity.TABLE, primaryKeys = ["cardId"])
data class ValidatedCardEntity(
val cardId: Int,
) {
companion object {
const val TABLE = "validated"
}
}

View File

@ -0,0 +1,35 @@
package com.github.nacabaro.vbhelper.companion.validation
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
class ValidatedCardManager(
private val validatedCardDao: ValidatedCardDao,
) {
private val mutex = Mutex()
private var validatedIds: Set<Int>? = null
private suspend fun ensureLoaded(): Set<Int> {
validatedIds?.let { return it }
return mutex.withLock {
validatedIds ?: validatedCardDao.getIds().toSet().also { validatedIds = it }
}
}
suspend fun addValidatedCard(cardId: Int) {
mutex.withLock {
val current = validatedIds ?: validatedCardDao.getIds().toSet()
if (!current.contains(cardId)) {
validatedCardDao.insert(ValidatedCardEntity(cardId))
validatedIds = current + cardId
} else {
validatedIds = current
}
}
}
suspend fun isValidatedCard(cardId: Int): Boolean {
return ensureLoaded().contains(cardId)
}
}

View File

@ -2,6 +2,7 @@ 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
@ -19,6 +20,7 @@ interface AdventureDao {
""")
fun getAdventureCount(): Int
@RewriteQueriesToDropUnusedColumns
@Query(
"""
SELECT

View File

@ -2,6 +2,7 @@ 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
@ -27,12 +28,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,9 +13,15 @@ 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,6 +11,9 @@ 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,6 +4,7 @@ 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
@ -38,6 +39,7 @@ interface UserCharacterDao {
@Upsert
fun insertSpecialMissions(vararg specialMissions: SpecialMissions)
@RewriteQueriesToDropUnusedColumns
@Query(
"""
SELECT
@ -55,6 +57,7 @@ interface UserCharacterDao {
)
fun getTransformationHistory(monId: Long): Flow<List<CharacterDtos.TransformationHistory>>
@RewriteQueriesToDropUnusedColumns
@Query(
"""
SELECT
@ -71,6 +74,7 @@ interface UserCharacterDao {
)
suspend fun getTransformationHistoryForExport(monId: Long): List<CharacterDtos.TransformationHistoryExport>
@RewriteQueriesToDropUnusedColumns
@Query(
"""
SELECT
@ -96,6 +100,7 @@ interface UserCharacterDao {
)
fun getAllCharacters(): Flow<List<CharacterDtos.CharacterWithSprites>>
@RewriteQueriesToDropUnusedColumns
@Query(
"""
SELECT
@ -137,9 +142,13 @@ interface UserCharacterDao {
@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
@ -205,6 +214,7 @@ interface UserCharacterDao {
@Query("""SELECT * FROM VitalsHistory WHERE charId = :charId ORDER BY id ASC""")
suspend fun getVitalsHistory(charId: Long): List<VitalsHistory>
@RewriteQueriesToDropUnusedColumns
@Query(
"""
SELECT
@ -231,6 +241,7 @@ interface UserCharacterDao {
)
suspend fun getBECharacters(): List<CharacterDtos.CharacterWithSprites>
@RewriteQueriesToDropUnusedColumns
@Query(
"""
SELECT

View File

@ -7,7 +7,6 @@ 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
import com.github.nacabaro.vbhelper.daos.CharacterTransferPolicyDao
import com.github.nacabaro.vbhelper.daos.DexDao
import com.github.nacabaro.vbhelper.daos.CardDao
import com.github.nacabaro.vbhelper.daos.CardFusionsDao
@ -28,7 +27,6 @@ import com.github.nacabaro.vbhelper.domain.characters.Sprite
import com.github.nacabaro.vbhelper.domain.characters.Adventure
import com.github.nacabaro.vbhelper.domain.characters.Dex
import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData
import com.github.nacabaro.vbhelper.domain.device_data.CharacterTransferPolicy
import com.github.nacabaro.vbhelper.domain.device_data.SpecialMissions
import com.github.nacabaro.vbhelper.domain.device_data.TransformationHistory
import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter
@ -39,6 +37,7 @@ import com.github.nacabaro.vbhelper.domain.items.Items
@Database(
version = 6,
exportSchema = false,
entities = [
Card::class,
CardProgress::class,
@ -52,7 +51,6 @@ import com.github.nacabaro.vbhelper.domain.items.Items
SpecialMissions::class,
TransformationHistory::class,
VitalsHistory::class,
CharacterTransferPolicy::class,
Dex::class,
Items::class,
Adventure::class,
@ -74,7 +72,6 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun cardAdventureDao(): CardAdventureDao
abstract fun cardFusionsDao(): CardFusionsDao
abstract fun vitalWearSettingsDao(): VitalWearSettingsDao
abstract fun characterTransferPolicyDao(): CharacterTransferPolicyDao
companion object {
val MIGRATION_1_2 = object : Migration(1, 2) {
@ -145,23 +142,19 @@ abstract class AppDatabase : RoomDatabase() {
val MIGRATION_5_6 = object : Migration(5, 6) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS `CharacterTransferPolicy` (
`characterId` INTEGER NOT NULL,
`nativeDeviceType` TEXT NOT NULL,
`preferredHceExportFormat` TEXT NOT NULL,
`preferredNfcaExportFormat` TEXT NOT NULL,
`lastObservedImportFormat` TEXT,
`lastTransferTransport` TEXT,
`lastTransferTarget` TEXT,
`preserveVbRoundTrip` INTEGER NOT NULL,
`preserveBeRoundTrip` INTEGER NOT NULL,
PRIMARY KEY(`characterId`),
FOREIGN KEY(`characterId`) REFERENCES `UserCharacter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE
)
""".trimIndent()
)
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

@ -2,6 +2,7 @@ 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
@ -10,4 +11,5 @@ interface AppContainer {
val transferSeenDao: SharedTransferSeenDao
val dataStoreSecretsRepository: DataStoreSecretsRepository
val currencyRepository: CurrencyRepository
val cardSettingsRepository: CardSettingsRepository
}

View File

@ -8,6 +8,7 @@ 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.source.CurrencyRepository
import com.github.nacabaro.vbhelper.source.DataStoreSecretsRepository
import com.github.nacabaro.vbhelper.source.SecretsSerializer
@ -37,6 +38,7 @@ class DefaultAppContainer(private val context: Context) : AppContainer {
.addMigrations(AppDatabase.MIGRATION_2_3)
.addMigrations(AppDatabase.MIGRATION_3_5)
.addMigrations(AppDatabase.MIGRATION_4_5)
.addMigrations(AppDatabase.MIGRATION_5_6)
.build()
}
@ -47,5 +49,7 @@ class DefaultAppContainer(private val context: Context) : AppContainer {
override val dataStoreSecretsRepository = DataStoreSecretsRepository(context.secretsStore)
override val currencyRepository = CurrencyRepository(context.currencyStore)
override val cardSettingsRepository = CardSettingsRepository(context.currencyStore)
}

View File

@ -2,12 +2,37 @@ 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,9 +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
@Entity(
indices = [Index(value = ["cardId"])],
foreignKeys = [
ForeignKey(
entity = Card::class,

View File

@ -2,9 +2,14 @@ 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

@ -9,7 +9,8 @@ import com.github.nacabaro.vbhelper.domain.characters.Sprite
@Entity(
indices = [
Index(value = ["cardId", "charaIndex"], unique = true)
Index(value = ["cardId", "charaIndex"], unique = true),
Index(value = ["spriteId"])
],
foreignKeys = [
ForeignKey(

View File

@ -2,10 +2,15 @@ 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,9 +2,14 @@ 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,10 +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.vb.SpecialMission
@Entity(
indices = [Index(value = ["characterId"])],
foreignKeys = [
ForeignKey(
entity = UserCharacter::class,

View File

@ -2,10 +2,15 @@ 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

@ -0,0 +1,31 @@
package com.github.nacabaro.vbhelper.domain.device_data
import com.github.nacabaro.vbhelper.utils.DeviceType
/**
* UnifiedCharacter provides a single access point for common character data
* and bridges the specific data for VB and BE characters.
*/
data class UnifiedCharacter(
val userCharacter: UserCharacter,
val vbData: VBCharacterData? = null,
val beData: BECharacterData? = null
) {
val characterType: DeviceType get() = userCharacter.characterType
fun isVB() = characterType == DeviceType.VBDevice
fun isBE() = characterType == DeviceType.BEDevice
// Unified accessors for core stats
val trophies: Int get() = userCharacter.trophies
val ageInDays: Int get() = userCharacter.ageInDays
val mood: Int get() = userCharacter.mood
val vitalPoints: Int get() = userCharacter.vitalPoints
val injuryStatus = userCharacter.injuryStatus
// BE specific accessors with defaults
val trainingHp: Int get() = beData?.trainingHp ?: 0
val trainingAp: Int get() = beData?.trainingAp ?: 0
val trainingBp: Int get() = beData?.trainingBp ?: 0
val abilityRarity = beData?.abilityRarity
}

View File

@ -2,12 +2,14 @@ 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,9 +2,11 @@ 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

@ -15,6 +15,7 @@ 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
@ -40,7 +41,6 @@ import com.github.nacabaro.vbhelper.screens.settingsScreen.CreditsScreen
import com.github.nacabaro.vbhelper.screens.spriteViewer.SpriteViewerControllerImpl
import com.github.nacabaro.vbhelper.screens.storageScreen.StorageScreenControllerImpl
import com.github.nacabaro.vbhelper.source.StorageRepository
import kotlinx.coroutines.flow.Flow
data class AppNavigationHandlers(
val settingsScreenController: SettingsScreenControllerImpl,
@ -56,20 +56,22 @@ data class AppNavigationHandlers(
@Composable
fun AppNavigation(
applicationNavigationHandlers: AppNavigationHandlers,
initialRoute: String? = null,
navigationEvents: Flow<String>? = null,
initialRoute: String? = null
) {
val navController = rememberNavController()
LaunchedEffect(navController, navigationEvents) {
navigationEvents?.collect { route ->
navController.navigate(route) {
launchSingleTop = true
restoreState = true
popUpTo(navController.graph.startDestinationId) {
saveState = true
}
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
}
}

View File

@ -7,6 +7,7 @@ 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
@ -30,40 +31,14 @@ fun BottomNavigationBar(navController: NavController) {
label = { Text(text = stringResource(item.label)) },
selected = currentRoute == item.route,
onClick = {
if (item == NavigationItems.Home) {
// Home should always show the Home root, not a restored nested route
// like Settings that was opened from Home previously.
val poppedToHome = navController.popBackStack(
NavigationItems.Home.route,
inclusive = false,
)
if (!poppedToHome) {
navController.navigate(NavigationItems.Home.route) {
popUpTo(navController.graph.startDestinationId) { inclusive = false }
launchSingleTop = true
restoreState = false
}
}
} else if (item == NavigationItems.Storage) {
// Adventure is launched from Storage; tapping Storage again should always
// bring the user back to the Storage root instead of appearing stuck.
val poppedToStorage = navController.popBackStack(
NavigationItems.Storage.route,
inclusive = false,
)
if (!poppedToStorage) {
navController.navigate(NavigationItems.Storage.route) {
popUpTo(navController.graph.startDestinationId) { saveState = true }
launchSingleTop = true
restoreState = true
}
}
} else {
navController.navigate(item.route) {
popUpTo(navController.graph.startDestinationId) { saveState = true }
launchSingleTop = true
restoreState = true
navController.navigate(item.route) {
// Always route tab clicks to each tab's root screen.
popUpTo(navController.graph.findStartDestination().id) {
inclusive = false
saveState = false
}
launchSingleTop = true
restoreState = false
}
}
)

View File

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

View File

@ -51,6 +51,7 @@ 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
@ -64,6 +65,8 @@ 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
@ -71,6 +74,7 @@ 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
@ -899,7 +903,6 @@ fun MiddleBattleView(
y = enemyVerticalOffset + 40.dp
),
contentScale = ContentScale.Fit,
reloadMappings = false,
animationOffset = 375L // Offset enemy animation by half the idle duration
)
@ -986,7 +989,6 @@ fun MiddleBattleView(
y = playerVerticalOffset - 40.dp
),
contentScale = ContentScale.Fit,
reloadMappings = false,
animationOffset = 0L // Player animation starts immediately
)
@ -1072,19 +1074,30 @@ 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) {
0 -> 0 // rookie
1 -> 1 // champion
2 -> 2 // ultimate
3 -> 3 // mega
2 -> 0 // rookie
3 -> 1 // champion
4 -> 2 // ultimate
5 -> 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
@ -1101,11 +1114,11 @@ fun MiddleBattleView(
RetrofitHelper().getPVPWinner(
ctx,
1,
playerUserId ?: 2L,
activeCharacter?.name ?: "Player",
playerStage,
opponentStage,
opponentCharacter?.name ?: "Opponent",
playerUserId,
activeCharacter?.charaId ?: "dim011_mon01",
playerStage,
battleSystem.critBarProgress,
opponentCharacter?.charaId ?: "dim011_mon01",
opponentStage
) { apiResult ->
// Handle API response here
@ -1309,7 +1322,6 @@ fun PlayerBattleView(
y = verticalOffset
),
contentScale = ContentScale.Fit,
reloadMappings = false,
animationOffset = 0L // Player animation starts immediately
)
@ -1491,7 +1503,6 @@ fun EnemyBattleView(
y = verticalOffset
),
contentScale = ContentScale.Fit,
reloadMappings = false,
animationOffset = 375L // Offset enemy animation by half the idle duration
)
@ -1641,8 +1652,63 @@ 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) }
@ -1724,39 +1790,85 @@ fun BattlesScreen() {
// Determine if player can battle based on stage (derived from activeUserCharacter)
val canBattle = activeUserCharacter?.stage?.let { it >= 2 } ?: false
// 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) {
return@LaunchedEffect
}
val loadOpponentsIfReady: () -> Unit = loadOpponents@{
val currentCharacter = activeUserCharacter
if (currentCharacter != null && canBattle && playerBattleType != null) {
try {
RetrofitHelper().getOpponents(context, playerBattleType!!) { opponents ->
try {
// Create a new list to trigger UI recomposition
opponentsList = ArrayList(opponents.opponentsList)
} catch (e: Exception) {
Log.d(TAG, "Error processing opponents data: ${e.message}")
e.printStackTrace()
}
}
} catch (e: Exception) {
Log.d(TAG,"Error calling getOpponents: ${e.message}")
e.printStackTrace()
}
} else {
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}")
val readyToken = authReadyToken
if (!isAuthenticated) {
if (opponentsList.isNotEmpty()) {
opponentsList = ArrayList()
}
return@loadOpponents
}
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
}
try {
isLoadingOpponents = true
RetrofitHelper().getOpponents(context, battleType, onComplete = {
isLoadingOpponents = false
}) { opponents ->
try {
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 - 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)
}
}
@ -1775,17 +1887,19 @@ 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.userInfo?.userId?.toLongOrNull()
val sessionToken = response.sessionToken
val extractedUserId = response.extractUserId()
val sessionToken = response.extractSessionToken()
println("BATTLESCREEN: Authentication successful, userId: $extractedUserId, sessionToken: ${if (sessionToken != null) "present" else "missing"}")
// Store both nacatech token (for re-auth) and sessionToken (for API calls)
// Persist auth first so follow-up API calls can immediately use the token.
kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch {
battleAuthContainer.authRepository.setAuthenticated(
isAuthenticated = true,
@ -1793,14 +1907,16 @@ fun BattlesScreen() {
sessionToken = sessionToken,
userId = extractedUserId
)
}
// Update UI state on main thread
kotlinx.coroutines.CoroutineScope(Dispatchers.Main).launch {
isAuthenticated = true
isCheckingAuth = false
userId = extractedUserId
println("BATTLESCREEN: Authentication successful, userId: $extractedUserId")
android.widget.Toast.makeText(context, "Authentication successful!", android.widget.Toast.LENGTH_SHORT).show()
withContext(Dispatchers.Main) {
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}")
@ -1815,23 +1931,16 @@ fun BattlesScreen() {
kotlinx.coroutines.CoroutineScope(Dispatchers.Main).launch {
isAuthenticated = false
isCheckingAuth = false
// 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()
}
}
isProcessingAuthCallback = false
authBrowserLaunchInFlight = false
println("BATTLESCREEN: Auth callback token was already used; avoiding automatic browser relaunch loop")
}
} 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 {
@ -1839,12 +1948,47 @@ 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 {
@ -1852,7 +1996,8 @@ 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) {
userId = storedUserId
@ -1867,41 +2012,36 @@ fun BattlesScreen() {
userId = storedUserId
}
// 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 (consumeAuthCallbackIntent()) {
return@LaunchedEffect
}
// 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) {
if (localAuthState && storedToken != null && storedToken.isNotEmpty() && (storedUserId != null || !storedSessionToken.isNullOrEmpty())) {
// 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)")
println("BATTLESCREEN: Restoring active session (userId: $storedUserId, sessionTokenPresent=${!storedSessionToken.isNullOrEmpty()})")
isAuthenticated = true
isCheckingAuth = false
userId = storedUserId
// Session is restored, no need to validate token again
} else if (localAuthState && storedToken != null && storedToken.isNotEmpty()) {
} else if (localAuthState && storedToken != null && storedToken.isNotEmpty() && storedSessionToken.isNullOrEmpty()) {
// 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, storedToken) { response ->
RetrofitHelper().authenticate(
context = context,
token = storedToken,
showUserFacingErrors = false,
) { response ->
// Update UI on main thread
kotlinx.coroutines.CoroutineScope(Dispatchers.Main).launch {
if (response.success) {
val extractedUserId = response.userInfo?.userId?.toLongOrNull()
val sessionToken = response.sessionToken
val extractedUserId = response.extractUserId()
val sessionToken = response.extractSessionToken()
// Update stored userId and sessionToken
if (extractedUserId != null) {
kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch {
@ -1924,8 +2064,22 @@ 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 {
@ -1933,11 +2087,7 @@ fun BattlesScreen() {
}
isAuthenticated = false
isCheckingAuth = false
// Open auth URL
val authUrl = "http://auth.nacatech.es/begin?app=443654920&redirect_uri=vbhelper://auth?token="
val authIntent = Intent(Intent.ACTION_VIEW, Uri.parse(authUrl))
context.startActivity(authIntent)
println("BATTLESCREEN: Opened auth URL after critical validation failure: $authUrl")
openBattleAuthPage()
} else {
// Non-critical error (e.g., network issue) - keep authenticated state
println("BATTLESCREEN: Non-critical validation error, keeping authenticated state")
@ -1952,10 +2102,7 @@ fun BattlesScreen() {
isAuthenticated = false
isCheckingAuth = false
// If not authenticated and no fresh token in intent, open auth URL
val authUrl = "http://auth.nacatech.es/begin?app=443654920&redirect_uri=vbhelper://auth?token="
val authIntent = Intent(Intent.ACTION_VIEW, Uri.parse(authUrl))
context.startActivity(authIntent)
println("BATTLESCREEN: Opened auth URL: $authUrl")
openBattleAuthPage()
}
} catch (e: Exception) {
println("BATTLESCREEN: Error checking authentication status: ${e.message}")
@ -1964,42 +2111,11 @@ fun BattlesScreen() {
}
}
// 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)
// Handle deep link callback once on initial load and consume it.
LaunchedEffect(Unit) {
// Small delay to ensure activity is fully initialized
kotlinx.coroutines.delay(100)
val activity = context as? ComponentActivity
val intent = activity?.intent
// Only process if this is a fresh deep link (ACTION_VIEW)
if (intent?.action == Intent.ACTION_VIEW) {
val uri = intent.data
if (uri != null) {
// 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")
}
consumeAuthCallbackIntent()
}
// Check intent when screen becomes visible or when authentication state changes
@ -2009,15 +2125,10 @@ fun BattlesScreen() {
val lifecycleOwner = activity as? LifecycleOwner
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME && !isAuthenticated) {
// Check intent data when activity resumes - only if it's a fresh ACTION_VIEW intent
val intent = activity?.intent
if (intent?.action == Intent.ACTION_VIEW) {
intent.data?.let { uri ->
if (uri.getQueryParameter("c") != null || uri.getQueryParameter("token") != null) {
handleTokenFromUri(uri)
}
}
if (event == Lifecycle.Event.ON_RESUME) {
authBrowserLaunchInFlight = false
if (!isAuthenticated) {
consumeAuthCallbackIntent()
}
}
}
@ -2038,45 +2149,16 @@ fun BattlesScreen() {
isAuthenticated = false
isCheckingAuth = false
// 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()
}
openBattleAuthPage()
}
}
}
// Also check intent when authentication state changes
// Only process if it's a fresh ACTION_VIEW intent (deep link)
// Also check for callback when auth state changes.
LaunchedEffect(isAuthenticated) {
if (!isAuthenticated) {
kotlinx.coroutines.delay(200) // Small delay to ensure intent is available
val activity = context as? ComponentActivity
val intent = activity?.intent
// Only process if this is a fresh deep link (ACTION_VIEW)
if (intent?.action == Intent.ACTION_VIEW) {
intent.data?.let { uri ->
println("BATTLESCREEN: Re-checking ACTION_VIEW intent data - URI: $uri, scheme: ${uri.scheme}, host: ${uri.host}")
// Handle vbhelper://auth?token= or vbhelper://auth?c= deep link
if (uri.scheme == "vbhelper" && uri.host == "auth") {
handleTokenFromUri(uri)
}
// Handle http://localhost:8080/authenticate?c= redirect
else if ((uri.scheme == "http" || uri.scheme == "https") &&
(uri.host == "localhost" || uri.host == "127.0.0.1" || uri.host?.contains("8080") == true)) {
handleTokenFromUri(uri)
}
// Also check if there's a 'c' or 'token' parameter in any URL
else if (uri.getQueryParameter("c") != null || uri.getQueryParameter("token") != null) {
handleTokenFromUri(uri)
}
}
}
consumeAuthCallbackIntent()
}
}
@ -2084,9 +2166,8 @@ fun BattlesScreen() {
// Only check if permission is granted
LaunchedEffect(hasStoragePermission) {
if (hasStoragePermission) {
val spriteFileManager = SpriteFileManager(context)
if (spriteFileManager.checkSpriteFilesExist()) {
} else {
val spriteFileManager = SpriteFileManager()
if (!spriteFileManager.checkSpriteFilesExist()) {
println("BATTLESCREEN: Sprite files not found in external storage")
}
} else {
@ -2233,6 +2314,10 @@ fun BattlesScreen() {
color = Color.Gray,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { openBattleAuthPage() }) {
Text("Open Login")
}
}
}
}
@ -2267,6 +2352,12 @@ 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)
@ -2281,7 +2372,7 @@ fun BattlesScreen() {
else -> 0
}
RetrofitHelper().getPVPWinner(context, 0, userId ?: 2L, cardId, apiStage, 0, opponent.charaId, apiStage) { apiResult ->
RetrofitHelper().getPVPWinner(context, 0, currentUserId, cardId, apiStage, 0, opponent.charaId, apiStage) { apiResult ->
// Check if there's an existing match
when {
apiResult.status.contains("Existing match found", ignoreCase = true) -> {
@ -2382,14 +2473,17 @@ 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) {
0 -> 0 // rookie
1 -> 1 // champion
2 -> 2 // ultimate
3 -> 3 // mega
2 -> 0 // rookie
3 -> 1 // champion
4 -> 2 // ultimate
5 -> 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
@ -2402,11 +2496,16 @@ fun BattlesScreen() {
RetrofitHelper().getPVPWinner(
context,
1,
userId ?: 2L,
activeCharacter?.name ?: "Player",
playerStage,
opponentStage,
selectedOpponent?.name ?: "Opponent",
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",
playerStage,
0,
selectedOpponent?.charaId ?: "dim011_mon01",
opponentStage
) { apiResult ->
// Winner might be empty in first call, but we can check HP values
@ -2415,7 +2514,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
@ -2442,18 +2541,21 @@ 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 = apiResult.winner ?: if (playerWon) "You" else "Opponent"
winnerName = if (apiResult.winner.isNotEmpty()) apiResult.winner else if (playerWon) "You" else "Opponent"
isWinnerLoaded = true
// Then send the cleanup call - this will have the actual winner name
RetrofitHelper().getPVPWinner(
context,
2,
userId ?: 2L,
activeCharacter?.name ?: "Player",
userId ?: run {
Toast.makeText(context, "Session missing user id. Please re-authenticate.", Toast.LENGTH_SHORT).show()
return@getPVPWinner
},
activeCharacter?.charaId ?: "dim011_mon01",
playerStage,
opponentStage,
selectedOpponent?.name ?: "Opponent",
0,
selectedOpponent?.charaId ?: "dim011_mon01",
opponentStage
) { cleanupResult ->
// Update winner name from cleanup call if available
@ -2465,7 +2567,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
@ -2602,8 +2704,13 @@ fun BattlesScreen() {
RetrofitHelper().getPVPWinner(
context,
0,
userId ?: 2L,
cardId,
userId ?: run {
Toast.makeText(context, "Session missing user id. Please re-authenticate.", Toast.LENGTH_SHORT).show()
showResumeDialog = false
existingMatchState = null
return@let
},
cardId,
apiStage,
0,
clickedOpponent.charaId,
@ -2727,8 +2834,13 @@ fun BattlesScreen() {
RetrofitHelper().getPVPWinner(
context,
0,
userId ?: 2L,
cardId,
userId ?: run {
Toast.makeText(context, "Session missing user id. Please re-authenticate.", Toast.LENGTH_SHORT).show()
showResumeDialog = false
existingMatchState = null
return@let
},
cardId,
apiStage,
0,
opponent.charaId,

View File

@ -33,8 +33,6 @@ 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
@ -74,9 +72,8 @@ fun HomeScreen(
?: flowOf(emptyList())
).collectAsState(initial = emptyList())
val vbSpecialMissions by (
val specialMissions by (
activeMon
?.takeIf { it.characterType == DeviceType.VBDevice }
?.let { chara ->
storageRepository.getSpecialMissions(chara.id)
}
@ -144,29 +141,20 @@ fun HomeScreen(
height = cardIconData!!.cardIconHeight
)
if (activeMon!!.isBemCard && beData != null) {
BEBEmHomeScreen(
activeMon = activeMon!!,
beData = beData!!,
transformationHistory = transformationHistory,
contentPadding = PaddingValues(0.dp),
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) {
val activeCharacter = activeMon!!
val displaySpecialMissions = specialMissions
if (activeCharacter.characterType == DeviceType.BEDevice || vbData != null) {
VBDiMHomeScreen(
activeMon = activeMon!!,
vbData = vbData!!,
activeMon = activeCharacter,
vbData = vbData ?: VBCharacterData(
id = activeCharacter.id,
generation = 0,
totalTrophies = activeCharacter.trophies,
),
transformationHistory = transformationHistory,
contentPadding = PaddingValues(0.dp),
specialMissions = vbSpecialMissions,
specialMissions = displaySpecialMissions,
homeScreenController = homeScreenController,
onClickCollect = { item, currency ->
collectedItem = item
@ -225,3 +213,5 @@ fun HomeScreen(
}
}
}

View File

@ -0,0 +1,241 @@
package com.github.nacabaro.vbhelper.screens.homeScreens.screens
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.res.stringResource
import com.github.nacabaro.vbhelper.R
import com.github.nacabaro.vbhelper.screens.homeScreens.dialogs.DeleteSpecialMissionDialog
import com.github.nacabaro.vbhelper.components.CharacterEntry
import com.github.nacabaro.vbhelper.components.ItemDisplay
import com.github.nacabaro.vbhelper.components.SpecialMissionsEntry
import com.github.nacabaro.vbhelper.components.TransformationHistoryCard
import com.github.nacabaro.vbhelper.domain.device_data.SpecialMissions
import com.github.nacabaro.vbhelper.domain.device_data.UnifiedCharacter
import com.github.nacabaro.vbhelper.dtos.CharacterDtos
import com.github.nacabaro.vbhelper.dtos.ItemDtos
import com.github.nacabaro.vbhelper.screens.homeScreens.HomeScreenControllerImpl
import com.github.nacabaro.vbhelper.screens.itemsScreen.getIconResource
import com.github.nacabaro.vbhelper.screens.itemsScreen.ItemsScreenControllerImpl
import com.github.nacabaro.vbhelper.utils.BitmapData
import java.util.Locale
@Composable
fun UnifiedHomeScreen(
activeMon: CharacterDtos.CharacterWithSprites,
cardIcon: BitmapData,
unifiedChar: UnifiedCharacter,
specialMissions: List<SpecialMissions>,
transformationHistory: List<CharacterDtos.TransformationHistory>,
contentPadding: PaddingValues,
homeScreenController: HomeScreenControllerImpl,
onClickCollect: (ItemDtos.PurchasedItem?, Int?) -> Unit
) {
var selectedSpecialMissionId by remember { mutableStateOf<Long>(-1) }
Column(
modifier = Modifier
.padding(top = contentPadding.calculateTopPadding())
.verticalScroll(state = rememberScrollState())
) {
// CORE SLOT: Always the same for VB and BE
Row(modifier = Modifier.fillMaxWidth()) {
CharacterEntry(
icon = BitmapData(
bitmap = activeMon.spriteIdle,
width = activeMon.spriteWidth,
height = activeMon.spriteHeight
),
cardIcon = cardIcon,
multiplier = 8,
shape = androidx.compose.material.MaterialTheme.shapes.small,
modifier = Modifier.weight(1f).aspectRatio(1f)
)
Column(modifier = Modifier.weight(0.5f).aspectRatio(0.5f)) {
ItemDisplay(
icon = R.drawable.baseline_vitals_24,
textValue = activeMon.vitalPoints.toString(),
definition = stringResource(R.string.home_vbdim_vitals),
modifier = Modifier.weight(0.5f).aspectRatio(1f).padding(8.dp)
)
ItemDisplay(
icon = R.drawable.baseline_trophy_24,
textValue = activeMon.trophies.toString(),
definition = stringResource(R.string.home_vbdim_trophies),
modifier = Modifier.weight(0.5f).aspectRatio(1f).padding(8.dp)
)
}
}
// STATS ROW 1: mood and timing (VB timer vs BE training limit)
Row(modifier = Modifier.fillMaxWidth()) {
ItemDisplay(
icon = R.drawable.baseline_mood_24,
textValue = activeMon.mood.toString(),
definition = stringResource(R.string.home_vbdim_mood),
modifier = Modifier.weight(1f).aspectRatio(1f).padding(8.dp)
)
if (unifiedChar.isVB()) {
val transformationCountdownInHours = activeMon.transformationCountdown / 60
ItemDisplay(
icon = R.drawable.baseline_next_24,
textValue = when (transformationCountdownInHours) {
0 -> "${activeMon.transformationCountdown} m"
else -> "$transformationCountdownInHours h"
},
definition = stringResource(R.string.home_vbdim_next_timer),
modifier = Modifier.weight(1f).aspectRatio(1f).padding(8.dp)
)
} else {
val timeInHours = (unifiedChar.beData?.remainingTrainingTimeInMinutes ?: 0) / 60
ItemDisplay(
icon = R.drawable.baseline_timer_24,
textValue = "$timeInHours h",
definition = "Training limit",
modifier = Modifier.weight(1f).aspectRatio(1f).padding(8.dp)
)
}
if (unifiedChar.isBE()) {
ItemDisplay(
icon = R.drawable.baseline_rank_24,
textValue = (unifiedChar.beData?.rank ?: 0).toString(),
definition = "Rank",
modifier = Modifier.weight(1f).aspectRatio(1f).padding(8.dp)
)
} else {
// For VB, maybe put a spacer or other stat here to keep row balance
}
}
// STATS ROW 2: Battle stats and Items
Row(modifier = Modifier.fillMaxWidth()) {
val transformationCountdownInHours = activeMon.transformationCountdown / 60
ItemDisplay(
icon = R.drawable.baseline_next_24,
textValue = when (transformationCountdownInHours) {
0 -> "${activeMon.transformationCountdown} m"
else -> "$transformationCountdownInHours h"
},
definition = "Next timer",
modifier = Modifier.weight(1f).aspectRatio(1f).padding(8.dp)
)
// Battle stats (Common)
ItemDisplay(
icon = R.drawable.baseline_swords_24,
textValue = calculateWinPercent(activeMon.totalBattlesWon, activeMon.totalBattlesLost),
definition = "Total battle win %",
modifier = Modifier.weight(1f).aspectRatio(1f).padding(8.dp)
)
ItemDisplay(
icon = R.drawable.baseline_swords_24,
textValue = calculateWinPercent(activeMon.currentPhaseBattlesWon, activeMon.currentPhaseBattlesLost),
definition = "Current phase win %",
modifier = Modifier.weight(1f).aspectRatio(1f).padding(8.dp)
)
if (unifiedChar.isBE()) {
val beData = unifiedChar.beData
if (beData != null && beData.itemRemainingTime != 0) {
ItemDisplay(
icon = getIconResource(beData.itemType),
textValue = "${beData.itemRemainingTime} m",
definition = getItemTypeName(beData.itemType),
modifier = Modifier.weight(1f).aspectRatio(1f).padding(8.dp)
)
}
}
}
// HISTORY SLOT: Common
Row(modifier = Modifier.fillMaxWidth()) {
TransformationHistoryCard(
transformationHistory = transformationHistory,
modifier = Modifier.weight(1f).padding(8.dp)
)
}
// CONTEXTUAL BOTTOM SLOT
if (unifiedChar.isVB()) {
Row(modifier = Modifier.padding(16.dp)) {
Text(text = stringResource(R.string.home_vbdim_special_missions), fontSize = 24.sp)
}
for (mission in specialMissions) {
Row(modifier = Modifier.fillMaxWidth()) {
SpecialMissionsEntry(
specialMission = mission,
modifier = Modifier.weight(1f).padding(8.dp),
onClickMission = { missionId -> selectedSpecialMissionId = missionId },
onClickCollect = { homeScreenController.clearSpecialMission(selectedSpecialMissionId, onClickCollect) }
)
}
}
} else if (unifiedChar.isBE()) {
Row(modifier = Modifier.fillMaxWidth()) {
val beData = unifiedChar.beData ?: return@Column
ItemDisplay(
icon = R.drawable.baseline_health_24,
textValue = "+${beData.trainingHp}",
definition = "Training HP",
modifier = Modifier.weight(1f).aspectRatio(1f).padding(8.dp)
)
ItemDisplay(
icon = R.drawable.baseline_agility_24,
textValue = "+${beData.trainingBp}",
definition = "Training BP",
modifier = Modifier.weight(1f).aspectRatio(1f).padding(8.dp)
)
ItemDisplay(
icon = R.drawable.baseline_attack_24,
textValue = "+${beData.trainingAp}",
definition = "Training AP",
modifier = Modifier.weight(1f).aspectRatio(1f).padding(8.dp)
)
}
}
}
if (selectedSpecialMissionId.toInt() != -1 && unifiedChar.isVB()) {
DeleteSpecialMissionDialog(
onClickDismiss = { selectedSpecialMissionId = -1 },
onClickDelete = {
homeScreenController.clearSpecialMission(selectedSpecialMissionId, onClickCollect)
selectedSpecialMissionId = -1
}
)
}
}
private fun calculateWinPercent(won: Int, lost: Int): String {
return if (lost == 0) "0.00 %" else {
val percentage = won.toFloat() / (won + lost).toFloat()
String.format(Locale.getDefault(), "%.2f", percentage * 100) + " %"
}
}
private fun getItemTypeName(itemId: Int): String {
return when (itemId) {
ItemsScreenControllerImpl.ItemTypes.PPTraining.id -> "PP Training"
ItemsScreenControllerImpl.ItemTypes.HPTraining.id -> "HP Training"
ItemsScreenControllerImpl.ItemTypes.APTraining.id -> "AP Training"
ItemsScreenControllerImpl.ItemTypes.BPTraining.id -> "BP Training"
ItemsScreenControllerImpl.ItemTypes.AllTraining.id -> "All Training"
else -> ""
}
}

View File

@ -8,12 +8,17 @@ 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
@ -28,6 +33,12 @@ 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,
@ -48,3 +59,48 @@ 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.TabRow
import androidx.compose.material3.PrimaryTabRow
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))
TabRow(
PrimaryTabRow(
selectedTabIndex = selectedTabItem,
modifier = Modifier
) {

View File

@ -7,7 +7,6 @@ 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
@ -51,19 +50,12 @@ 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 (
@ -122,8 +114,7 @@ class ItemsScreenControllerImpl (
.updateCharacter(characterData)
} else if (item.itemIcon in ItemTypes.Step8k.id .. ItemTypes.Win4.id &&
characterData.characterType == DeviceType.VBDevice &&
vbCharacterData != null
(characterData.characterType == DeviceType.VBDevice || characterData.characterType == DeviceType.BEDevice)
) {
applySpecialMission(item.itemIcon, item.itemLength, characterId)
}
@ -180,12 +171,12 @@ class ItemsScreenControllerImpl (
.getSpecialMissions(characterId)
.first()
var newSpecialMission = availableSpecialMissions[specialMissionSlot]
newSpecialMission = SpecialMissions(
id = newSpecialMission.id,
characterId = newSpecialMission.characterId,
val existingMission = availableSpecialMissions.firstOrNull { it.watchId == specialMissionSlot }
val newSpecialMission = SpecialMissions(
id = existingMission?.id ?: 0,
characterId = characterId,
goal = specialMissionGoal,
watchId = newSpecialMission.watchId,
watchId = specialMissionSlot,
progress = 0,
status = SpecialMission.Status.AVAILABLE,
timeElapsedInMinutes = 0,

View File

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

View File

@ -144,7 +144,12 @@ 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, onComplete: (ScanScreenController.WriteResult) -> Unit) {}
override fun onClickWrite(
secrets: Secrets,
nfcCharacter: NfcCharacter,
characterId: Long?,
onComplete: (ScanScreenController.WriteResult) -> 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

@ -20,7 +20,12 @@ interface ScanScreenController {
fun onClickRead(secrets: Secrets, onComplete: ()->Unit, onMultipleCards: (List<Card>) -> Unit)
fun onClickCheckCard(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: () -> Unit)
fun onClickWrite(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: (WriteResult) -> Unit)
fun onClickWrite(
secrets: Secrets,
nfcCharacter: NfcCharacter,
characterId: Long? = null,
onComplete: (WriteResult) -> Unit
)
fun cancelRead()

View File

@ -12,8 +12,10 @@ 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
@ -22,13 +24,11 @@ import com.github.nacabaro.vbhelper.screens.scanScreen.converters.FromNfcConvert
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.transfer.ExportFormat
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.TransferTarget
import com.github.nacabaro.vbhelper.transfer.TransferTransport
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
@ -57,6 +57,7 @@ class ScanScreenControllerImpl(
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
@ -122,7 +123,6 @@ class ScanScreenControllerImpl(
val options = Bundle()
options.putInt(NfcAdapter.EXTRA_READER_PRESENCE_CHECK_DELAY, 250)
val flags = NfcAdapter.FLAG_READER_NFC_A or
NfcAdapter.FLAG_READER_NFC_B or
NfcAdapter.FLAG_READER_NO_PLATFORM_SOUNDS or
NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK
runCatching {
@ -162,177 +162,153 @@ class ScanScreenControllerImpl(
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 {
var importResult: VitalWearCharacterImporter.ImportResult? = null
val moved = VitalWearHceReaderClient(isoDep).moveCharacterFromWatch { character ->
val result = importer.importCharacter(character)
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."
}
}
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, onComplete: (ScanScreenController.WriteResult) -> Unit) {
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 initialSlotState = readNfcASlotState(tagCommunicator)
if (initialSlotState.isFull()) {
onComplete.invoke(ScanScreenController.WriteResult.BLOCKED_DEVICE_FULL)
return@handleTag componentActivity.getString(R.string.scan_target_device_full)
}
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"
)
val migrationCheck = verifyActiveToBackupMigration(tagCommunicator, initialSlotState)
if (!migrationCheck.canProceed) {
onComplete.invoke(ScanScreenController.WriteResult.COPIED)
return@handleTag migrationCheck.message
}
// 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
}
when (nfcCharacter) {
is VBNfcCharacter -> tagCommunicator.sendCharacter(nfcCharacter)
is BENfcCharacter -> tagCommunicator.sendCharacter(nfcCharacter)
}
onComplete.invoke(ScanScreenController.WriteResult.MOVE_CONFIRMED)
componentActivity.getString(R.string.scan_sent_character_success)
} catch (writeError: Throwable) {
Log.e("NFC_WRITE_A", "NFC-A write failed; trying alternate payload format", writeError)
val characterId = lastRequestedCharacterId
val alternateFormat = when (nfcCharacter) {
is BENfcCharacter -> ExportFormat.VB
is VBNfcCharacter -> ExportFormat.BE
else -> null
}
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 alternateSent = if (characterId != null && alternateFormat != null) {
runCatching {
val alternateCharacter = runBlocking {
ToNfcConverter(componentActivity = componentActivity).characterToNfc(
characterId = characterId,
target = TransferTarget.REAL_BRACELET,
transport = TransferTransport.NFCA,
forcedFormat = alternateFormat,
)
}
when (alternateCharacter) {
is VBNfcCharacter -> tagCommunicator.sendCharacter(alternateCharacter)
is BENfcCharacter -> tagCommunicator.sendCharacter(alternateCharacter)
}
true
}.getOrElse { alternateError ->
Log.e("NFC_WRITE_A", "Alternate payload write failed", alternateError)
false
}
} else {
false
}
val migrationCheck = verifyActiveToBackupMigration(tagCommunicator, initialSlotState)
if (!migrationCheck.canProceed) {
onComplete.invoke(ScanScreenController.WriteResult.COPIED)
return@handleTag migrationCheck.message
}
if (alternateSent) {
onComplete.invoke(ScanScreenController.WriteResult.MOVE_CONFIRMED)
"Sent character after converting payload for the tapped bracelet."
} else {
Log.e("NFC_WRITE_A", "NFC-A write failed; attempting opposite direction fallback", writeError)
runCatching {
val receivedCharacter = tagCommunicator.receiveCharacter()
val fallbackMessage = importCharacterFromNfcAFallback(receivedCharacter)
onComplete.invoke(ScanScreenController.WriteResult.COPIED)
fallbackMessage
}.getOrElse { readFallbackError ->
Log.e("NFC_WRITE_A", "NFC-A opposite-direction fallback failed", readFallbackError)
componentActivity.getString(R.string.scan_error_generic)
}
}
}
},
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 {
val proto = runBlocking {
VitalWearCharacterExporter(application.container.db)
.buildCharacterProto(characterId)
}
hceClient.sendCharacterToWatch(proto)
onComplete.invoke(ScanScreenController.WriteResult.MOVE_CONFIRMED)
componentActivity.getString(R.string.scan_sent_character_success)
} catch (writeError: Throwable) {
Log.e("NFC_WRITE_HCE", "HCE write failed; attempting opposite direction", writeError)
runCatching {
var importResult: VitalWearCharacterImporter.ImportResult? = null
val moved = hceClient.moveCharacterFromWatch { character ->
val result = VitalWearCharacterImporter(application.container.db, application.container.transferSeenDao).importCharacter(character)
importResult = result
result.success
}
onComplete.invoke(ScanScreenController.WriteResult.COPIED)
if (moved) {
importResult?.message
?: "Detected source on watch and imported it instead of sending."
} else {
importResult?.message
?: "Watch is in source mode, but import was rejected."
}
}.getOrElse { readFallbackError ->
Log.e("NFC_WRITE_HCE", "HCE opposite-direction fallback failed", readFallbackError)
componentActivity.getString(R.string.scan_error_generic)
}
}
}
// 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
}
}
)
}
private fun importCharacterFromNfcAFallback(receivedCharacter: NfcCharacter): String {
val application = componentActivity.applicationContext as VBHelper
val fromNfcConverter = FromNfcConverter(componentActivity)
val candidateCards = application.container.db
.cardDao()
.getCardByCardId(receivedCharacter.dimId.toInt())
if (candidateCards.isEmpty()) {
return "Detected source character on bracelet, but matching card is not imported in VBHelper."
}
val preferredCardId = receivedCharacter.appReserved2
.firstOrNull()
?.toLong()
?.takeIf { it > 0L }
val preferredCard = preferredCardId?.let { id ->
candidateCards.firstOrNull { it.id == id }
}
val selectedCard = when {
preferredCard != null -> preferredCard
candidateCards.size == 1 -> candidateCards.single()
else -> {
lastScannedCharacter = receivedCharacter
return "Detected source character, but multiple matching cards exist. Use Read and select the card."
}
}
return fromNfcConverter.addCharacterUsingCard(receivedCharacter, selectedCard.id)
}
// ---- Check card (NFC-A only; ISO-DEP watches don't need a DIM prep) -----------
@ -407,7 +383,6 @@ class ScanScreenControllerImpl(
// 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_NFC_B or
NfcAdapter.FLAG_READER_NO_PLATFORM_SOUNDS or
NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK
val sessionId = readerSessionCounter.incrementAndGet()
@ -450,18 +425,19 @@ class ScanScreenControllerImpl(
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 hasIsoDepRoute = isoDep != null && isoDepHandler != null
val hasNfcARoute = NfcA.get(tag) != null
val hasIsoDepHandler = isoDep != null && isoDepHandler != null
val confirmedVitalWear = if (hasIsoDepHandler) {
runCatching { isVitalWearHceTarget(isoDep) }.getOrDefault(false)
} else {
false
}
try {
// Prefer ISO-DEP whenever present so HCE sessions do not accidentally fall through to NFC-A.
if (hasIsoDepRoute) {
if (hasIsoDepHandler && confirmedVitalWear) {
val isoDepTarget = isoDep
val isoDepAction = isoDepHandler
val confirmedVitalWear = runCatching { isVitalWearHceTarget(isoDepTarget) }.getOrDefault(false)
if (!confirmedVitalWear) {
Log.w("NFC_HCE", "IsoDep tag did not confirm VitalWear AID; attempting ISO-DEP handler without NFC-A fallback")
}
try {
_detectedTransportFlow.value = DetectedTransport.ISO_DEP
isoDepTarget.connect()
@ -480,7 +456,19 @@ class ScanScreenControllerImpl(
setTransferStatus(R.string.scan_transfer_failed_try_again)
}
}
} else if (hasIsoDepHandler && !confirmedVitalWear && !hasNfcARoute) {
// Hard-fail only when IsoDep is present without any NFC-A fallback.
// This keeps HCE safety while allowing real bracelets to route via NFC-A.
Log.w("NFC_ROUTE", "IsoDep present but VitalWear AID not confirmed and no NFC-A route; cancelling transfer")
_detectedTransportFlow.value = DetectedTransport.UNKNOWN
componentActivity.runOnUiThread {
Toast.makeText(componentActivity, "VitalWear HCE not detected. Transfer cancelled.", Toast.LENGTH_SHORT).show()
setTransferStatus(R.string.scan_transfer_failed_try_again)
}
} else if (hasNfcARoute) {
if (hasIsoDepHandler && !confirmedVitalWear) {
Log.i("NFC_ROUTE", "IsoDep present but VitalWear AID not confirmed; routing to NFC-A fallback")
}
if (!handleNfcATag(tag, secrets, nfcAHandler)) {
componentActivity.runOnUiThread {
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_tag_not_vb), Toast.LENGTH_SHORT).show()
@ -488,6 +476,9 @@ class ScanScreenControllerImpl(
}
}
} 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()
@ -677,10 +668,10 @@ class ScanScreenControllerImpl(
val migrationVerified = afterState.backupPresent == true ||
(beforeState.count != null && afterState.count != null && afterState.count >= beforeState.count)
if (!migrationVerified) {
return SlotMigrationCheck(
canProceed = false,
message = "Transfer blocked. Backup slot could not be verified after migration."
)
// 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 = "")
@ -746,11 +737,7 @@ class ScanScreenControllerImpl(
override suspend fun characterToNfc(characterId: Long): NfcCharacter {
lastRequestedCharacterId = characterId
val character = ToNfcConverter(componentActivity = componentActivity).characterToNfc(
characterId = characterId,
target = TransferTarget.REAL_BRACELET,
transport = TransferTransport.NFCA,
)
val character = ToNfcConverter(componentActivity = componentActivity).characterToNfc(characterId)
Log.d("CharacterType", character.toString())
return character
}

View File

@ -0,0 +1,51 @@
package com.github.nacabaro.vbhelper.screens.scanScreen
import android.content.Context
import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import timber.log.Timber
class TransferHaptics(context: Context) {
private val vibrator: Vibrator? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val manager = context.getSystemService(VibratorManager::class.java)
manager?.defaultVibrator
} else {
@Suppress("DEPRECATION")
context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
}
fun onTransferStart() {
vibrate(VibrationEffect.createOneShot(100L, VibrationEffect.DEFAULT_AMPLITUDE))
}
fun onTransferProgress() {
vibrate(VibrationEffect.createOneShot(50L, VibrationEffect.DEFAULT_AMPLITUDE))
}
fun onTransferComplete() {
vibrate(VibrationEffect.createWaveform(longArrayOf(0L, 100L, 80L, 150L), -1))
}
fun onTransferFailed() {
vibrate(VibrationEffect.createOneShot(250L, VibrationEffect.DEFAULT_AMPLITUDE))
}
fun onDeviceDetected() {
vibrate(VibrationEffect.createOneShot(80L, VibrationEffect.DEFAULT_AMPLITUDE))
}
private fun vibrate(effect: VibrationEffect) {
val deviceVibrator = vibrator ?: return
if (!deviceVibrator.hasVibrator()) {
return
}
runCatching {
deviceVibrator.vibrate(effect)
}.onFailure { error ->
Timber.w(error, "Transfer haptics vibration skipped")
}
}
}

View File

@ -11,8 +11,6 @@ import com.github.nacabaro.vbhelper.domain.device_data.SpecialMissions
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.transfer.CharacterTransferPolicyResolver
import com.github.nacabaro.vbhelper.transfer.ExportFormat
import com.github.nacabaro.vbhelper.utils.DeviceType
import java.util.GregorianCalendar
@ -22,7 +20,6 @@ class FromNfcConverter (
private val application = componentActivity.applicationContext as VBHelper
private val database = application.container.db
private val transferSeenDao = application.container.transferSeenDao
private val transferPolicyResolver = CharacterTransferPolicyResolver(database.characterTransferPolicyDao())
fun addCharacterUsingCard(
@ -130,6 +127,8 @@ class FromNfcConverter (
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,
@ -137,18 +136,6 @@ class FromNfcConverter (
)
}
database
.characterTransferPolicyDao()
.upsert(
transferPolicyResolver.policyForNfcaImport(
characterId = characterId,
observedFormat = when (nfcCharacter) {
is BENfcCharacter -> ExportFormat.BE
else -> ExportFormat.VB
}
)
)
addTransformationHistoryToDatabase(
characterId = characterId,
nfcCharacter = nfcCharacter,
@ -248,8 +235,8 @@ class FromNfcConverter (
itemType = nfcCharacter.itemType.toInt(),
itemMultiplier = nfcCharacter.itemMultiplier.toInt(),
itemRemainingTime = nfcCharacter.itemRemainingTime.toInt(),
otp0 = "", //nfcCharacter.value!!.otp0.toString(),
otp1 = "", //nfcCharacter.value!!.otp1.toString(),
otp0 = nfcCharacter.getOtp0().joinToString("") { "%02x".format(it) },
otp1 = nfcCharacter.getOtp1().joinToString("") { "%02x".format(it) },
minorVersion = nfcCharacter.characterCreationFirmwareVersion.minorVersion.toInt(),
majorVersion = nfcCharacter.characterCreationFirmwareVersion.majorVersion.toInt(),
)
@ -259,6 +246,42 @@ 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 = com.github.cfogrady.vbnfc.data.MissionType.NONE,
status = com.github.cfogrady.vbnfc.data.MissionStatus.UNAVAILABLE,
goal = 0,
progress = 0,
timeElapsedInMinutes = 0,
timeLimitInMinutes = 0,
)
}
database
.userCharacterDao()
.insertSpecialMissions(*emptyMissions.toTypedArray())
}
private fun addVitalsHistoryToDatabase(

View File

@ -15,10 +15,6 @@ 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.transfer.CharacterTransferPolicyResolver
import com.github.nacabaro.vbhelper.transfer.ExportFormat
import com.github.nacabaro.vbhelper.transfer.TransferTarget
import com.github.nacabaro.vbhelper.transfer.TransferTransport
import com.github.nacabaro.vbhelper.utils.DeviceType
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
@ -30,19 +26,27 @@ class ToNfcConverter(
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
private val transferPolicyResolver = CharacterTransferPolicyResolver(database.characterTransferPolicyDao())
suspend fun characterToNfc(
characterId: Long,
target: TransferTarget = TransferTarget.REAL_BRACELET,
transport: TransferTransport = TransferTransport.NFCA,
forcedFormat: ExportFormat? = null,
forcedProfile: DeviceType? = null,
): NfcCharacter {
val app = componentActivity.applicationContext as VBHelper
val database = app.container.db
@ -54,20 +58,14 @@ class ToNfcConverter(
val characterInfo = database
.characterDao()
.getCharacterInfo(userCharacter.charId)
val card = database
.cardDao()
.getCardByCharacterIdSync(characterId)
?: error("Card not found for character $characterId")
val exportFormat = forcedFormat ?: transferPolicyResolver.resolveExportFormat(
characterId = characterId,
characterType = userCharacter.characterType,
transport = transport,
target = target,
isBemCard = card.isBEm,
)
// 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 (exportFormat == ExportFormat.BE)
return if (shouldEncodeAsBem)
nfcToBENfc(characterId, characterInfo, userCharacter)
else
nfcToVBNfc(characterId, characterInfo, userCharacter)
@ -238,6 +236,18 @@ class ToNfcConverter(
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(),
@ -278,8 +288,8 @@ class ToNfcConverter(
itemType = beData.itemType.toByte(),
itemMultiplier = beData.itemMultiplier.toByte(),
itemRemainingTime = beData.itemRemainingTime.toByte(),
otp0 = byteArrayOf(8),
otp1 = byteArrayOf(8),
otp0 = otp0ByteArray,
otp1 = otp1ByteArray,
characterCreationFirmwareVersion = FirmwareVersion(
minorVersion = beData.minorVersion.toByte(),
majorVersion = beData.majorVersion.toByte()

View File

@ -1,54 +1,54 @@
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.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
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
@Composable
fun ActionScreen(
topBannerText: String,
detectedTransportMessage: String?,
transferStatusMessage: String?,
characterPreview: ImageBitmapData? = null,
onClickCancel: () -> Unit,
isTransferring: Boolean = true,
) {
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))
if (!transferStatusMessage.isNullOrBlank()) {
Text(
text = transferStatusMessage,
modifier = Modifier.padding(top = 8.dp, start = 16.dp, end = 16.dp)
)
}
Button(
onClick = onClickCancel,
modifier = Modifier.padding(16.dp)
) {
Text(stringResource(R.string.action_cancel))
}
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
)
}
}

View File

@ -3,17 +3,21 @@ 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"
@ -24,8 +28,17 @@ 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) }
@ -33,6 +46,12 @@ fun ReadingScreen(
var isDoneReadingCharacter by remember { mutableStateOf(false) }
var cardSelectScreen by remember { mutableStateOf(false) }
LaunchedEffect(readingScreen) {
if (readingScreen) {
transferHaptics.onTransferStart()
}
}
fun startReadIfNeeded() {
val availableSecrets = secrets
if (availableSecrets == null) {
@ -52,11 +71,13 @@ fun ReadingScreen(
secrets = availableSecrets,
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
@ -119,13 +140,15 @@ fun ReadingScreen(
if (readingScreen) {
ActionScreen(
topBannerText = stringResource(R.string.reading_character_title),
detectedTransportMessage = transportMessage,
transferStatusMessage = transferStatus,
) {
onClickCancel = {
Log.d(TAG, "ActionScreen.onCancel: cancelRead + onCancel")
readingScreen = false
scanScreenController.cancelRead()
onCancel()
}
}
)
} else if (cardSelectScreen) {
ChooseCard(
cards = cardsRead!!,

View File

@ -0,0 +1,267 @@
package com.github.nacabaro.vbhelper.screens.scanScreen.screens
import androidx.compose.animation.core.FastOutLinearInEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.animateIntAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Card
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.github.nacabaro.vbhelper.components.TopBanner
import com.github.nacabaro.vbhelper.R
import com.github.nacabaro.vbhelper.utils.ImageBitmapData
import kotlinx.coroutines.delay
@Composable
fun TransferAnimationScreen(
topBannerText: String,
detectedTransportMessage: String?,
transferStatusMessage: String?,
characterPreview: ImageBitmapData? = null,
onClickCancel: () -> Unit,
isTransferring: Boolean = true,
) {
var pulseScale by remember { mutableStateOf(1f) }
var animationProgress by remember { mutableStateOf(0) }
val scale by animateFloatAsState(
targetValue = pulseScale,
animationSpec = tween(durationMillis = 800),
label = "pulse_scale"
)
val progress by animateIntAsState(
targetValue = animationProgress,
animationSpec = tween(durationMillis = 100, easing = FastOutLinearInEasing),
label = "progress"
)
LaunchedEffect(isTransferring) {
if (isTransferring) {
while (true) {
pulseScale = 1.2f
delay(400)
pulseScale = 1f
delay(400)
}
}
}
LaunchedEffect(isTransferring) {
if (isTransferring) {
while (animationProgress < 100) {
delay(50)
animationProgress += 2
}
}
}
Scaffold(
topBar = {
TopBanner(
text = topBannerText,
onBackClick = onClickCancel
)
}
) { innerPadding ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
) {
characterPreview?.let { preview ->
Card(
modifier = Modifier.padding(bottom = 24.dp)
) {
Image(
bitmap = preview.imageBitmap,
contentDescription = "Transferred Digimon sprite",
modifier = Modifier
.size(preview.dpWidth, preview.dpHeight)
.padding(8.dp),
filterQuality = FilterQuality.None
)
}
}
// Animated pulsing circle indicator
Box(
modifier = Modifier
.size(120.dp)
.scale(scale)
.background(
color = Color(0xFF6366F1).copy(alpha = 0.2f),
shape = CircleShape
),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.size(80.dp)
.background(
color = Color(0xFF6366F1),
shape = CircleShape
),
contentAlignment = Alignment.Center
) {
Text(
text = "🔄",
fontSize = 40.sp,
modifier = Modifier.scale(if (scale > 1f) 1.1f else 1f)
)
}
}
Text(
text = stringResource(R.string.action_place_near_reader),
fontSize = 16.sp,
modifier = Modifier.padding(top = 24.dp),
fontWeight = FontWeight.Medium
)
if (!detectedTransportMessage.isNullOrBlank()) {
Text(
text = detectedTransportMessage,
modifier = Modifier.padding(top = 12.dp, start = 16.dp, end = 16.dp),
fontSize = 14.sp
)
}
if (!transferStatusMessage.isNullOrBlank()) {
Text(
text = transferStatusMessage,
modifier = Modifier.padding(top = 12.dp, start = 16.dp, end = 16.dp),
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = Color(0xFF6366F1)
)
}
// Progress ring
if (isTransferring) {
CircularProgressIndicator(
progress = { progress / 100f },
modifier = Modifier
.size(100.dp)
.padding(top = 24.dp),
strokeWidth = 4.dp
)
Text(
text = "$progress%",
modifier = Modifier.padding(top = 8.dp),
fontSize = 12.sp,
color = Color.Gray
)
}
Button(
onClick = onClickCancel,
modifier = Modifier.padding(top = 32.dp, bottom = 16.dp)
) {
Text(stringResource(R.string.action_cancel))
}
}
}
}
@Composable
fun TransferCompleteScreen(
topBannerText: String,
resultMessage: String,
onClickOk: () -> Unit,
isSuccess: Boolean = true,
) {
var showCheckmark by remember { mutableStateOf(false) }
val scale by animateFloatAsState(
targetValue = if (showCheckmark) 1f else 0f,
animationSpec = tween(durationMillis = 600, easing = FastOutLinearInEasing),
label = "checkmark_scale"
)
LaunchedEffect(Unit) {
delay(300)
showCheckmark = true
}
Scaffold(
topBar = {
TopBanner(
text = topBannerText,
onBackClick = {}
)
}
) { innerPadding ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
) {
Box(
modifier = Modifier
.size(120.dp)
.scale(scale)
.background(
color = if (isSuccess) Color(0xFF10B981) else Color(0xFFEF4444),
shape = CircleShape
),
contentAlignment = Alignment.Center
) {
Text(
text = if (isSuccess) "" else "",
fontSize = 60.sp,
color = Color.White,
fontWeight = FontWeight.Bold
)
}
Text(
text = resultMessage,
fontSize = 16.sp,
modifier = Modifier.padding(top = 24.dp, start = 16.dp, end = 16.dp),
fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center
)
Button(
onClick = onClickOk,
modifier = Modifier.padding(top = 32.dp, bottom = 16.dp)
) {
Text("OK")
}
}
}
}

View File

@ -7,15 +7,21 @@ 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
@ -31,9 +37,39 @@ fun WritingScreen(
) {
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) }
@ -44,6 +80,9 @@ fun WritingScreen(
DisposableEffect(writing) {
if (writing) {
val transferHaptics = TransferHaptics(application)
transferHaptics.onTransferStart()
scanScreenController.registerActivityLifecycleListener(
SCAN_SCREEN_ACTIVITY_LIFECYCLE_LISTENER,
object : ActivityLifecycleListener {
@ -55,11 +94,13 @@ fun WritingScreen(
if (!isDoneSendingCard) {
scanScreenController.onClickCheckCard(secrets!!, nfcCharacter) {
isDoneSendingCard = true
transferHaptics.onTransferProgress()
}
} else if (!isDoneWritingCharacter) {
scanScreenController.onClickWrite(secrets!!, nfcCharacter) { result ->
scanScreenController.onClickWrite(secrets!!, nfcCharacter, characterId) { result ->
writeResult = result
isDoneWritingCharacter = true
transferHaptics.onTransferProgress()
}
}
}
@ -70,11 +111,13 @@ fun WritingScreen(
if (!isDoneSendingCard) {
scanScreenController.onClickCheckCard(secrets!!, nfcCharacter) {
isDoneSendingCard = true
transferHaptics.onTransferProgress()
}
} else if (!isDoneWritingCharacter) {
scanScreenController.onClickWrite(secrets!!, nfcCharacter) { result ->
scanScreenController.onClickWrite(secrets!!, nfcCharacter, characterId) { result ->
writeResult = result
isDoneWritingCharacter = true
transferHaptics.onTransferProgress()
}
}
}
@ -106,11 +149,14 @@ fun WritingScreen(
writing = true
ActionScreen(
topBannerText = stringResource(R.string.sending_card_title),
detectedTransportMessage = transportMessage,
transferStatusMessage = transferStatus,
) {
characterPreview = characterPreview,
onClickCancel = {
scanScreenController.cancelRead()
onCancel()
}
}
)
} else if (!writingConfirmScreen) {
writing = false
WriteCharacterScreen (
@ -127,12 +173,15 @@ fun WritingScreen(
writing = true
ActionScreen(
topBannerText = stringResource(R.string.writing_character_action_title),
detectedTransportMessage = transportMessage,
transferStatusMessage = transferStatus,
) {
characterPreview = characterPreview,
onClickCancel = {
isDoneSendingCard = false
scanScreenController.cancelRead()
onCancel()
}
}
)
}
var completedWriting by remember { mutableStateOf(false) }
@ -140,6 +189,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)
}

View File

@ -5,6 +5,7 @@ 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
@ -12,8 +13,12 @@ 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
@ -67,6 +72,7 @@ fun SettingsScreen(
settingsScreenController.onClickImportCard()
}
SettingsSection(title = stringResource(R.string.settings_section_about))
SettingsEntry(
title = stringResource(R.string.settings_credits_title),
@ -98,6 +104,38 @@ 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) }
)
}
}
}
@ -123,6 +161,40 @@ 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,4 +5,8 @@ interface SettingsScreenController {
fun onClickImportDatabase()
fun onClickImportApk()
fun onClickImportCard()
fun onClickCompanionImportCardImage()
fun onClickCompanionImportFirmware()
fun onClickCompanionSendWatchLogs()
fun onClickCompanionSendPhoneLogs()
}

View File

@ -1,5 +1,6 @@
package com.github.nacabaro.vbhelper.screens.settingsScreen
import android.content.Intent
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import android.net.Uri
@ -7,8 +8,13 @@ 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
@ -16,6 +22,7 @@ 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(
@ -29,11 +36,14 @@ 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")
@ -101,13 +111,41 @@ 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 cardImportController = CardImportController(database)
val dimToBemEnabled = cardSettingsRepository.enableDimToBemConversion.first()
val cardImportController = CardImportController(database, dimToBemEnabled)
cardImportController.importCard(fileReader)
}

View File

@ -13,7 +13,8 @@ import com.github.nacabaro.vbhelper.domain.characters.Sprite
import java.io.InputStream
class CardImportController(
private val database: AppDatabase
private val database: AppDatabase,
private val enableDimToBemConversion: Boolean = false
) {
suspend fun importCard(
fileReader: InputStream?
@ -117,6 +118,16 @@ 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(
@ -126,9 +137,9 @@ class CardImportController(
nameSprite = card.spriteData.sprites[spriteCounter].pixelData,
stage = characters[index].stage,
attribute = NfcCharacter.Attribute.entries[characters[index].attribute],
baseHp = characters[index].hp,
baseBp = characters[index].dp,
baseAp = characters[index].ap,
baseHp = baseHp,
baseBp = baseBp,
baseAp = baseAp,
nameWidth = card.spriteData.sprites[spriteCounter].spriteDimensions.width,
nameHeight = card.spriteData.sprites[spriteCounter].spriteDimensions.height
)
@ -150,6 +161,40 @@ 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,11 +1,14 @@
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
@ -18,6 +21,19 @@ 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) {
@ -59,6 +75,19 @@ 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)
@ -92,6 +121,118 @@ 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,12 +44,18 @@ 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

@ -0,0 +1,28 @@
package com.github.nacabaro.vbhelper.source
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class CardSettingsRepository(
private val dataStore: DataStore<Preferences>
) {
private companion object {
val ENABLE_DIM_TO_BEM_CONVERSION = booleanPreferencesKey("enable_dim_to_bem_conversion")
}
val enableDimToBemConversion: Flow<Boolean> = dataStore.data
.map { preferences ->
preferences[ENABLE_DIM_TO_BEM_CONVERSION] ?: false
}
suspend fun setEnableDimToBemConversion(enable: Boolean) {
dataStore.edit { preferences ->
preferences[ENABLE_DIM_TO_BEM_CONVERSION] = enable
}
}
}

View File

@ -0,0 +1,54 @@
package com.github.nacabaro.vbhelper.source
import com.github.nacabaro.vbhelper.database.AppDatabase
import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData
import com.github.nacabaro.vbhelper.utils.DeviceType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Utility for converting character device types.
* Used to fix mismatched device types when sending to physical NFC devices.
*/
class DeviceTypeConverter(private val database: AppDatabase) {
/**
* Prepares a BE character for VB-target exports without deleting BE stats.
* This keeps the BE profile intact while ensuring a VB sidecar exists.
*/
suspend fun convertBEToVB(characterId: Long): Boolean = withContext(Dispatchers.IO) {
try {
val userCharacter = database.userCharacterDao().getCharacter(characterId)
if (userCharacter.characterType != DeviceType.BEDevice) {
// Already VB or other type, no conversion needed
return@withContext true
}
// Keep BE typing/stats as-is; only ensure a VB sidecar profile exists for exports.
ensureVBCharacterDataExists(characterId)
true
} catch (e: Exception) {
// Log but don't crash - character can still be sent as-is
android.util.Log.e("DeviceTypeConverter", "Failed to convert character $characterId to VB type", e)
false
}
}
private suspend fun ensureVBCharacterDataExists(characterId: Long) {
val vbData = database.userCharacterDao().getVbDataOrNull(characterId)
if (vbData == null) {
val vbCharacterData = VBCharacterData(
id = characterId,
generation = 0,
totalTrophies = 0
)
database.userCharacterDao().insertVBCharacterData(vbCharacterData)
}
}
}

View File

@ -3,11 +3,8 @@ package com.github.nacabaro.vbhelper.source
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.transfer.CharacterTransferPolicyResolver
import com.github.nacabaro.vbhelper.transfer.TransferTarget
import com.github.nacabaro.vbhelper.transfer.TransferTransport
import com.github.nacabaro.vbhelper.transfer.toTransferDeviceType
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
@ -34,15 +31,25 @@ internal fun resolveTrainingSeconds(
class VitalWearCharacterExporter(
private val database: AppDatabase
) {
private val transferPolicyResolver = CharacterTransferPolicyResolver(database.characterTransferPolicyDao())
/**
* 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): Character {
suspend fun buildCharacterProto(
characterId: Long,
forcedTransferProfile: DeviceType? = null,
): Character {
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)
@ -50,17 +57,25 @@ class VitalWearCharacterExporter(
val cardProgress = database.cardProgressDao().getCardProgressSync(card.id) ?: 0
val beData = database.userCharacterDao().getBeDataOrNull(characterId)
val vbData = database.userCharacterDao().getVbDataOrNull(characterId)
val exportFormat = transferPolicyResolver.resolveExportFormat(
characterId = characterId,
characterType = userCharacter.characterType,
transport = TransferTransport.HCE,
target = TransferTarget.VITAL_WEAR,
isBemCard = card.isBEm,
)
val normalizedTransformationCountdownMinutes = normalizeTransformationCountdownMinutes(
transformationCountdownMinutes = userCharacter.transformationCountdown,
hasPossibleTransformations = hasPossibleTransformations(userCharacter.charId),
)
// Use forced profile if specified (e.g., BE for HCE route), otherwise use stored type.
// For HCE/VitalWear routes, forcedTransferProfile = BE ensures BE stats are serialized.
val transferDeviceType = forcedTransferProfile ?: userCharacter.characterType
val settingsBuilder = Character.Settings.newBuilder()
.setTrainingInBackground(vwSettings?.trainingInBackground ?: false)
.setAllowedBattles(resolveAllowedBattles(vwSettings?.allowedBattles))
// VitalWear chooses DIM-vs-BE runtime path from card type + assumedFranchise.
// For forced BE HCE exports of DIM cards, provide a stable assumed franchise hint.
if (forcedTransferProfile == DeviceType.BEDevice && !card.isBEm) {
settingsBuilder.setAssumedFranchise(0)
}
return Character.newBuilder()
.setCardId(card.cardId)
.setCardName(card.name)
@ -68,7 +83,7 @@ class VitalWearCharacterExporter(
Character.CharacterStats.newBuilder()
.setSlotId(characterInfo.charId)
.setVitals(characterWithSprites.vitalPoints)
.setTrainingTimeRemainingInSeconds(resolveTrainingSeconds(userCharacter.characterType, beData))
.setTrainingTimeRemainingInSeconds(resolveTrainingSeconds(transferDeviceType, beData))
.setTimeUntilNextTransformation(normalizedTransformationCountdownMinutes.toLong() * 60L)
.setTrainedBp(resolveTrainedBp(beData))
.setTrainedHp(resolveTrainedHp(beData))
@ -81,7 +96,7 @@ class VitalWearCharacterExporter(
.setTotalWins(characterWithSprites.totalBattlesWon)
.setCurrentPhaseWins(characterWithSprites.currentPhaseBattlesWon)
.setMood(characterWithSprites.mood)
.setDeviceType(exportFormat.toTransferDeviceType())
.setDeviceType(transferDeviceType.toTransferDeviceType())
.setAgeInDays(userCharacter.ageInDays.coerceAtLeast(0))
.setActivityLevel(userCharacter.activityLevel.coerceAtLeast(0))
.setHeartRateCurrent(userCharacter.heartRateCurrent.coerceAtLeast(0))
@ -106,12 +121,7 @@ class VitalWearCharacterExporter(
.setFirmwareMajorVersion(beData?.majorVersion ?: 0)
.build()
)
.setSettings(
Character.Settings.newBuilder()
.setTrainingInBackground(vwSettings?.trainingInBackground ?: false)
.setAllowedBattles(resolveAllowedBattles(vwSettings?.allowedBattles))
.build()
)
.setSettings(settingsBuilder.build())
.putMaxAdventureCompletedByCard(card.name, (cardProgress - 1).coerceAtLeast(0))
.addAllTransformationHistory(
database.userCharacterDao().getTransformationHistoryForExport(characterId).map {
@ -122,6 +132,43 @@ 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()
}

View File

@ -5,12 +5,13 @@ 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.transfer.CharacterTransferPolicyResolver
import com.github.nacabaro.vbhelper.transfer.ExportFormat
import com.github.nacabaro.vbhelper.domain.characters.Sprite
import com.github.nacabaro.vbhelper.utils.DeviceType
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.runBlocking
@ -20,8 +21,6 @@ class VitalWearCharacterImporter(
private val database: AppDatabase,
private val transferSeenDao: SharedTransferSeenDao,
) {
private val transferPolicyResolver = CharacterTransferPolicyResolver(database.characterTransferPolicyDao())
data class ImportResult(
val success: Boolean,
val message: String
@ -29,15 +28,16 @@ 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 = runCatching {
database.characterDao().getCharacterByMonIndex(slotId, importedCard.id)
}.getOrNull() ?: return ImportResult(
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}."
)
@ -62,11 +62,6 @@ class VitalWearCharacterImporter(
fallbackIsBeCharacter = fallbackIsBeCharacter,
)
val isBeCharacter = deviceType == DeviceType.BEDevice
val observedImportFormat = resolveObservedHceImportFormat(
transferDeviceType = character.characterStats.deviceType,
importedCard = importedCard,
hasBePayloadStats = hasBePayloadStats,
)
val userCharacterId = database.userCharacterDao().insertCharacterData(
UserCharacter(
@ -100,6 +95,21 @@ class VitalWearCharacterImporter(
)
}
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(
@ -129,31 +139,179 @@ class VitalWearCharacterImporter(
majorVersion = character.characterStats.firmwareMajorVersion
)
)
} else {
database.userCharacterDao().insertVBCharacterData(
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)
}
}
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) {
// 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 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."
)
}
/**
* 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,
)
)
}
runBlocking {
database.characterTransferPolicyDao().upsert(
transferPolicyResolver.policyForHceImport(
characterId = userCharacterId,
importedCardIsBem = importedCard.isBEm,
resolvedDeviceType = deviceType,
observedFormat = observedImportFormat,
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
)
)
}
@ -182,7 +340,6 @@ class VitalWearCharacterImporter(
}
if (insertedTransformationCount == 0) {
// Keep HomeScreen renderable for freshly imported characters with empty history.
database.userCharacterDao().insertTransformation(
userCharacterId,
slotId,
@ -252,6 +409,131 @@ class VitalWearCharacterImporter(
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
@ -290,23 +572,6 @@ class VitalWearCharacterImporter(
return rarities.getOrElse(rawValue) { resolveDefaultAbilityRarity() }
}
private fun resolveObservedHceImportFormat(
transferDeviceType: Character.CharacterStats.TransferDeviceType,
importedCard: Card,
hasBePayloadStats: Boolean,
): ExportFormat {
return when (transferDeviceType) {
Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_BE -> ExportFormat.BE
Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_VB -> ExportFormat.VB
Character.CharacterStats.TransferDeviceType.UNRECOGNIZED,
Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_UNSPECIFIED -> if (importedCard.isBEm || hasBePayloadStats) {
ExportFormat.BE
} else {
ExportFormat.VB
}
}
}
private fun markSeen(cardName: String, slotId: Int, cardId: Long, timestamp: Long) {
database.dexDao().insertCharacter(slotId, cardId, timestamp)
transferSeenDao.markSeen(cardName, slotId, timestamp)

View File

@ -2,6 +2,8 @@
import android.nfc.tech.IsoDep
import android.util.Log
import com.github.cfogrady.vitalwear.protos.Character
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
/**
* Phone-side ISO-DEP client that drives the VitalWear HCE session.
* Mirrors the APDU protocol defined in VitalWearHceProtocol on the watch.
@ -20,10 +22,17 @@ class VitalWearHceReaderClient(private val isoDep: IsoDep) {
private val INS_READ_CHUNK: Byte = 0x20
private val INS_WRITE_CHUNK: Byte = 0x30
private val INS_COMMIT: Byte = 0x40
private val INS_STATUS: Byte = 0x50
private val INS_SYNC_UI: Byte = 0x60
private val INS_VIBRATE: Byte = 0x70
private val MODE_WATCH_TO_PHONE: Byte = 0x01
private val MODE_PHONE_TO_WATCH: Byte = 0x02
private val VERSION: Byte = 0x01
private val SW_OK = 0x9000
private val STATUS_SUCCESS: Byte = 0x04
private val STATUS_FAILURE: Byte = 0x05
private val STATUS_SYNCING: Byte = 0x03
/** MOVE READ: read character bytes first, let caller import/validate, then COMMIT only on success.
* If [onCharacterRead] returns false, COMMIT is skipped so source can remain on the watch.
*/
@ -67,8 +76,16 @@ class VitalWearHceReaderClient(private val isoDep: IsoDep) {
}
/** WRITE: watch has called armReceive() — phone pushes a character to the watch. */
fun sendCharacterToWatch(character: Character) {
fun sendCharacterToWatch(character: Character, closeSyncUiAfterCommit: Boolean = true) {
selectAid()
// --- True to Toy Ceremony ---
// 1. Haptic Lock-on
sendApdu(INS_VIBRATE, byteArrayOf())
// 2. Visual Sync (Start Beam)
sendApdu(INS_SYNC_UI, byteArrayOf(0x01))
val payload = character.toByteArray()
val negResponse = sendApdu(INS_NEGOTIATE, byteArrayOf(MODE_PHONE_TO_WATCH, VERSION))
requireOk(negResponse, "NEGOTIATE")
@ -82,6 +99,40 @@ class VitalWearHceReaderClient(private val isoDep: IsoDep) {
offset = chunkEnd
}
requireOk(sendApdu(INS_COMMIT, byteArrayOf()), "COMMIT")
// 3. Visual Sync (End Beam)
if (closeSyncUiAfterCommit) {
sendApdu(INS_SYNC_UI, byteArrayOf(0x00))
}
}
/**
* WRITE + confirmation polling.
* Returns true only when the watch reports import success.
*/
fun sendCharacterToWatchAndConfirm(character: Character): Boolean {
sendCharacterToWatch(character, closeSyncUiAfterCommit = false)
val confirmed = runBlocking {
repeat(12) {
val statusResponse = sendApdu(INS_STATUS, byteArrayOf())
requireOk(statusResponse, "STATUS")
val statusByte = statusResponse.dropLast(2).firstOrNull()
Log.d("HCE_CLIENT", "STATUS poll[$it]: statusByte=${statusByte?.toInt()?.and(0xFF)}")
if (statusByte == STATUS_SUCCESS) {
return@runBlocking true
}
if (statusByte == STATUS_FAILURE) {
return@runBlocking false
}
delay(250)
}
false
}
// End the sync UI after we have consumed terminal status.
runCatching {
sendApdu(INS_SYNC_UI, byteArrayOf(0x00))
}
return confirmed
}
/**

View File

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

View File

@ -7,6 +7,41 @@ 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;
@ -77,4 +112,8 @@ 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

@ -178,7 +178,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

@ -68,14 +68,17 @@
<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">DiM/BEm management</string>
<string name="settings_section_dim_bem">VBH-VW card 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">
@ -84,7 +87,12 @@
<string name="settings_import_card_title">Import card</string>
<string name="settings_import_card_desc">
Import DiM/BEm
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
</string>
<string name="settings_credits_title">Credits</string>
@ -103,6 +111,27 @@
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>
@ -187,7 +216,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,5 +1,8 @@
<?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

@ -0,0 +1,25 @@
package com.github.nacabaro.vbhelper.screens.scanScreen.converters
import com.github.nacabaro.vbhelper.utils.DeviceType
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ToNfcConverterProfileSelectionTest {
@Test
fun shouldEncodeAsBem_usesStoredTypeWhenForcedProfileMissing() {
assertTrue(ToNfcConverter.shouldEncodeAsBem(null, DeviceType.BEDevice))
assertFalse(ToNfcConverter.shouldEncodeAsBem(null, DeviceType.VBDevice))
}
@Test
fun shouldEncodeAsBem_forcedVBWinsOverStoredBE() {
assertFalse(ToNfcConverter.shouldEncodeAsBem(DeviceType.VBDevice, DeviceType.BEDevice))
}
@Test
fun shouldEncodeAsBem_forcedBEWinsOverStoredVB() {
assertTrue(ToNfcConverter.shouldEncodeAsBem(DeviceType.BEDevice, DeviceType.VBDevice))
}
}

View File

@ -17,6 +17,12 @@ 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"
[libraries]
androidx-compose-material = { module = "androidx.compose.material:material" }
@ -46,6 +52,13 @@ 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,17 +17,13 @@ dependencyResolutionManagement {
mavenLocal()
google()
mavenCentral()
maven { url = uri("https://jitpack.io") }
}
}
rootProject.name = "VBHelper"
include(":app")
includeBuild("../VB-DIM-Reader-2.0.0") {
dependencySubstitution {
substitute(module("com.github.cfogrady:vb-dim-reader")).using(project(":"))
}
}
includeBuild("../vb-nfc-reader") {
dependencySubstitution {