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 { dependencies {
implementation(libs.androidx.room.runtime) implementation(libs.androidx.room.runtime)
implementation(libs.vb.nfc.reader) implementation(libs.vb.nfc.reader)
implementation(libs.dim.reader) // Temporarily commented out due to Lombok compilation issues in VB-DIM-Reader
implementation(libs.androidx.core.ktx) // 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.room.ktx)
implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.navigation.compose) implementation(libs.androidx.navigation.compose)
@ -90,6 +94,13 @@ dependencies {
implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3) implementation(libs.androidx.material3)
implementation(libs.androidx.compose.material.icons.extended) implementation(libs.androidx.compose.material.icons.extended)
implementation(libs.play.services.wearable)
implementation(libs.kotlinx.coroutines.play.services)
implementation(libs.timber)
implementation(libs.tiny.log)
implementation(libs.tiny.log.impl)
implementation(libs.slf4j.api)
runtimeOnly(libs.slf4j.timber)
debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest) debugImplementation(libs.androidx.ui.test.manifest)
@ -118,4 +129,7 @@ dependencies {
// HTTP request logging // HTTP request logging
implementation("com.squareup.okhttp3:logging-interceptor:4.11.0") implementation("com.squareup.okhttp3:logging-interceptor:4.11.0")
// In-app browser for OAuth login
implementation("androidx.browser:browser:1.8.0")
} }

View File

@ -11,6 +11,7 @@
<uses-permission android:name="android.permission.NFC" /> <uses-permission android:name="android.permission.NFC" />
<uses-feature android:name="android.hardware.nfc" android:required="true" /> <uses-feature android:name="android.hardware.nfc" android:required="true" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
@ -50,11 +51,17 @@
android:exported="true" android:exported="true"
android:readPermission="com.github.nacabaro.vbhelper.permission.ACCESS_TRANSFER_SEEN" android:readPermission="com.github.nacabaro.vbhelper.permission.ACCESS_TRANSFER_SEEN"
android:writePermission="com.github.nacabaro.vbhelper.permission.ACCESS_TRANSFER_SEEN" /> android:writePermission="com.github.nacabaro.vbhelper.permission.ACCESS_TRANSFER_SEEN" />
<activity android:name=".companion.card.CompanionImportCardActivity" />
<activity android:name=".companion.card.CompanionValidateCardActivity" />
<activity android:name=".companion.firmware.CompanionFirmwareImportActivity" />
<activity android:name=".companion.logs.CompanionWatchLogsActivity" />
<activity
android:name=".companion.transfer.CompanionCharacterImportActivity"
android:exported="false" />
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:label="@string/app_name" android:label="@string/app_name"
android:launchMode="singleTask"
android:theme="@style/Theme.VBHelper"> android:theme="@style/Theme.VBHelper">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <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" /> <data android:scheme="http" android:host="127.0.0.1" android:port="8080" android:pathPrefix="/authenticate" />
</intent-filter> </intent-filter>
</activity> </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> </application>
</manifest> </manifest>

View File

@ -7,9 +7,13 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.github.nacabaro.vbhelper.navigation.AppNavigation import com.github.nacabaro.vbhelper.navigation.AppNavigation
import com.github.nacabaro.vbhelper.di.VBHelper import com.github.nacabaro.vbhelper.di.VBHelper
import com.github.nacabaro.vbhelper.navigation.AppNavigationHandlers import com.github.nacabaro.vbhelper.navigation.AppNavigationHandlers
import com.github.nacabaro.vbhelper.navigation.NavigationItems
import com.github.nacabaro.vbhelper.screens.homeScreens.HomeScreenControllerImpl import com.github.nacabaro.vbhelper.screens.homeScreens.HomeScreenControllerImpl
import com.github.nacabaro.vbhelper.screens.itemsScreen.ItemsScreenControllerImpl import com.github.nacabaro.vbhelper.screens.itemsScreen.ItemsScreenControllerImpl
import com.github.nacabaro.vbhelper.screens.scanScreen.ScanScreenControllerImpl import com.github.nacabaro.vbhelper.screens.scanScreen.ScanScreenControllerImpl
@ -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.spriteViewer.SpriteViewerControllerImpl
import com.github.nacabaro.vbhelper.screens.storageScreen.StorageScreenControllerImpl import com.github.nacabaro.vbhelper.screens.storageScreen.StorageScreenControllerImpl
import com.github.nacabaro.vbhelper.ui.theme.VBHelperTheme import com.github.nacabaro.vbhelper.ui.theme.VBHelperTheme
import kotlinx.coroutines.flow.MutableSharedFlow import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch
import kotlinx.coroutines.Dispatchers
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val onActivityLifecycleListeners = HashMap<String, ActivityLifecycleListener>() private val onActivityLifecycleListeners = HashMap<String, ActivityLifecycleListener>()
private var initialRoute: String? = null private var initialRoute: String? by mutableStateOf(null)
private val deepLinkNavigationEvents = MutableSharedFlow<String>(extraBufferCapacity = 1)
private fun registerActivityLifecycleListener(key: String, activityLifecycleListener: ActivityLifecycleListener) { private fun registerActivityLifecycleListener(key: String, activityLifecycleListener: ActivityLifecycleListener) {
if( onActivityLifecycleListeners[key] != null) { if( onActivityLifecycleListeners[key] != null) {
@ -60,7 +64,6 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge() enableEdgeToEdge()
initialRoute = getInitialRouteFromIntent(intent) initialRoute = getInitialRouteFromIntent(intent)
initialRoute?.let { deepLinkNavigationEvents.tryEmit(it) }
setContent { setContent {
VBHelperTheme { VBHelperTheme {
@ -73,8 +76,7 @@ class MainActivity : ComponentActivity() {
storageScreenController = storageScreenController, storageScreenController = storageScreenController,
spriteViewerController = spriteViewerController, spriteViewerController = spriteViewerController,
cardScreenController = cardScreenController, cardScreenController = cardScreenController,
initialRoute = initialRoute, initialRoute = initialRoute
deepLinkNavigationEvents = deepLinkNavigationEvents.asSharedFlow(),
) )
} }
} }
@ -102,15 +104,35 @@ class MainActivity : ComponentActivity() {
super.onNewIntent(intent) super.onNewIntent(intent)
setIntent(intent) setIntent(intent)
initialRoute = getInitialRouteFromIntent(intent) initialRoute = getInitialRouteFromIntent(intent)
initialRoute?.let { deepLinkNavigationEvents.tryEmit(it) }
} }
private fun getInitialRouteFromIntent(intent: Intent?): String? { private fun getInitialRouteFromIntent(intent: Intent?): String? {
if (intent == null) return null if (intent == null) return null
val data = intent.data val data = intent.data
if (intent.action == Intent.ACTION_VIEW && data != null) { if (intent.action == Intent.ACTION_VIEW && data != null) {
if (data.scheme == "vbhelper" && data.host == "auth") { val isAppAuthCallback = data.scheme == "vbhelper" && data.host == "auth"
return "Battle" 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 return null
@ -126,8 +148,7 @@ class MainActivity : ComponentActivity() {
homeScreenController: HomeScreenControllerImpl, homeScreenController: HomeScreenControllerImpl,
spriteViewerController: SpriteViewerControllerImpl, spriteViewerController: SpriteViewerControllerImpl,
cardScreenController: CardScreenControllerImpl, cardScreenController: CardScreenControllerImpl,
initialRoute: String? = null, initialRoute: String? = null
deepLinkNavigationEvents: kotlinx.coroutines.flow.Flow<String>,
) { ) {
AppNavigation( AppNavigation(
applicationNavigationHandlers = AppNavigationHandlers( applicationNavigationHandlers = AppNavigationHandlers(
@ -140,8 +161,7 @@ class MainActivity : ComponentActivity() {
spriteViewerController, spriteViewerController,
cardScreenController cardScreenController
), ),
initialRoute = initialRoute, initialRoute = initialRoute
navigationEvents = deepLinkNavigationEvents,
) )
} }
} }

View File

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

View File

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

View File

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

View File

@ -1,21 +1,46 @@
package com.github.nacabaro.vbhelper.battle package com.github.nacabaro.vbhelper.battle
import com.google.gson.annotations.SerializedName
data class AdditionalInfo( data class AdditionalInfo(
@SerializedName(value = "avatar", alternate = ["avatar_url"])
val avatar: String? = null, val avatar: String? = null,
@SerializedName(value = "id", alternate = ["user_id"])
val id: Long? = null, val id: Long? = null,
@SerializedName(value = "name", alternate = ["username", "display_name"])
val name: String? = null, val name: String? = null,
val status: String? = null val status: String? = null
) )
data class UserInfo( data class UserInfo(
@SerializedName(value = "userId", alternate = ["user_id", "id"])
val userId: String? = null, val userId: String? = null,
@SerializedName(value = "additionalInfo", alternate = ["additional_info"])
val additionalInfo: AdditionalInfo? = null val additionalInfo: AdditionalInfo? = null
) )
data class AuthenticateResponse( data class AuthenticateResponse(
@SerializedName(value = "success", alternate = ["ok"])
val success: Boolean, val success: Boolean,
val message: String? = null, val message: String? = null,
@SerializedName(value = "userInfo", alternate = ["user_info", "user"])
val userInfo: UserInfo? = null, val userInfo: UserInfo? = null,
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.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import android.content.Context
import java.io.File
enum class DigimonAnimationType { enum class DigimonAnimationType {
IDLE, IDLE,
IDLE2, IDLE2,
WALK, WALK,
WALK2,
RUN,
RUN2,
WORKOUT,
WORKOUT2,
HAPPY,
SLEEP, SLEEP,
ATTACK, ATTACK
FLEE
} }
data class AnimationState( data class AnimationState(
@ -31,7 +22,6 @@ data class AnimationState(
class DigimonAnimationStateMachine( class DigimonAnimationStateMachine(
private val characterId: String, private val characterId: String,
private val context: Context,
private val initialFrameOffset: Int = 0, // New parameter for offsetting the starting frame private val initialFrameOffset: Int = 0, // New parameter for offsetting the starting frame
private val timingOffset: Long = 0L // New parameter for offsetting the timing private val timingOffset: Long = 0L // New parameter for offsetting the timing
) { ) {
@ -44,21 +34,13 @@ class DigimonAnimationStateMachine(
var isPlaying by mutableStateOf(false) var isPlaying by mutableStateOf(false)
private set private set
// Direct mapping of frame numbers (1-12) to animation types // Direct mapping of frame numbers used by current battle animations.
// This is based on the standard Digimon sprite frame order
private val frameToAnimationType = mapOf( private val frameToAnimationType = mapOf(
1 to DigimonAnimationType.IDLE, 1 to DigimonAnimationType.IDLE,
2 to DigimonAnimationType.IDLE2, 2 to DigimonAnimationType.IDLE2,
3 to DigimonAnimationType.WALK, 3 to DigimonAnimationType.WALK,
4 to DigimonAnimationType.WALK2,
5 to DigimonAnimationType.RUN,
6 to DigimonAnimationType.RUN2,
7 to DigimonAnimationType.WORKOUT,
8 to DigimonAnimationType.WORKOUT2,
9 to DigimonAnimationType.HAPPY,
10 to DigimonAnimationType.SLEEP, 10 to DigimonAnimationType.SLEEP,
11 to DigimonAnimationType.ATTACK, 11 to DigimonAnimationType.ATTACK
12 to DigimonAnimationType.FLEE
) )
// Reverse mapping for getting frame numbers for each animation type // Reverse mapping for getting frame numbers for each animation type
@ -69,15 +51,8 @@ class DigimonAnimationStateMachine(
DigimonAnimationType.IDLE to 750L, DigimonAnimationType.IDLE to 750L,
DigimonAnimationType.IDLE2 to 750L, DigimonAnimationType.IDLE2 to 750L,
DigimonAnimationType.WALK to 200L, DigimonAnimationType.WALK to 200L,
DigimonAnimationType.WALK2 to 200L,
DigimonAnimationType.RUN to 150L,
DigimonAnimationType.RUN2 to 150L,
DigimonAnimationType.WORKOUT to 300L,
DigimonAnimationType.WORKOUT2 to 300L,
DigimonAnimationType.HAPPY to 400L,
DigimonAnimationType.SLEEP to 1500L, DigimonAnimationType.SLEEP to 1500L,
DigimonAnimationType.ATTACK to 650L, DigimonAnimationType.ATTACK to 650L
DigimonAnimationType.FLEE to 150L
) )
/* /*
@ -155,13 +130,4 @@ class DigimonAnimationStateMachine(
return currentFrameNumber return currentFrameNumber
} }
fun getCurrentCharacterId(): String {
return characterId
}
// Method to reload mappings (useful for testing)
fun reloadMappings() {
println("Reloading mappings for character: $characterId")
// No need to reload since we use direct frame mapping
}
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,6 +4,7 @@ import androidx.room.Dao
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import androidx.room.RewriteQueriesToDropUnusedColumns
import androidx.room.Upsert import androidx.room.Upsert
import com.github.nacabaro.vbhelper.domain.card.CardCharacter import com.github.nacabaro.vbhelper.domain.card.CardCharacter
import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter
@ -38,6 +39,7 @@ interface UserCharacterDao {
@Upsert @Upsert
fun insertSpecialMissions(vararg specialMissions: SpecialMissions) fun insertSpecialMissions(vararg specialMissions: SpecialMissions)
@RewriteQueriesToDropUnusedColumns
@Query( @Query(
""" """
SELECT SELECT
@ -55,6 +57,7 @@ interface UserCharacterDao {
) )
fun getTransformationHistory(monId: Long): Flow<List<CharacterDtos.TransformationHistory>> fun getTransformationHistory(monId: Long): Flow<List<CharacterDtos.TransformationHistory>>
@RewriteQueriesToDropUnusedColumns
@Query( @Query(
""" """
SELECT SELECT
@ -71,6 +74,7 @@ interface UserCharacterDao {
) )
suspend fun getTransformationHistoryForExport(monId: Long): List<CharacterDtos.TransformationHistoryExport> suspend fun getTransformationHistoryForExport(monId: Long): List<CharacterDtos.TransformationHistoryExport>
@RewriteQueriesToDropUnusedColumns
@Query( @Query(
""" """
SELECT SELECT
@ -96,6 +100,7 @@ interface UserCharacterDao {
) )
fun getAllCharacters(): Flow<List<CharacterDtos.CharacterWithSprites>> fun getAllCharacters(): Flow<List<CharacterDtos.CharacterWithSprites>>
@RewriteQueriesToDropUnusedColumns
@Query( @Query(
""" """
SELECT SELECT
@ -137,9 +142,13 @@ interface UserCharacterDao {
@Query("SELECT * FROM VBCharacterData WHERE id = :id") @Query("SELECT * FROM VBCharacterData WHERE id = :id")
suspend fun getVbDataOrNull(id: Long): VBCharacterData? suspend fun getVbDataOrNull(id: Long): VBCharacterData?
@Query("DELETE FROM BECharacterData WHERE id = :id")
suspend fun deleteBECharacterData(id: Long)
@Query("SELECT * FROM SpecialMissions WHERE characterId = :id") @Query("SELECT * FROM SpecialMissions WHERE characterId = :id")
fun getSpecialMissions(id: Long): Flow<List<SpecialMissions>> fun getSpecialMissions(id: Long): Flow<List<SpecialMissions>>
@RewriteQueriesToDropUnusedColumns
@Query( @Query(
""" """
SELECT SELECT
@ -205,6 +214,7 @@ interface UserCharacterDao {
@Query("""SELECT * FROM VitalsHistory WHERE charId = :charId ORDER BY id ASC""") @Query("""SELECT * FROM VitalsHistory WHERE charId = :charId ORDER BY id ASC""")
suspend fun getVitalsHistory(charId: Long): List<VitalsHistory> suspend fun getVitalsHistory(charId: Long): List<VitalsHistory>
@RewriteQueriesToDropUnusedColumns
@Query( @Query(
""" """
SELECT SELECT
@ -231,6 +241,7 @@ interface UserCharacterDao {
) )
suspend fun getBECharacters(): List<CharacterDtos.CharacterWithSprites> suspend fun getBECharacters(): List<CharacterDtos.CharacterWithSprites>
@RewriteQueriesToDropUnusedColumns
@Query( @Query(
""" """
SELECT 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.AdventureDao
import com.github.nacabaro.vbhelper.daos.CardAdventureDao import com.github.nacabaro.vbhelper.daos.CardAdventureDao
import com.github.nacabaro.vbhelper.daos.CharacterDao import com.github.nacabaro.vbhelper.daos.CharacterDao
import com.github.nacabaro.vbhelper.daos.CharacterTransferPolicyDao
import com.github.nacabaro.vbhelper.daos.DexDao import com.github.nacabaro.vbhelper.daos.DexDao
import com.github.nacabaro.vbhelper.daos.CardDao import com.github.nacabaro.vbhelper.daos.CardDao
import com.github.nacabaro.vbhelper.daos.CardFusionsDao 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.Adventure
import com.github.nacabaro.vbhelper.domain.characters.Dex 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.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.SpecialMissions
import com.github.nacabaro.vbhelper.domain.device_data.TransformationHistory import com.github.nacabaro.vbhelper.domain.device_data.TransformationHistory
import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter
@ -39,6 +37,7 @@ import com.github.nacabaro.vbhelper.domain.items.Items
@Database( @Database(
version = 6, version = 6,
exportSchema = false,
entities = [ entities = [
Card::class, Card::class,
CardProgress::class, CardProgress::class,
@ -52,7 +51,6 @@ import com.github.nacabaro.vbhelper.domain.items.Items
SpecialMissions::class, SpecialMissions::class,
TransformationHistory::class, TransformationHistory::class,
VitalsHistory::class, VitalsHistory::class,
CharacterTransferPolicy::class,
Dex::class, Dex::class,
Items::class, Items::class,
Adventure::class, Adventure::class,
@ -74,7 +72,6 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun cardAdventureDao(): CardAdventureDao abstract fun cardAdventureDao(): CardAdventureDao
abstract fun cardFusionsDao(): CardFusionsDao abstract fun cardFusionsDao(): CardFusionsDao
abstract fun vitalWearSettingsDao(): VitalWearSettingsDao abstract fun vitalWearSettingsDao(): VitalWearSettingsDao
abstract fun characterTransferPolicyDao(): CharacterTransferPolicyDao
companion object { companion object {
val MIGRATION_1_2 = object : Migration(1, 2) { val MIGRATION_1_2 = object : Migration(1, 2) {
@ -145,23 +142,19 @@ abstract class AppDatabase : RoomDatabase() {
val MIGRATION_5_6 = object : Migration(5, 6) { val MIGRATION_5_6 = object : Migration(5, 6) {
override fun migrate(db: SupportSQLiteDatabase) { override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL( 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`)")
CREATE TABLE IF NOT EXISTS `CharacterTransferPolicy` ( db.execSQL("CREATE INDEX IF NOT EXISTS `index_CardAdventure_characterId` ON `CardAdventure` (`characterId`)")
`characterId` INTEGER NOT NULL, db.execSQL("CREATE INDEX IF NOT EXISTS `index_CardFusions_fromCharaId` ON `CardFusions` (`fromCharaId`)")
`nativeDeviceType` TEXT NOT NULL, db.execSQL("CREATE INDEX IF NOT EXISTS `index_CardFusions_toCharaId` ON `CardFusions` (`toCharaId`)")
`preferredHceExportFormat` TEXT NOT NULL, db.execSQL("CREATE INDEX IF NOT EXISTS `index_Background_cardId` ON `Background` (`cardId`)")
`preferredNfcaExportFormat` TEXT NOT NULL, db.execSQL("CREATE INDEX IF NOT EXISTS `index_PossibleTransformations_charaId` ON `PossibleTransformations` (`charaId`)")
`lastObservedImportFormat` TEXT, db.execSQL("CREATE INDEX IF NOT EXISTS `index_PossibleTransformations_toCharaId` ON `PossibleTransformations` (`toCharaId`)")
`lastTransferTransport` TEXT, db.execSQL("CREATE INDEX IF NOT EXISTS `index_UserCharacter_charId` ON `UserCharacter` (`charId`)")
`lastTransferTarget` TEXT, db.execSQL("CREATE INDEX IF NOT EXISTS `index_SpecialMissions_characterId` ON `SpecialMissions` (`characterId`)")
`preserveVbRoundTrip` INTEGER NOT NULL, db.execSQL("CREATE INDEX IF NOT EXISTS `index_TransformationHistory_monId` ON `TransformationHistory` (`monId`)")
`preserveBeRoundTrip` INTEGER NOT NULL, db.execSQL("CREATE INDEX IF NOT EXISTS `index_TransformationHistory_stageId` ON `TransformationHistory` (`stageId`)")
PRIMARY KEY(`characterId`), db.execSQL("CREATE INDEX IF NOT EXISTS `index_VitalsHistory_charId` ON `VitalsHistory` (`charId`)")
FOREIGN KEY(`characterId`) REFERENCES `UserCharacter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE
)
""".trimIndent()
)
} }
} }

View File

@ -2,6 +2,7 @@ package com.github.nacabaro.vbhelper.di
import com.github.cfogrady.vitalwear.common.data.SharedTransferSeenDao import com.github.cfogrady.vitalwear.common.data.SharedTransferSeenDao
import com.github.nacabaro.vbhelper.database.AppDatabase import com.github.nacabaro.vbhelper.database.AppDatabase
import com.github.nacabaro.vbhelper.source.CardSettingsRepository
import com.github.nacabaro.vbhelper.source.CurrencyRepository import com.github.nacabaro.vbhelper.source.CurrencyRepository
import com.github.nacabaro.vbhelper.source.DataStoreSecretsRepository import com.github.nacabaro.vbhelper.source.DataStoreSecretsRepository
@ -10,4 +11,5 @@ interface AppContainer {
val transferSeenDao: SharedTransferSeenDao val transferSeenDao: SharedTransferSeenDao
val dataStoreSecretsRepository: DataStoreSecretsRepository val dataStoreSecretsRepository: DataStoreSecretsRepository
val currencyRepository: CurrencyRepository 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.cfogrady.vitalwear.common.data.SharedTransferSeenDao
import com.github.nacabaro.vbhelper.di.AppContainer import com.github.nacabaro.vbhelper.di.AppContainer
import com.github.nacabaro.vbhelper.database.AppDatabase import com.github.nacabaro.vbhelper.database.AppDatabase
import com.github.nacabaro.vbhelper.source.CardSettingsRepository
import com.github.nacabaro.vbhelper.source.CurrencyRepository import com.github.nacabaro.vbhelper.source.CurrencyRepository
import com.github.nacabaro.vbhelper.source.DataStoreSecretsRepository import com.github.nacabaro.vbhelper.source.DataStoreSecretsRepository
import com.github.nacabaro.vbhelper.source.SecretsSerializer import com.github.nacabaro.vbhelper.source.SecretsSerializer
@ -37,6 +38,7 @@ class DefaultAppContainer(private val context: Context) : AppContainer {
.addMigrations(AppDatabase.MIGRATION_2_3) .addMigrations(AppDatabase.MIGRATION_2_3)
.addMigrations(AppDatabase.MIGRATION_3_5) .addMigrations(AppDatabase.MIGRATION_3_5)
.addMigrations(AppDatabase.MIGRATION_4_5) .addMigrations(AppDatabase.MIGRATION_4_5)
.addMigrations(AppDatabase.MIGRATION_5_6)
.build() .build()
} }
@ -47,5 +49,7 @@ class DefaultAppContainer(private val context: Context) : AppContainer {
override val dataStoreSecretsRepository = DataStoreSecretsRepository(context.secretsStore) override val dataStoreSecretsRepository = DataStoreSecretsRepository(context.secretsStore)
override val currencyRepository = CurrencyRepository(context.currencyStore) override val currencyRepository = CurrencyRepository(context.currencyStore)
override val cardSettingsRepository = CardSettingsRepository(context.currencyStore)
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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.Entity
import androidx.room.ForeignKey import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import com.github.cfogrady.vbnfc.data.NfcCharacter import com.github.cfogrady.vbnfc.data.NfcCharacter
import com.github.nacabaro.vbhelper.utils.DeviceType import com.github.nacabaro.vbhelper.utils.DeviceType
import com.github.nacabaro.vbhelper.domain.card.CardCharacter import com.github.nacabaro.vbhelper.domain.card.CardCharacter
@Entity( @Entity(
indices = [Index(value = ["charId"])],
foreignKeys = [ foreignKeys = [
ForeignKey( ForeignKey(
entity = CardCharacter::class, entity = CardCharacter::class,

View File

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

View File

@ -15,6 +15,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import com.github.nacabaro.vbhelper.di.VBHelper import com.github.nacabaro.vbhelper.di.VBHelper
@ -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.spriteViewer.SpriteViewerControllerImpl
import com.github.nacabaro.vbhelper.screens.storageScreen.StorageScreenControllerImpl import com.github.nacabaro.vbhelper.screens.storageScreen.StorageScreenControllerImpl
import com.github.nacabaro.vbhelper.source.StorageRepository import com.github.nacabaro.vbhelper.source.StorageRepository
import kotlinx.coroutines.flow.Flow
data class AppNavigationHandlers( data class AppNavigationHandlers(
val settingsScreenController: SettingsScreenControllerImpl, val settingsScreenController: SettingsScreenControllerImpl,
@ -56,20 +56,22 @@ data class AppNavigationHandlers(
@Composable @Composable
fun AppNavigation( fun AppNavigation(
applicationNavigationHandlers: AppNavigationHandlers, applicationNavigationHandlers: AppNavigationHandlers,
initialRoute: String? = null, initialRoute: String? = null
navigationEvents: Flow<String>? = null,
) { ) {
val navController = rememberNavController() val navController = rememberNavController()
LaunchedEffect(navController, navigationEvents) { LaunchedEffect(initialRoute) {
navigationEvents?.collect { route -> val route = initialRoute ?: return@LaunchedEffect
if (navController.currentBackStackEntry?.destination?.route == route) {
return@LaunchedEffect
}
navController.navigate(route) { navController.navigate(route) {
popUpTo(navController.graph.findStartDestination().id) {
inclusive = false
saveState = false
}
launchSingleTop = true launchSingleTop = true
restoreState = true restoreState = false
popUpTo(navController.graph.startDestinationId) {
saveState = true
}
}
} }
} }

View File

@ -7,6 +7,7 @@ import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -30,42 +31,16 @@ fun BottomNavigationBar(navController: NavController) {
label = { Text(text = stringResource(item.label)) }, label = { Text(text = stringResource(item.label)) },
selected = currentRoute == item.route, selected = currentRoute == item.route,
onClick = { onClick = {
if (item == NavigationItems.Home) { navController.navigate(item.route) {
// Home should always show the Home root, not a restored nested route // Always route tab clicks to each tab's root screen.
// like Settings that was opened from Home previously. popUpTo(navController.graph.findStartDestination().id) {
val poppedToHome = navController.popBackStack( inclusive = false
NavigationItems.Home.route, saveState = false
inclusive = false, }
)
if (!poppedToHome) {
navController.navigate(NavigationItems.Home.route) {
popUpTo(navController.graph.startDestinationId) { inclusive = false }
launchSingleTop = true launchSingleTop = true
restoreState = false 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
}
}
}
) )
} }
} }

View File

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

View File

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

View File

@ -33,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.domain.device_data.VBCharacterData
import com.github.nacabaro.vbhelper.dtos.ItemDtos import com.github.nacabaro.vbhelper.dtos.ItemDtos
import com.github.nacabaro.vbhelper.navigation.NavigationItems import com.github.nacabaro.vbhelper.navigation.NavigationItems
import com.github.nacabaro.vbhelper.screens.homeScreens.screens.BEBEmHomeScreen
import com.github.nacabaro.vbhelper.screens.homeScreens.screens.BEDiMHomeScreen
import com.github.nacabaro.vbhelper.screens.homeScreens.screens.VBDiMHomeScreen import com.github.nacabaro.vbhelper.screens.homeScreens.screens.VBDiMHomeScreen
import com.github.nacabaro.vbhelper.screens.itemsScreen.ObtainedItemDialog import com.github.nacabaro.vbhelper.screens.itemsScreen.ObtainedItemDialog
import com.github.nacabaro.vbhelper.source.StorageRepository import com.github.nacabaro.vbhelper.source.StorageRepository
@ -74,9 +72,8 @@ fun HomeScreen(
?: flowOf(emptyList()) ?: flowOf(emptyList())
).collectAsState(initial = emptyList()) ).collectAsState(initial = emptyList())
val vbSpecialMissions by ( val specialMissions by (
activeMon activeMon
?.takeIf { it.characterType == DeviceType.VBDevice }
?.let { chara -> ?.let { chara ->
storageRepository.getSpecialMissions(chara.id) storageRepository.getSpecialMissions(chara.id)
} }
@ -144,29 +141,20 @@ fun HomeScreen(
height = cardIconData!!.cardIconHeight height = cardIconData!!.cardIconHeight
) )
if (activeMon!!.isBemCard && beData != null) { val activeCharacter = activeMon!!
BEBEmHomeScreen( val displaySpecialMissions = specialMissions
activeMon = activeMon!!,
beData = beData!!, if (activeCharacter.characterType == DeviceType.BEDevice || vbData != null) {
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) {
VBDiMHomeScreen( VBDiMHomeScreen(
activeMon = activeMon!!, activeMon = activeCharacter,
vbData = vbData!!, vbData = vbData ?: VBCharacterData(
id = activeCharacter.id,
generation = 0,
totalTrophies = activeCharacter.trophies,
),
transformationHistory = transformationHistory, transformationHistory = transformationHistory,
contentPadding = PaddingValues(0.dp), contentPadding = PaddingValues(0.dp),
specialMissions = vbSpecialMissions, specialMissions = displaySpecialMissions,
homeScreenController = homeScreenController, homeScreenController = homeScreenController,
onClickCollect = { item, currency -> onClickCollect = { item, currency ->
collectedItem = item 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.Card
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.github.nacabaro.vbhelper.dtos.ItemDtos import com.github.nacabaro.vbhelper.dtos.ItemDtos
import com.github.nacabaro.vbhelper.domain.items.ItemType
import com.github.nacabaro.vbhelper.R import com.github.nacabaro.vbhelper.R
@Composable @Composable
@ -28,6 +33,12 @@ fun ItemElement(
.aspectRatio(1f) .aspectRatio(1f)
) { ) {
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
ItemCompatibilityBadge(
itemType = item.itemType,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp)
)
Icon( Icon(
painter = painterResource(id = getIconResource(item.itemIcon)), painter = painterResource(id = getIconResource(item.itemIcon)),
contentDescription = null, contentDescription = null,
@ -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.foundation.layout.padding
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Tab import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -34,7 +34,7 @@ fun ItemsScreen(
topBar = { topBar = {
Column { Column {
TopBanner(text = stringResource(R.string.items_title)) TopBanner(text = stringResource(R.string.items_title))
TabRow( PrimaryTabRow(
selectedTabIndex = selectedTabItem, selectedTabIndex = selectedTabItem,
modifier = Modifier 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.database.AppDatabase
import com.github.nacabaro.vbhelper.di.VBHelper import com.github.nacabaro.vbhelper.di.VBHelper
import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData
import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData
import com.github.nacabaro.vbhelper.dtos.ItemDtos import com.github.nacabaro.vbhelper.dtos.ItemDtos
import com.github.nacabaro.vbhelper.utils.DeviceType import com.github.nacabaro.vbhelper.utils.DeviceType
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -51,19 +50,12 @@ class ItemsScreenControllerImpl (
val item = getItem(itemId) val item = getItem(itemId)
val characterData = database.userCharacterDao().getCharacter(characterId) val characterData = database.userCharacterDao().getCharacter(characterId)
var beCharacterData: BECharacterData? = null var beCharacterData: BECharacterData? = null
var vbCharacterData: VBCharacterData? = null
if (characterData.characterType == DeviceType.BEDevice) { if (characterData.characterType == DeviceType.BEDevice) {
beCharacterData = database beCharacterData = database
.userCharacterDao() .userCharacterDao()
.getBeData(characterId) .getBeData(characterId)
.firstOrNull() .firstOrNull()
} else if (characterData.characterType == DeviceType.VBDevice) {
vbCharacterData = database
.userCharacterDao()
.getVbData(characterId)
.firstOrNull()
} }
if ( if (
@ -122,8 +114,7 @@ class ItemsScreenControllerImpl (
.updateCharacter(characterData) .updateCharacter(characterData)
} else if (item.itemIcon in ItemTypes.Step8k.id .. ItemTypes.Win4.id && } else if (item.itemIcon in ItemTypes.Step8k.id .. ItemTypes.Win4.id &&
characterData.characterType == DeviceType.VBDevice && (characterData.characterType == DeviceType.VBDevice || characterData.characterType == DeviceType.BEDevice)
vbCharacterData != null
) { ) {
applySpecialMission(item.itemIcon, item.itemLength, characterId) applySpecialMission(item.itemIcon, item.itemLength, characterId)
} }
@ -180,12 +171,12 @@ class ItemsScreenControllerImpl (
.getSpecialMissions(characterId) .getSpecialMissions(characterId)
.first() .first()
var newSpecialMission = availableSpecialMissions[specialMissionSlot] val existingMission = availableSpecialMissions.firstOrNull { it.watchId == specialMissionSlot }
newSpecialMission = SpecialMissions( val newSpecialMission = SpecialMissions(
id = newSpecialMission.id, id = existingMission?.id ?: 0,
characterId = newSpecialMission.characterId, characterId = characterId,
goal = specialMissionGoal, goal = specialMissionGoal,
watchId = newSpecialMission.watchId, watchId = specialMissionSlot,
progress = 0, progress = 0,
status = SpecialMission.Status.AVAILABLE, status = SpecialMission.Status.AVAILABLE,
timeElapsedInMinutes = 0, timeElapsedInMinutes = 0,

View File

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

View File

@ -144,7 +144,12 @@ fun ScanScreenPreview() {
override fun flushCharacter(cardId: Long) {} override fun flushCharacter(cardId: Long) {}
override fun onClickRead(secrets: Secrets, onComplete: ()->Unit, onMultipleCards: (List<Card>) -> Unit) {} override fun onClickRead(secrets: Secrets, onComplete: ()->Unit, onMultipleCards: (List<Card>) -> Unit) {}
override fun onClickCheckCard(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: () -> Unit) {} override fun onClickCheckCard(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: () -> Unit) {}
override fun onClickWrite(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 cancelRead() {}
override fun characterFromNfc(nfcCharacter: NfcCharacter, onMultipleCards: (List<Card>, NfcCharacter) -> Unit): String { return "" } override fun characterFromNfc(nfcCharacter: NfcCharacter, onMultipleCards: (List<Card>, NfcCharacter) -> Unit): String { return "" }
override suspend fun characterToNfc(characterId: Long): NfcCharacter? { return null } override suspend fun characterToNfc(characterId: Long): NfcCharacter? { return null }

View File

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

View File

@ -12,8 +12,10 @@ import android.widget.Toast
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.github.cfogrady.vbnfc.TagCommunicator import com.github.cfogrady.vbnfc.TagCommunicator
import com.github.cfogrady.vitalwear.protos.Character
import com.github.cfogrady.vbnfc.be.BENfcCharacter import com.github.cfogrady.vbnfc.be.BENfcCharacter
import com.github.cfogrady.vbnfc.data.NfcCharacter import com.github.cfogrady.vbnfc.data.NfcCharacter
import com.github.cfogrady.vbnfc.data.DeviceType as NfcDeviceTypeId
import com.github.cfogrady.vbnfc.vb.VBNfcCharacter import com.github.cfogrady.vbnfc.vb.VBNfcCharacter
import com.github.nacabaro.vbhelper.ActivityLifecycleListener import com.github.nacabaro.vbhelper.ActivityLifecycleListener
import com.github.nacabaro.vbhelper.domain.card.Card import com.github.nacabaro.vbhelper.domain.card.Card
@ -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.screens.scanScreen.converters.ToNfcConverter
import com.github.nacabaro.vbhelper.source.VitalWearCharacterExporter import com.github.nacabaro.vbhelper.source.VitalWearCharacterExporter
import com.github.nacabaro.vbhelper.source.VitalWearCharacterImporter 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.getCryptographicTransformerMap
import com.github.nacabaro.vbhelper.source.isMissingSecrets import com.github.nacabaro.vbhelper.source.isMissingSecrets
import com.github.nacabaro.vbhelper.source.proto.Secrets import com.github.nacabaro.vbhelper.source.proto.Secrets
import com.github.nacabaro.vbhelper.transfer.TransferTarget
import com.github.nacabaro.vbhelper.transfer.TransferTransport
import com.github.nacabaro.vbhelper.transfer.hce.VitalWearHceReaderClient import com.github.nacabaro.vbhelper.transfer.hce.VitalWearHceReaderClient
import com.github.nacabaro.vbhelper.utils.DeviceType
import com.github.nacabaro.vbhelper.R import com.github.nacabaro.vbhelper.R
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -57,6 +57,7 @@ class ScanScreenControllerImpl(
private val _transferStatusFlow = MutableStateFlow<String?>(null) private val _transferStatusFlow = MutableStateFlow<String?>(null)
private var lastScannedCharacter: NfcCharacter? = null private var lastScannedCharacter: NfcCharacter? = null
private var lastRequestedCharacterId: Long? = null private var lastRequestedCharacterId: Long? = null
private var lastWriteCharacterId: Long? = null
private val nfcAdapter: NfcAdapter private val nfcAdapter: NfcAdapter
private val isHandlingTag = AtomicBoolean(false) private val isHandlingTag = AtomicBoolean(false)
private var lastTagId: ByteArray? = null private var lastTagId: ByteArray? = null
@ -122,7 +123,6 @@ class ScanScreenControllerImpl(
val options = Bundle() val options = Bundle()
options.putInt(NfcAdapter.EXTRA_READER_PRESENCE_CHECK_DELAY, 250) options.putInt(NfcAdapter.EXTRA_READER_PRESENCE_CHECK_DELAY, 250)
val flags = NfcAdapter.FLAG_READER_NFC_A or val flags = NfcAdapter.FLAG_READER_NFC_A or
NfcAdapter.FLAG_READER_NFC_B or
NfcAdapter.FLAG_READER_NO_PLATFORM_SOUNDS or NfcAdapter.FLAG_READER_NO_PLATFORM_SOUNDS or
NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK
runCatching { runCatching {
@ -166,9 +166,11 @@ class ScanScreenControllerImpl(
val application = componentActivity.applicationContext as VBHelper val application = componentActivity.applicationContext as VBHelper
val importer = VitalWearCharacterImporter(application.container.db, application.container.transferSeenDao) val importer = VitalWearCharacterImporter(application.container.db, application.container.transferSeenDao)
try { try {
// ISO-DEP/HCE route: VitalWear characters are always imported as BE device type.
var importResult: VitalWearCharacterImporter.ImportResult? = null var importResult: VitalWearCharacterImporter.ImportResult? = null
val moved = VitalWearHceReaderClient(isoDep).moveCharacterFromWatch { character -> val moved = VitalWearHceReaderClient(isoDep).moveCharacterFromWatch { character ->
val result = importer.importCharacter(character) // Force imported device type to BE since source is VitalWear HCE (BE only).
val result = importer.importCharacter(character, forcedDeviceType = DeviceType.BEDevice)
importResult = result importResult = result
result.success result.success
} }
@ -190,13 +192,46 @@ class ScanScreenControllerImpl(
// ---- Write (phone sends character TO watch or bracelet) ------------------------ // ---- 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 _detectedTransportFlow.value = DetectedTransport.UNKNOWN
setTransferStatus(R.string.scan_transfer_waiting_tap) setTransferStatus(R.string.scan_transfer_waiting_tap)
handleTag( handleTag(
secrets, secrets,
nfcAHandler = { tagCommunicator -> nfcAHandler = { tagCommunicator ->
try { try {
val targetHeader = runCatching { tagCommunicator.readTargetHeader() }.getOrNull()
val normalizedDeviceType = when (targetHeader?.deviceTypeId) {
512u.toUShort() -> NfcDeviceTypeId.VitalSeriesDeviceType
768u.toUShort() -> NfcDeviceTypeId.VitalCharactersDeviceType
1024u.toUShort() -> NfcDeviceTypeId.VitalBraceletBEDeviceType
else -> targetHeader?.deviceTypeId
}
val forcedProfile = when (normalizedDeviceType) {
NfcDeviceTypeId.VitalBraceletBEDeviceType -> DeviceType.BEDevice
else -> DeviceType.VBDevice
}
Log.i(
"NFC_WRITE_A",
"Target NFC-A header rawDeviceType=${targetHeader?.deviceTypeId}, normalizedDeviceType=$normalizedDeviceType, dimId=${targetHeader?.getDimId()}, forcedProfile=$forcedProfile"
)
// NFC-A route: choose export profile by target bracelet type.
// - Vital Series / Vital Characters => VB profile
// - Vital Bracelet BE => BE profile
val nfcACharacter = if (characterId != null) {
runBlocking {
ToNfcConverter(componentActivity = componentActivity)
.characterToNfc(characterId, forcedProfile)
}
} else {
nfcCharacter
}
val initialSlotState = readNfcASlotState(tagCommunicator) val initialSlotState = readNfcASlotState(tagCommunicator)
if (initialSlotState.isFull()) { if (initialSlotState.isFull()) {
onComplete.invoke(ScanScreenController.WriteResult.BLOCKED_DEVICE_FULL) onComplete.invoke(ScanScreenController.WriteResult.BLOCKED_DEVICE_FULL)
@ -209,59 +244,34 @@ class ScanScreenControllerImpl(
return@handleTag migrationCheck.message return@handleTag migrationCheck.message
} }
when (nfcCharacter) { // Send with VB profile (which was forced above). Real bracelets only accept VBNfcCharacter.
is VBNfcCharacter -> tagCommunicator.sendCharacter(nfcCharacter) return@handleTag try {
is BENfcCharacter -> tagCommunicator.sendCharacter(nfcCharacter) 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) onComplete.invoke(ScanScreenController.WriteResult.MOVE_CONFIRMED)
componentActivity.getString(R.string.scan_sent_character_success) componentActivity.getString(R.string.scan_sent_character_success)
} catch (writeError: Throwable) { } catch (deviceTypeMismatch: Exception) {
Log.e("NFC_WRITE_A", "NFC-A write failed; trying alternate payload format", writeError) // Device type mismatch on NFC-A is terminal for this tap.
val characterId = lastRequestedCharacterId if (deviceTypeMismatch.message?.contains("Character doesn't match device type") == true) {
val alternateFormat = when (nfcCharacter) { Log.e("NFC_WRITE_A", "Device type mismatch on NFC-A with selected transfer profile", deviceTypeMismatch)
is BENfcCharacter -> ExportFormat.VB
is VBNfcCharacter -> ExportFormat.BE
else -> null
}
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
}
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) onComplete.invoke(ScanScreenController.WriteResult.COPIED)
fallbackMessage "Transfer failed: bracelet rejected character profile for this tap. Retry and keep bracelet steady."
}.getOrElse { readFallbackError -> } else {
Log.e("NFC_WRITE_A", "NFC-A opposite-direction fallback failed", readFallbackError) Log.e("NFC_WRITE_A", "NFC-A write failed", deviceTypeMismatch)
componentActivity.getString(R.string.scan_error_generic) 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 -> isoDepHandler = { isoDep ->
@ -270,69 +280,35 @@ class ScanScreenControllerImpl(
val application = componentActivity.applicationContext as VBHelper val application = componentActivity.applicationContext as VBHelper
val hceClient = VitalWearHceReaderClient(isoDep) val hceClient = VitalWearHceReaderClient(isoDep)
try { try {
// ISO-DEP/HCE route: VitalWear always uses BE profile (enforced).
// Create protobuf with forced BE device type for correct serialization.
val proto = runBlocking { val proto = runBlocking {
VitalWearCharacterExporter(application.container.db) VitalWearCharacterExporter(application.container.db)
.buildCharacterProto(characterId) .buildCharacterProto(characterId, forcedTransferProfile = DeviceType.BEDevice)
} }
hceClient.sendCharacterToWatch(proto) 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) onComplete.invoke(ScanScreenController.WriteResult.MOVE_CONFIRMED)
componentActivity.getString(R.string.scan_sent_character_success) 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 { } else {
importResult?.message // Transfer acknowledging but watch import failed or did not confirm.
?: "Watch is in source mode, but import was rejected." onComplete.invoke(ScanScreenController.WriteResult.COPIED)
} "Transfer sent but watch did not confirm import. Source was kept in VBH."
}.getOrElse { readFallbackError ->
Log.e("NFC_WRITE_HCE", "HCE opposite-direction fallback failed", readFallbackError)
componentActivity.getString(R.string.scan_error_generic)
} }
} 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) ----------- // ---- 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 // Work around for some broken Nfc firmware implementations that poll the card too fast
options.putInt(NfcAdapter.EXTRA_READER_PRESENCE_CHECK_DELAY, 250) options.putInt(NfcAdapter.EXTRA_READER_PRESENCE_CHECK_DELAY, 250)
val flags = NfcAdapter.FLAG_READER_NFC_A or val flags = NfcAdapter.FLAG_READER_NFC_A or
NfcAdapter.FLAG_READER_NFC_B or
NfcAdapter.FLAG_READER_NO_PLATFORM_SOUNDS or NfcAdapter.FLAG_READER_NO_PLATFORM_SOUNDS or
NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK
val sessionId = readerSessionCounter.incrementAndGet() val sessionId = readerSessionCounter.incrementAndGet()
@ -450,18 +425,19 @@ class ScanScreenControllerImpl(
setTransferStatus(R.string.scan_transfer_detected_keep_tap) setTransferStatus(R.string.scan_transfer_detected_keep_tap)
// Detect transport once per tap and lock to that route for this transfer. // 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 isoDep = IsoDep.get(tag)
val hasIsoDepRoute = isoDep != null && isoDepHandler != null
val hasNfcARoute = NfcA.get(tag) != 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 { try {
// Prefer ISO-DEP whenever present so HCE sessions do not accidentally fall through to NFC-A. if (hasIsoDepHandler && confirmedVitalWear) {
if (hasIsoDepRoute) {
val isoDepTarget = isoDep val isoDepTarget = isoDep
val isoDepAction = isoDepHandler 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 { try {
_detectedTransportFlow.value = DetectedTransport.ISO_DEP _detectedTransportFlow.value = DetectedTransport.ISO_DEP
isoDepTarget.connect() isoDepTarget.connect()
@ -480,7 +456,19 @@ class ScanScreenControllerImpl(
setTransferStatus(R.string.scan_transfer_failed_try_again) 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) { } 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)) { if (!handleNfcATag(tag, secrets, nfcAHandler)) {
componentActivity.runOnUiThread { componentActivity.runOnUiThread {
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_tag_not_vb), Toast.LENGTH_SHORT).show() Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_tag_not_vb), Toast.LENGTH_SHORT).show()
@ -488,6 +476,9 @@ class ScanScreenControllerImpl(
} }
} }
} else { } 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 _detectedTransportFlow.value = DetectedTransport.UNKNOWN
componentActivity.runOnUiThread { componentActivity.runOnUiThread {
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_tag_not_vb), Toast.LENGTH_SHORT).show() Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_tag_not_vb), Toast.LENGTH_SHORT).show()
@ -677,10 +668,10 @@ class ScanScreenControllerImpl(
val migrationVerified = afterState.backupPresent == true || val migrationVerified = afterState.backupPresent == true ||
(beforeState.count != null && afterState.count != null && afterState.count >= beforeState.count) (beforeState.count != null && afterState.count != null && afterState.count >= beforeState.count)
if (!migrationVerified) { if (!migrationVerified) {
return SlotMigrationCheck( // Some real bracelets do not expose stable occupancy signals through the current
canProceed = false, // reflection-based probing. In that case, prefer compatibility over false blocks.
message = "Transfer blocked. Backup slot could not be verified after migration." 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 = "") return SlotMigrationCheck(canProceed = true, message = "")
@ -746,11 +737,7 @@ class ScanScreenControllerImpl(
override suspend fun characterToNfc(characterId: Long): NfcCharacter { override suspend fun characterToNfc(characterId: Long): NfcCharacter {
lastRequestedCharacterId = characterId lastRequestedCharacterId = characterId
val character = ToNfcConverter(componentActivity = componentActivity).characterToNfc( val character = ToNfcConverter(componentActivity = componentActivity).characterToNfc(characterId)
characterId = characterId,
target = TransferTarget.REAL_BRACELET,
transport = TransferTransport.NFCA,
)
Log.d("CharacterType", character.toString()) Log.d("CharacterType", character.toString())
return character 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.UserCharacter
import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData
import com.github.nacabaro.vbhelper.domain.device_data.VitalsHistory import com.github.nacabaro.vbhelper.domain.device_data.VitalsHistory
import com.github.nacabaro.vbhelper.transfer.CharacterTransferPolicyResolver
import com.github.nacabaro.vbhelper.transfer.ExportFormat
import com.github.nacabaro.vbhelper.utils.DeviceType import com.github.nacabaro.vbhelper.utils.DeviceType
import java.util.GregorianCalendar import java.util.GregorianCalendar
@ -22,7 +20,6 @@ class FromNfcConverter (
private val application = componentActivity.applicationContext as VBHelper private val application = componentActivity.applicationContext as VBHelper
private val database = application.container.db private val database = application.container.db
private val transferSeenDao = application.container.transferSeenDao private val transferSeenDao = application.container.transferSeenDao
private val transferPolicyResolver = CharacterTransferPolicyResolver(database.characterTransferPolicyDao())
fun addCharacterUsingCard( fun addCharacterUsingCard(
@ -130,6 +127,8 @@ class FromNfcConverter (
characterId = characterId, characterId = characterId,
nfcCharacter = nfcCharacter nfcCharacter = nfcCharacter
) )
// Keep a VB profile alongside BE stats for later VB-target exports.
addVbCharacterProfileFromBe(characterId, nfcCharacter)
} else if (nfcCharacter is VBNfcCharacter) { } else if (nfcCharacter is VBNfcCharacter) {
addVbCharacterToDatabase( addVbCharacterToDatabase(
characterId = characterId, characterId = characterId,
@ -137,18 +136,6 @@ class FromNfcConverter (
) )
} }
database
.characterTransferPolicyDao()
.upsert(
transferPolicyResolver.policyForNfcaImport(
characterId = characterId,
observedFormat = when (nfcCharacter) {
is BENfcCharacter -> ExportFormat.BE
else -> ExportFormat.VB
}
)
)
addTransformationHistoryToDatabase( addTransformationHistoryToDatabase(
characterId = characterId, characterId = characterId,
nfcCharacter = nfcCharacter, nfcCharacter = nfcCharacter,
@ -248,8 +235,8 @@ class FromNfcConverter (
itemType = nfcCharacter.itemType.toInt(), itemType = nfcCharacter.itemType.toInt(),
itemMultiplier = nfcCharacter.itemMultiplier.toInt(), itemMultiplier = nfcCharacter.itemMultiplier.toInt(),
itemRemainingTime = nfcCharacter.itemRemainingTime.toInt(), itemRemainingTime = nfcCharacter.itemRemainingTime.toInt(),
otp0 = "", //nfcCharacter.value!!.otp0.toString(), otp0 = nfcCharacter.getOtp0().joinToString("") { "%02x".format(it) },
otp1 = "", //nfcCharacter.value!!.otp1.toString(), otp1 = nfcCharacter.getOtp1().joinToString("") { "%02x".format(it) },
minorVersion = nfcCharacter.characterCreationFirmwareVersion.minorVersion.toInt(), minorVersion = nfcCharacter.characterCreationFirmwareVersion.minorVersion.toInt(),
majorVersion = nfcCharacter.characterCreationFirmwareVersion.majorVersion.toInt(), majorVersion = nfcCharacter.characterCreationFirmwareVersion.majorVersion.toInt(),
) )
@ -259,6 +246,42 @@ class FromNfcConverter (
.insertBECharacterData(extraCharacterData) .insertBECharacterData(extraCharacterData)
} }
private fun addVbCharacterProfileFromBe(
characterId: Long,
nfcCharacter: BENfcCharacter,
) {
val historyCount = nfcCharacter.transformationHistory.count { it.toCharIndex.toInt() != 255 }
val vbData = VBCharacterData(
id = characterId,
generation = (historyCount - 1).coerceAtLeast(0),
totalTrophies = nfcCharacter.trophies.toInt(),
)
database.userCharacterDao().insertVBCharacterData(vbData)
// Populate empty mission slots for BE characters for UI consistency
addEmptyMissionSlotsForBe(characterId)
}
private fun addEmptyMissionSlotsForBe(characterId: Long) {
// Create 4 empty mission slots (one for each watch position 0-3)
val emptyMissions = (0..3).map { watchId ->
SpecialMissions(
characterId = characterId,
watchId = watchId,
missionType = 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( 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.UserCharacter
import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData
import com.github.nacabaro.vbhelper.dtos.CharacterDtos import com.github.nacabaro.vbhelper.dtos.CharacterDtos
import com.github.nacabaro.vbhelper.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 com.github.nacabaro.vbhelper.utils.DeviceType
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
@ -30,19 +26,27 @@ class ToNfcConverter(
companion object { companion object {
private const val MIN_NFC_TRANSFORMATION_YEAR = 2021 private const val MIN_NFC_TRANSFORMATION_YEAR = 2021
private const val MAX_NFC_TRANSFORMATION_YEAR = 2035 private const val MAX_NFC_TRANSFORMATION_YEAR = 2035
internal fun shouldEncodeAsBem(
forcedProfile: DeviceType?,
storedDeviceType: DeviceType,
): Boolean {
return when (forcedProfile) {
DeviceType.BEDevice -> true
DeviceType.VBDevice -> false
else -> storedDeviceType == DeviceType.BEDevice
}
}
} }
private val application: VBHelper = componentActivity.applicationContext as VBHelper private val application: VBHelper = componentActivity.applicationContext as VBHelper
private val database: AppDatabase = application.container.db private val database: AppDatabase = application.container.db
private val transferPolicyResolver = CharacterTransferPolicyResolver(database.characterTransferPolicyDao())
suspend fun characterToNfc( suspend fun characterToNfc(
characterId: Long, characterId: Long,
target: TransferTarget = TransferTarget.REAL_BRACELET, forcedProfile: DeviceType? = null,
transport: TransferTransport = TransferTransport.NFCA,
forcedFormat: ExportFormat? = null,
): NfcCharacter { ): NfcCharacter {
val app = componentActivity.applicationContext as VBHelper val app = componentActivity.applicationContext as VBHelper
val database = app.container.db val database = app.container.db
@ -54,20 +58,14 @@ class ToNfcConverter(
val characterInfo = database val characterInfo = database
.characterDao() .characterDao()
.getCharacterInfo(userCharacter.charId) .getCharacterInfo(userCharacter.charId)
val card = database
.cardDao()
.getCardByCharacterIdSync(characterId)
?: error("Card not found for character $characterId")
val exportFormat = forcedFormat ?: transferPolicyResolver.resolveExportFormat( // Forced profile overrides stored device type for transfer semantics:
characterId = characterId, // - NFC-A route forces VBDevice (real bracelets only understand VB)
characterType = userCharacter.characterType, // - HCE route forces BEDevice (VitalWear only sends/accepts BE)
transport = transport, // Otherwise, use the character's own stored type.
target = target, val shouldEncodeAsBem = shouldEncodeAsBem(forcedProfile, userCharacter.characterType)
isBemCard = card.isBEm,
)
return if (exportFormat == ExportFormat.BE) return if (shouldEncodeAsBem)
nfcToBENfc(characterId, characterInfo, userCharacter) nfcToBENfc(characterId, characterInfo, userCharacter)
else else
nfcToVBNfc(characterId, characterInfo, userCharacter) nfcToVBNfc(characterId, characterInfo, userCharacter)
@ -238,6 +236,18 @@ class ToNfcConverter(
val paddedTransformationArray = generateTransformationHistory(characterId) val paddedTransformationArray = generateTransformationHistory(characterId)
// Convert hex-encoded OTP strings back to ByteArrays; use empty arrays if not stored
val otp0ByteArray = if (beData.otp0.isNotEmpty()) {
beData.otp0.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
} else {
ByteArray(8) // Default to 8-byte zero array if not available
}
val otp1ByteArray = if (beData.otp1.isNotEmpty()) {
beData.otp1.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
} else {
ByteArray(8) // Default to 8-byte zero array if not available
}
val nfcData = BENfcCharacter( val nfcData = BENfcCharacter(
dimId = characterInfo.cardId.toUShort(), dimId = characterInfo.cardId.toUShort(),
charIndex = characterInfo.charId.toUShort(), charIndex = characterInfo.charId.toUShort(),
@ -278,8 +288,8 @@ class ToNfcConverter(
itemType = beData.itemType.toByte(), itemType = beData.itemType.toByte(),
itemMultiplier = beData.itemMultiplier.toByte(), itemMultiplier = beData.itemMultiplier.toByte(),
itemRemainingTime = beData.itemRemainingTime.toByte(), itemRemainingTime = beData.itemRemainingTime.toByte(),
otp0 = byteArrayOf(8), otp0 = otp0ByteArray,
otp1 = byteArrayOf(8), otp1 = otp1ByteArray,
characterCreationFirmwareVersion = FirmwareVersion( characterCreationFirmwareVersion = FirmwareVersion(
minorVersion = beData.minorVersion.toByte(), minorVersion = beData.minorVersion.toByte(),
majorVersion = beData.majorVersion.toByte() majorVersion = beData.majorVersion.toByte()

View File

@ -1,54 +1,54 @@
package com.github.nacabaro.vbhelper.screens.scanScreen.screens package com.github.nacabaro.vbhelper.screens.scanScreen.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier import androidx.compose.runtime.remember
import androidx.compose.ui.res.stringResource import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import com.github.nacabaro.vbhelper.screens.scanScreen.TransferHaptics
import com.github.nacabaro.vbhelper.components.TopBanner import com.github.nacabaro.vbhelper.utils.ImageBitmapData
import com.github.nacabaro.vbhelper.R
@Composable @Composable
fun ActionScreen( fun ActionScreen(
topBannerText: String, topBannerText: String,
detectedTransportMessage: String?,
transferStatusMessage: String?, transferStatusMessage: String?,
characterPreview: ImageBitmapData? = null,
onClickCancel: () -> Unit, onClickCancel: () -> Unit,
isTransferring: Boolean = true,
) { ) {
Scaffold ( val context = LocalContext.current
topBar = { val transferHaptics = remember(context) { TransferHaptics(context) }
TopBanner(
text = topBannerText, LaunchedEffect(transferStatusMessage) {
onBackClick = onClickCancel 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) {
) { innerPadding -> TransferAnimationScreen(
Column ( topBannerText = topBannerText,
horizontalAlignment = Alignment.CenterHorizontally, detectedTransportMessage = detectedTransportMessage,
verticalArrangement = Arrangement.Center, transferStatusMessage = transferStatusMessage,
modifier = Modifier characterPreview = characterPreview,
.padding(innerPadding) onClickCancel = onClickCancel,
.fillMaxSize() isTransferring = true
) { )
Text(stringResource(R.string.action_place_near_reader)) } else {
if (!transferStatusMessage.isNullOrBlank()) { TransferAnimationScreen(
Text( topBannerText = topBannerText,
text = transferStatusMessage, detectedTransportMessage = detectedTransportMessage,
modifier = Modifier.padding(top = 8.dp, start = 16.dp, end = 16.dp) transferStatusMessage = transferStatusMessage,
characterPreview = characterPreview,
onClickCancel = onClickCancel,
isTransferring = false
) )
}
Button(
onClick = onClickCancel,
modifier = Modifier.padding(16.dp)
) {
Text(stringResource(R.string.action_cancel))
}
}
} }
} }

View File

@ -3,17 +3,21 @@ package com.github.nacabaro.vbhelper.screens.scanScreen.screens
import android.util.Log import android.util.Log
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import com.github.nacabaro.vbhelper.ActivityLifecycleListener import com.github.nacabaro.vbhelper.ActivityLifecycleListener
import com.github.nacabaro.vbhelper.domain.card.Card import com.github.nacabaro.vbhelper.domain.card.Card
import com.github.nacabaro.vbhelper.screens.cardScreen.ChooseCard import com.github.nacabaro.vbhelper.screens.cardScreen.ChooseCard
import com.github.nacabaro.vbhelper.screens.scanScreen.DetectedTransport
import com.github.nacabaro.vbhelper.screens.scanScreen.SCAN_SCREEN_ACTIVITY_LIFECYCLE_LISTENER import com.github.nacabaro.vbhelper.screens.scanScreen.SCAN_SCREEN_ACTIVITY_LIFECYCLE_LISTENER
import com.github.nacabaro.vbhelper.screens.scanScreen.ScanScreenController import com.github.nacabaro.vbhelper.screens.scanScreen.ScanScreenController
import com.github.nacabaro.vbhelper.screens.scanScreen.TransferHaptics
import com.github.nacabaro.vbhelper.R import com.github.nacabaro.vbhelper.R
private const val TAG = "ReadingScreen" private const val TAG = "ReadingScreen"
@ -24,8 +28,17 @@ fun ReadingScreen(
onCancel: () -> Unit, onCancel: () -> Unit,
onComplete: () -> Unit onComplete: () -> Unit
) { ) {
val context = LocalContext.current
val transferHaptics = remember(context) { TransferHaptics(context) }
val secrets by scanScreenController.secretsFlow.collectAsState(null) val secrets by scanScreenController.secretsFlow.collectAsState(null)
val transferStatus by scanScreenController.transferStatusFlow.collectAsState(null) val transferStatus by scanScreenController.transferStatusFlow.collectAsState(null)
val detectedTransport by scanScreenController.detectedTransportFlow.collectAsState()
val transportMessage = when (detectedTransport) {
DetectedTransport.NFC_A -> stringResource(R.string.scan_transport_bandai_bracelet)
DetectedTransport.ISO_DEP -> stringResource(R.string.scan_transport_vitalwear_hce)
DetectedTransport.UNKNOWN -> null
}
var cardsRead by remember { mutableStateOf<List<Card>?>(null) } var cardsRead by remember { mutableStateOf<List<Card>?>(null) }
@ -33,6 +46,12 @@ fun ReadingScreen(
var isDoneReadingCharacter by remember { mutableStateOf(false) } var isDoneReadingCharacter by remember { mutableStateOf(false) }
var cardSelectScreen by remember { mutableStateOf(false) } var cardSelectScreen by remember { mutableStateOf(false) }
LaunchedEffect(readingScreen) {
if (readingScreen) {
transferHaptics.onTransferStart()
}
}
fun startReadIfNeeded() { fun startReadIfNeeded() {
val availableSecrets = secrets val availableSecrets = secrets
if (availableSecrets == null) { if (availableSecrets == null) {
@ -52,11 +71,13 @@ fun ReadingScreen(
secrets = availableSecrets, secrets = availableSecrets,
onComplete = { onComplete = {
Log.d(TAG, "onClickRead.onComplete: marking read complete") Log.d(TAG, "onClickRead.onComplete: marking read complete")
transferHaptics.onTransferComplete()
readingScreen = false readingScreen = false
isDoneReadingCharacter = true isDoneReadingCharacter = true
}, },
onMultipleCards = { cards -> onMultipleCards = { cards ->
Log.d(TAG, "onClickRead.onMultipleCards: showing card select (count=${cards.size})") Log.d(TAG, "onClickRead.onMultipleCards: showing card select (count=${cards.size})")
transferHaptics.onTransferProgress()
cardsRead = cards cardsRead = cards
readingScreen = false readingScreen = false
cardSelectScreen = true cardSelectScreen = true
@ -119,13 +140,15 @@ fun ReadingScreen(
if (readingScreen) { if (readingScreen) {
ActionScreen( ActionScreen(
topBannerText = stringResource(R.string.reading_character_title), topBannerText = stringResource(R.string.reading_character_title),
detectedTransportMessage = transportMessage,
transferStatusMessage = transferStatus, transferStatusMessage = transferStatus,
) { onClickCancel = {
Log.d(TAG, "ActionScreen.onCancel: cancelRead + onCancel") Log.d(TAG, "ActionScreen.onCancel: cancelRead + onCancel")
readingScreen = false readingScreen = false
scanScreenController.cancelRead() scanScreenController.cancelRead()
onCancel() onCancel()
} }
)
} else if (cardSelectScreen) { } else if (cardSelectScreen) {
ChooseCard( ChooseCard(
cards = cardsRead!!, cards = cardsRead!!,

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

View File

@ -5,6 +5,7 @@ import android.net.Uri
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -12,8 +13,12 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -67,6 +72,7 @@ fun SettingsScreen(
settingsScreenController.onClickImportCard() settingsScreenController.onClickImportCard()
} }
SettingsSection(title = stringResource(R.string.settings_section_about)) SettingsSection(title = stringResource(R.string.settings_section_about))
SettingsEntry( SettingsEntry(
title = stringResource(R.string.settings_credits_title), title = stringResource(R.string.settings_credits_title),
@ -98,6 +104,38 @@ fun SettingsScreen(
) { ) {
settingsScreenController.onClickImportDatabase() settingsScreenController.onClickImportDatabase()
} }
SettingsSection(title = stringResource(R.string.settings_section_companion_tools))
SettingsEntry(
title = stringResource(R.string.settings_companion_import_card_image_title),
description = stringResource(R.string.settings_companion_import_card_image_desc)
) {
settingsScreenController.onClickCompanionImportCardImage()
}
SettingsEntry(
title = stringResource(R.string.settings_companion_import_firmware_title),
description = stringResource(R.string.settings_companion_import_firmware_desc)
) {
settingsScreenController.onClickCompanionImportFirmware()
}
SettingsEntry(
title = stringResource(R.string.settings_companion_send_watch_logs_title),
description = stringResource(R.string.settings_companion_send_watch_logs_desc)
) {
settingsScreenController.onClickCompanionSendWatchLogs()
}
SettingsEntry(
title = stringResource(R.string.settings_companion_send_phone_logs_title),
description = stringResource(R.string.settings_companion_send_phone_logs_desc)
) {
settingsScreenController.onClickCompanionSendPhoneLogs()
}
SettingsSwitchEntry(
title = stringResource(R.string.settings_enable_dim_to_bem_title),
description = stringResource(R.string.settings_enable_dim_to_bem_desc),
isEnabled = settingsScreenController,
onToggle = { settingsScreenController.onToggleDimToBemConversion(it) }
)
} }
} }
} }
@ -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 @Composable
fun SettingsSection( fun SettingsSection(
title: String title: String

View File

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

View File

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

View File

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

View File

@ -1,11 +1,14 @@
package com.github.nacabaro.vbhelper.screens.settingsScreen.controllers package com.github.nacabaro.vbhelper.screens.settingsScreen.controllers
import androidx.room.Room
import android.database.sqlite.SQLiteDatabase
import android.net.Uri import android.net.Uri
import android.provider.OpenableColumns import android.provider.OpenableColumns
import android.util.Log import android.util.Log
import android.widget.Toast import android.widget.Toast
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.github.nacabaro.vbhelper.database.AppDatabase
import com.github.nacabaro.vbhelper.di.VBHelper import com.github.nacabaro.vbhelper.di.VBHelper
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -18,6 +21,19 @@ class DatabaseManagementController(
val application: VBHelper val application: VBHelper
) { ) {
private val roomDbName = "internalDb" private val roomDbName = "internalDb"
private val requiredLegacyVersions = setOf(1, 2, 3, 4, 5)
private val requiredCoreTables = setOf(
"UserCharacter",
"Character",
"Card",
"CardCharacter",
"TransformationHistory",
)
private data class ValidationResult(
val isValid: Boolean,
val reason: String? = null,
)
fun exportDatabase( destinationUri: Uri) { fun exportDatabase( destinationUri: Uri) {
componentActivity.lifecycleScope.launch(Dispatchers.IO) { componentActivity.lifecycleScope.launch(Dispatchers.IO) {
@ -59,6 +75,19 @@ class DatabaseManagementController(
return@launch return@launch
} }
val validationResult = validateImportBackup(sourceUri)
if (!validationResult.isValid) {
componentActivity.runOnUiThread {
Toast.makeText(
componentActivity,
validationResult.reason
?: "Import blocked: backup is incompatible with this VBH-VW version.",
Toast.LENGTH_LONG
).show()
}
return@launch
}
application.container.db.close() application.container.db.close()
val dbPath = componentActivity.getDatabasePath(roomDbName) val dbPath = componentActivity.getDatabasePath(roomDbName)
@ -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) { private fun copyFile(inputStream: InputStream, outputStream: OutputStream) {
val buffer = ByteArray(1024) val buffer = ByteArray(1024)
var bytesRead: Int var bytesRead: Int

View File

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

View File

@ -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.cfogrady.vitalwear.protos.Character
import com.github.nacabaro.vbhelper.database.AppDatabase import com.github.nacabaro.vbhelper.database.AppDatabase
import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData import com.github.nacabaro.vbhelper.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.github.nacabaro.vbhelper.utils.DeviceType
import com.google.protobuf.ByteString
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
internal const val DEFAULT_VITALWEAR_TRAINING_TIME_SECONDS = 100L * 60L * 60L internal const val DEFAULT_VITALWEAR_TRAINING_TIME_SECONDS = 100L * 60L * 60L
@ -34,15 +31,25 @@ internal fun resolveTrainingSeconds(
class VitalWearCharacterExporter( class VitalWearCharacterExporter(
private val database: AppDatabase private val database: AppDatabase
) { ) {
private val transferPolicyResolver = CharacterTransferPolicyResolver(database.characterTransferPolicyDao())
/** /**
* Builds a Character proto from the stored character data. * Builds a Character proto from the stored character data.
* Used by the HCE ISO-DEP transfer path. * 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 characterWithSprites = database.userCharacterDao().getCharacterWithSprites(characterId)
val userCharacter = database.userCharacterDao().getCharacter(characterId) val userCharacter = database.userCharacterDao().getCharacter(characterId)
val cardCharacter = database.characterDao().getCharacterById(userCharacter.charId)
?: error("Card character not found for user character $characterId")
val sprite = database.spriteDao().getSpriteById(cardCharacter.spriteId)
?: error("Sprite not found for user character $characterId")
val characterInfo = database.characterDao().getCharacterInfo(userCharacter.charId) val characterInfo = database.characterDao().getCharacterInfo(userCharacter.charId)
val vwSettings = database.vitalWearSettingsDao().getByCharacterId(characterId) val vwSettings = database.vitalWearSettingsDao().getByCharacterId(characterId)
val card = database.cardDao().getCardByCharacterIdSync(characterId) val card = database.cardDao().getCardByCharacterIdSync(characterId)
@ -50,17 +57,25 @@ class VitalWearCharacterExporter(
val cardProgress = database.cardProgressDao().getCardProgressSync(card.id) ?: 0 val cardProgress = database.cardProgressDao().getCardProgressSync(card.id) ?: 0
val beData = database.userCharacterDao().getBeDataOrNull(characterId) val beData = database.userCharacterDao().getBeDataOrNull(characterId)
val vbData = database.userCharacterDao().getVbDataOrNull(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( val normalizedTransformationCountdownMinutes = normalizeTransformationCountdownMinutes(
transformationCountdownMinutes = userCharacter.transformationCountdown, transformationCountdownMinutes = userCharacter.transformationCountdown,
hasPossibleTransformations = hasPossibleTransformations(userCharacter.charId), 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() return Character.newBuilder()
.setCardId(card.cardId) .setCardId(card.cardId)
.setCardName(card.name) .setCardName(card.name)
@ -68,7 +83,7 @@ class VitalWearCharacterExporter(
Character.CharacterStats.newBuilder() Character.CharacterStats.newBuilder()
.setSlotId(characterInfo.charId) .setSlotId(characterInfo.charId)
.setVitals(characterWithSprites.vitalPoints) .setVitals(characterWithSprites.vitalPoints)
.setTrainingTimeRemainingInSeconds(resolveTrainingSeconds(userCharacter.characterType, beData)) .setTrainingTimeRemainingInSeconds(resolveTrainingSeconds(transferDeviceType, beData))
.setTimeUntilNextTransformation(normalizedTransformationCountdownMinutes.toLong() * 60L) .setTimeUntilNextTransformation(normalizedTransformationCountdownMinutes.toLong() * 60L)
.setTrainedBp(resolveTrainedBp(beData)) .setTrainedBp(resolveTrainedBp(beData))
.setTrainedHp(resolveTrainedHp(beData)) .setTrainedHp(resolveTrainedHp(beData))
@ -81,7 +96,7 @@ class VitalWearCharacterExporter(
.setTotalWins(characterWithSprites.totalBattlesWon) .setTotalWins(characterWithSprites.totalBattlesWon)
.setCurrentPhaseWins(characterWithSprites.currentPhaseBattlesWon) .setCurrentPhaseWins(characterWithSprites.currentPhaseBattlesWon)
.setMood(characterWithSprites.mood) .setMood(characterWithSprites.mood)
.setDeviceType(exportFormat.toTransferDeviceType()) .setDeviceType(transferDeviceType.toTransferDeviceType())
.setAgeInDays(userCharacter.ageInDays.coerceAtLeast(0)) .setAgeInDays(userCharacter.ageInDays.coerceAtLeast(0))
.setActivityLevel(userCharacter.activityLevel.coerceAtLeast(0)) .setActivityLevel(userCharacter.activityLevel.coerceAtLeast(0))
.setHeartRateCurrent(userCharacter.heartRateCurrent.coerceAtLeast(0)) .setHeartRateCurrent(userCharacter.heartRateCurrent.coerceAtLeast(0))
@ -106,12 +121,7 @@ class VitalWearCharacterExporter(
.setFirmwareMajorVersion(beData?.majorVersion ?: 0) .setFirmwareMajorVersion(beData?.majorVersion ?: 0)
.build() .build()
) )
.setSettings( .setSettings(settingsBuilder.build())
Character.Settings.newBuilder()
.setTrainingInBackground(vwSettings?.trainingInBackground ?: false)
.setAllowedBattles(resolveAllowedBattles(vwSettings?.allowedBattles))
.build()
)
.putMaxAdventureCompletedByCard(card.name, (cardProgress - 1).coerceAtLeast(0)) .putMaxAdventureCompletedByCard(card.name, (cardProgress - 1).coerceAtLeast(0))
.addAllTransformationHistory( .addAllTransformationHistory(
database.userCharacterDao().getTransformationHistoryForExport(characterId).map { database.userCharacterDao().getTransformationHistoryForExport(characterId).map {
@ -122,6 +132,43 @@ class VitalWearCharacterExporter(
.build() .build()
} }
) )
.setTransferCard(
Character.TransferCard.newBuilder()
.setCardId(card.cardId)
.setCardName(card.name)
.setIsBem(card.isBEm)
.setStageCount(card.stageCount)
.setFranchise(0)
.build()
)
.setTransferSpecies(
Character.TransferSpecies.newBuilder()
.setSlotId(characterInfo.charId)
.setStage(cardCharacter.stage)
.setAttribute(cardCharacter.attribute.ordinal)
.setBaseHp(cardCharacter.baseHp)
.setBaseBp(cardCharacter.baseBp)
.setBaseAp(cardCharacter.baseAp)
.setNameSprite(ByteString.copyFrom(cardCharacter.nameSprite))
.setNameSpriteWidth(cardCharacter.nameWidth)
.setNameSpriteHeight(cardCharacter.nameHeight)
.setIdle1(ByteString.copyFrom(sprite.spriteIdle1))
.setIdle2(ByteString.copyFrom(sprite.spriteIdle2))
.setWalk1(ByteString.copyFrom(sprite.spriteWalk1))
.setWalk2(ByteString.copyFrom(sprite.spriteWalk2))
.setRun1(ByteString.copyFrom(sprite.spriteRun1))
.setRun2(ByteString.copyFrom(sprite.spriteRun2))
.setTrain1(ByteString.copyFrom(sprite.spriteTrain1))
.setTrain2(ByteString.copyFrom(sprite.spriteTrain2))
.setWin(ByteString.copyFrom(sprite.spriteHappy))
.setDown(ByteString.copyFrom(sprite.spriteSleep))
.setAttack(ByteString.copyFrom(sprite.spriteAttack))
.setDodge(ByteString.copyFrom(sprite.spriteDodge))
.setSplash(ByteString.copyFrom(sprite.spriteIdle1))
.setSpriteWidth(sprite.width)
.setSpriteHeight(sprite.height)
.build()
)
.build() .build()
} }

View File

@ -5,12 +5,13 @@ import com.github.cfogrady.vitalwear.common.data.SharedTransferSeenDao
import com.github.cfogrady.vitalwear.protos.Character import com.github.cfogrady.vitalwear.protos.Character
import com.github.nacabaro.vbhelper.database.AppDatabase import com.github.nacabaro.vbhelper.database.AppDatabase
import com.github.nacabaro.vbhelper.domain.card.Card import com.github.nacabaro.vbhelper.domain.card.Card
import com.github.nacabaro.vbhelper.domain.card.CardCharacter
import com.github.nacabaro.vbhelper.domain.card.CardProgress
import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData
import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter
import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData
import com.github.nacabaro.vbhelper.domain.device_data.VitalWearCharacterSettings import com.github.nacabaro.vbhelper.domain.device_data.VitalWearCharacterSettings
import com.github.nacabaro.vbhelper.transfer.CharacterTransferPolicyResolver import com.github.nacabaro.vbhelper.domain.characters.Sprite
import com.github.nacabaro.vbhelper.transfer.ExportFormat
import com.github.nacabaro.vbhelper.utils.DeviceType import com.github.nacabaro.vbhelper.utils.DeviceType
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@ -20,8 +21,6 @@ class VitalWearCharacterImporter(
private val database: AppDatabase, private val database: AppDatabase,
private val transferSeenDao: SharedTransferSeenDao, private val transferSeenDao: SharedTransferSeenDao,
) { ) {
private val transferPolicyResolver = CharacterTransferPolicyResolver(database.characterTransferPolicyDao())
data class ImportResult( data class ImportResult(
val success: Boolean, val success: Boolean,
val message: String val message: String
@ -29,15 +28,16 @@ class VitalWearCharacterImporter(
fun importCharacter(character: Character): ImportResult { fun importCharacter(character: Character): ImportResult {
val importedCard = resolveCard(character) val importedCard = resolveCard(character)
?: createPlaceholderCard(character)
?: return ImportResult( ?: return ImportResult(
success = false, success = false,
message = "Matching card not found in VBHelper. Import that card first." message = "Matching card not found in VBHelper. Import that card first."
) )
val slotId = character.characterStats.slotId val slotId = character.characterStats.slotId
val cardCharacter = runCatching { val cardCharacter = database.characterDao().getCharacterByMonIndexOrNull(slotId, importedCard.id)
database.characterDao().getCharacterByMonIndex(slotId, importedCard.id) ?: createPlaceholderCharacter(importedCard, character)
}.getOrNull() ?: return ImportResult( ?: return ImportResult(
success = false, success = false,
message = "Character slot $slotId was not found on card ${importedCard.name}." message = "Character slot $slotId was not found on card ${importedCard.name}."
) )
@ -62,11 +62,6 @@ class VitalWearCharacterImporter(
fallbackIsBeCharacter = fallbackIsBeCharacter, fallbackIsBeCharacter = fallbackIsBeCharacter,
) )
val isBeCharacter = deviceType == DeviceType.BEDevice val isBeCharacter = deviceType == DeviceType.BEDevice
val observedImportFormat = resolveObservedHceImportFormat(
transferDeviceType = character.characterStats.deviceType,
importedCard = importedCard,
hasBePayloadStats = hasBePayloadStats,
)
val userCharacterId = database.userCharacterDao().insertCharacterData( val userCharacterId = database.userCharacterDao().insertCharacterData(
UserCharacter( 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) { if (isBeCharacter) {
val abilityRarity = resolveAbilityRarity(character.characterStats.abilityRarity) val abilityRarity = resolveAbilityRarity(character.characterStats.abilityRarity)
database.userCharacterDao().insertBECharacterData( database.userCharacterDao().insertBECharacterData(
@ -129,9 +139,138 @@ class VitalWearCharacterImporter(
majorVersion = character.characterStats.firmwareMajorVersion majorVersion = character.characterStats.firmwareMajorVersion
) )
) )
} else { }
database.userCharacterDao().insertVBCharacterData(
VBCharacterData( 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,
)
)
}
val vbProfile = VBCharacterData(
id = userCharacterId, id = userCharacterId,
generation = if (character.characterStats.generation > 0) { generation = if (character.characterStats.generation > 0) {
character.characterStats.generation character.characterStats.generation
@ -144,16 +283,35 @@ class VitalWearCharacterImporter(
character.characterStats.trainedPp.coerceAtLeast(0) character.characterStats.trainedPp.coerceAtLeast(0)
} }
) )
) database.userCharacterDao().insertVBCharacterData(vbProfile)
}
runBlocking { if (forcedDeviceType == DeviceType.BEDevice) {
database.characterTransferPolicyDao().upsert( val abilityRarity = resolveAbilityRarity(character.characterStats.abilityRarity)
transferPolicyResolver.policyForHceImport( database.userCharacterDao().insertBECharacterData(
characterId = userCharacterId, BECharacterData(
importedCardIsBem = importedCard.isBEm, id = userCharacterId,
resolvedDeviceType = deviceType, trainingHp = character.characterStats.trainedHp,
observedFormat = observedImportFormat, 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) { if (insertedTransformationCount == 0) {
// Keep HomeScreen renderable for freshly imported characters with empty history.
database.userCharacterDao().insertTransformation( database.userCharacterDao().insertTransformation(
userCharacterId, userCharacterId,
slotId, slotId,
@ -252,6 +409,131 @@ class VitalWearCharacterImporter(
return resolveCard(incomingCardName, incomingCardId) 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 { private fun secondsToMinutes(seconds: Long): Int {
if (seconds <= 0L) { if (seconds <= 0L) {
return 0 return 0
@ -290,23 +572,6 @@ class VitalWearCharacterImporter(
return rarities.getOrElse(rawValue) { resolveDefaultAbilityRarity() } 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) { private fun markSeen(cardName: String, slotId: Int, cardId: Long, timestamp: Long) {
database.dexDao().insertCharacter(slotId, cardId, timestamp) database.dexDao().insertCharacter(slotId, cardId, timestamp)
transferSeenDao.markSeen(cardName, slotId, timestamp) transferSeenDao.markSeen(cardName, slotId, timestamp)

View File

@ -2,6 +2,8 @@
import android.nfc.tech.IsoDep import android.nfc.tech.IsoDep
import android.util.Log import android.util.Log
import com.github.cfogrady.vitalwear.protos.Character 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. * Phone-side ISO-DEP client that drives the VitalWear HCE session.
* Mirrors the APDU protocol defined in VitalWearHceProtocol on the watch. * 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_READ_CHUNK: Byte = 0x20
private val INS_WRITE_CHUNK: Byte = 0x30 private val INS_WRITE_CHUNK: Byte = 0x30
private val INS_COMMIT: Byte = 0x40 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_WATCH_TO_PHONE: Byte = 0x01
private val MODE_PHONE_TO_WATCH: Byte = 0x02 private val MODE_PHONE_TO_WATCH: Byte = 0x02
private val VERSION: Byte = 0x01 private val VERSION: Byte = 0x01
private val SW_OK = 0x9000 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. /** 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. * 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. */ /** WRITE: watch has called armReceive() — phone pushes a character to the watch. */
fun sendCharacterToWatch(character: Character) { fun sendCharacterToWatch(character: Character, closeSyncUiAfterCommit: Boolean = true) {
selectAid() 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 payload = character.toByteArray()
val negResponse = sendApdu(INS_NEGOTIATE, byteArrayOf(MODE_PHONE_TO_WATCH, VERSION)) val negResponse = sendApdu(INS_NEGOTIATE, byteArrayOf(MODE_PHONE_TO_WATCH, VERSION))
requireOk(negResponse, "NEGOTIATE") requireOk(negResponse, "NEGOTIATE")
@ -82,6 +99,40 @@ class VitalWearHceReaderClient(private val isoDep: IsoDep) {
offset = chunkEnd offset = chunkEnd
} }
requireOk(sendApdu(INS_COMMIT, byteArrayOf()), "COMMIT") 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 { 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 { object ARGBMasks {
@ -64,7 +64,7 @@ fun BitmapData.getObscuredBitmap(): Bitmap {
argbPixels[i] = BLACK argbPixels[i] = BLACK
} }
} }
return Bitmap.createBitmap(argbPixels, this.width, this.height, Bitmap.Config.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; string card_name = 1;
int32 card_id = 2; int32 card_id = 2;
message TransferCard {
int32 card_id = 1;
string card_name = 2;
bool is_bem = 3;
int32 stage_count = 4;
int32 franchise = 5;
}
message TransferSpecies {
int32 slot_id = 1;
int32 stage = 2;
int32 attribute = 3;
int32 base_hp = 4;
int32 base_bp = 5;
int32 base_ap = 6;
bytes name_sprite = 7;
int32 name_sprite_width = 8;
int32 name_sprite_height = 9;
bytes idle1 = 10;
bytes idle2 = 11;
bytes walk1 = 12;
bytes walk2 = 13;
bytes run1 = 14;
bytes run2 = 15;
bytes train1 = 16;
bytes train2 = 17;
bytes win = 18;
bytes down = 19;
bytes attack = 20;
bytes dodge = 21;
bytes splash = 22;
int32 sprite_width = 23;
int32 sprite_height = 24;
}
message CharacterStats { message CharacterStats {
enum TransferDeviceType { enum TransferDeviceType {
TRANSFER_DEVICE_TYPE_UNSPECIFIED = 0; TRANSFER_DEVICE_TYPE_UNSPECIFIED = 0;
@ -77,4 +112,8 @@ message Character {
repeated TransformationEvent transformation_history = 5; repeated TransformationEvent transformation_history = 5;
map<string, int32> max_adventure_completed_by_card = 6; map<string, int32> max_adventure_completed_by_card = 6;
optional TransferCard transfer_card = 7;
optional TransferSpecies transfer_species = 8;
} }

View File

@ -178,7 +178,7 @@
<string name="dex_chara_name_icon_description">Nome do personagem</string> <string name="dex_chara_name_icon_description">Nome do personagem</string>
<string name="dex_chara_stats">HP: %1$d, BP: %2$d, AP: %3$d</string> <string name="dex_chara_stats">HP: %1$d, BP: %2$d, AP: %3$d</string>
<string name="dex_chara_stage_attribute">Stg: %1$s, Atr: %2$s</string> <string name="dex_chara_stage_attribute">Stg: %1$s, Atr: %2$s</string>
<string name="dex_chara_unknown_name">\?\?\?\?\?\?\?\?\?\?\?\?\?\?\?\?</string> <string name="dex_chara_unknown_name">________________</string>
<string name="dex_chara_stage_attribute_unknown">Stg: -, Atr: -</string> <string name="dex_chara_stage_attribute_unknown">Stg: -, Atr: -</string>
<string name="dex_chara_stats_unknown">HP: -, BP: -, AP: -</string> <string name="dex_chara_stats_unknown">HP: -, BP: -, AP: -</string>
<string name="dex_chara_requirements"> <string name="dex_chara_requirements">

View File

@ -68,14 +68,17 @@
<string name="scan_transfer_complete_remove">Transfer complete. You can remove the phone now.</string> <string name="scan_transfer_complete_remove">Transfer complete. You can remove the phone now.</string>
<string name="scan_transfer_failed_try_again">Transfer failed. Reposition devices and try again.</string> <string name="scan_transfer_failed_try_again">Transfer failed. Reposition devices and try again.</string>
<string name="scan_transfer_cancelled">Transfer cancelled.</string> <string name="scan_transfer_cancelled">Transfer cancelled.</string>
<string name="scan_transport_bandai_bracelet">Detected target: Bandai Vital Bracelet (NFC-A)</string>
<string name="scan_transport_vitalwear_hce">Detected target: VitalWear (HCE)</string>
<string name="settings_title">Settings</string> <string name="settings_title">Settings</string>
<string name="settings_section_nfc">NFC Communication</string> <string name="settings_section_nfc">NFC Communication</string>
<string name="settings_section_dim_bem">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_about">About and credits</string>
<string name="settings_section_data">Data management</string> <string name="settings_section_data">Data management</string>
<string name="settings_section_companion_tools">VitalWear companion tools</string>
<string name="settings_import_apk_title">Import APK</string> <string name="settings_import_apk_title">Import APK</string>
<string name="settings_import_apk_desc"> <string name="settings_import_apk_desc">
@ -84,7 +87,12 @@
<string name="settings_import_card_title">Import card</string> <string name="settings_import_card_title">Import card</string>
<string name="settings_import_card_desc"> <string name="settings_import_card_desc">
Import DiM/BEm 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>
<string name="settings_credits_title">Credits</string> <string name="settings_credits_title">Credits</string>
@ -103,6 +111,27 @@
Import application database Import application database
</string> </string>
<string name="settings_companion_import_card_image_title">Import card to VitalWear</string>
<string name="settings_companion_import_card_image_desc">Import Dim/Bem to Vitalwear</string>
<string name="settings_companion_import_firmware_title">Import firmware to VitalWear</string>
<string name="settings_companion_import_firmware_desc">Import firmware to Watch (10b for now)</string>
<string name="settings_companion_send_watch_logs_title">Send watch logs</string>
<string name="settings_companion_send_watch_logs_desc">Request logs from a connected VitalWear watch and share them</string>
<string name="settings_companion_send_phone_logs_title">Send phone logs</string>
<string name="settings_companion_send_phone_logs_desc">Share the latest VBHelper phone log file</string>
<string name="companion_logs_warning">WARNING: Logs have personal health information such as heart rate measurements and steps per day.</string>
<string name="companion_logs_no_files">No log files found</string>
<string name="companion_logs_failed_request">Failed to send log request to watch</string>
<string name="companion_logs_receive_failed">Error receiving log file</string>
<string name="companion_firmware_no_watch">No connected watch found</string>
<string name="companion_firmware_sent_success">Firmware sent successfully</string>
<string name="companion_card_import_success">Imported Successfully</string>
<string name="companion_validation_connect_vb">Connect to VB</string>
<string name="companion_validation_insert_card">Insert Card in VB and Reconnect</string>
<string name="companion_validation_success">Card Validated Successfully</string>
<string name="companion_loading_select_firmware">Select firmware file</string>
<string name="companion_transfer_disabled_message">Character transfer is disabled in companion. Use watch Transfer instead.</string>
<string name="credits_title">Credits</string> <string name="credits_title">Credits</string>
<string name="credits_section_reverse_engineering">Reverse engineering</string> <string name="credits_section_reverse_engineering">Reverse engineering</string>
<string name="credits_section_app_development">Application development</string> <string name="credits_section_app_development">Application development</string>
@ -187,7 +216,7 @@
<string name="dex_chara_name_icon_description">Character name</string> <string name="dex_chara_name_icon_description">Character name</string>
<string name="dex_chara_stats">HP: %1$d, BP: %2$d, AP: %3$d</string> <string name="dex_chara_stats">HP: %1$d, BP: %2$d, AP: %3$d</string>
<string name="dex_chara_stage_attribute">Stg: %1$s, Atr: %2$s</string> <string name="dex_chara_stage_attribute">Stg: %1$s, Atr: %2$s</string>
<string name="dex_chara_unknown_name">\?\?\?\?\?\?\?\?\?\?\?\?\?\?\?\?</string> <string name="dex_chara_unknown_name">________________</string>
<string name="dex_chara_stage_attribute_unknown">Stg: -, Atr: -</string> <string name="dex_chara_stage_attribute_unknown">Stg: -, Atr: -</string>
<string name="dex_chara_stats_unknown">HP: -, BP: -, AP: -</string> <string name="dex_chara_stats_unknown">HP: -, BP: -, AP: -</string>
<string name="dex_chara_requirements"> <string name="dex_chara_requirements">

View File

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

View File

@ -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" roomRuntime = "2.8.4"
vbNfcReader = "0.2.0-SNAPSHOT" vbNfcReader = "0.2.0-SNAPSHOT"
dimReader = "2.0.0" 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] [libraries]
androidx-compose-material = { module = "androidx.compose.material:material" } 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" } protobuf-javalite = { module = "com.google.protobuf:protobuf-javalite", version.ref = "protobufJavalite" }
vb-nfc-reader = { module = "com.github.cfogrady:vb-nfc-reader", version.ref = "vbNfcReader" } vb-nfc-reader = { module = "com.github.cfogrady:vb-nfc-reader", version.ref = "vbNfcReader" }
dim-reader = { module = "com.github.cfogrady:vb-dim-reader", version.ref = "dimReader" } dim-reader = { module = "com.github.cfogrady:vb-dim-reader", version.ref = "dimReader" }
play-services-wearable = { module = "com.google.android.gms:play-services-wearable", version.ref = "playServicesWearable" }
kotlinx-coroutines-play-services = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-play-services", version.ref = "kotlinxCoroutinesPlayServices" }
timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
tiny-log = { module = "org.tinylog:tinylog-api", version.ref = "tinyLog" }
tiny-log-impl = { module = "org.tinylog:tinylog-impl", version.ref = "tinyLog" }
slf4j-timber = { module = "at.favre.lib:slf4j-timber", version.ref = "slf4jTimber" }
slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4jApi" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }

View File

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