From 9aa62f0d81326c8ea5ab836a0a2ba4da07cdb1e7 Mon Sep 17 00:00:00 2001
From: Taveon Nelson <74945303+457R0@users.noreply.github.com>
Date: Mon, 18 May 2026 05:17:08 -0500
Subject: [PATCH] companion reup
---
app/build.gradle.kts | 18 +-
app/src/main/AndroidManifest.xml | 19 +-
.../github/nacabaro/vbhelper/MainActivity.kt | 48 +-
.../vbhelper/battle/AnimatedSpriteImage.kt | 17 +-
.../vbhelper/battle/ArenaBattleSystem.kt | 68 +--
.../vbhelper/battle/AuthInterceptor.kt | 1 +
.../vbhelper/battle/AuthenticateResponse.kt | 27 +-
.../vbhelper/battle/DigimonAnimationState.kt | 44 +-
.../vbhelper/battle/HitEffectComposables.kt | 4 +-
.../vbhelper/battle/HitEffectSpriteManager.kt | 118 +---
.../battle/IndividualSpriteManager.kt | 70 +--
.../vbhelper/battle/RetrofitHelper.kt | 147 +++--
.../vbhelper/battle/SpriteFileManager.kt | 301 +----------
.../nacabaro/vbhelper/battle/SpriteImage.kt | 6 +-
.../companion/card/ByteManipulation.kt | 46 ++
.../card/CompanionImportCardActivity.kt | 400 ++++++++++++++
.../card/CompanionValidateCardActivity.kt | 162 ++++++
.../vbhelper/companion/card/VBNfcData.kt | 48 ++
.../vbhelper/companion/common/ChannelTypes.kt | 10 +
.../CompanionFirmwareImportActivity.kt | 140 +++++
.../vbhelper/companion/logging/TinyLogTree.kt | 77 +++
.../companion/logs/CompanionLogService.kt | 122 +++++
.../logs/CompanionWatchLogsActivity.kt | 41 ++
.../logs/WatchCommunicationService.kt | 21 +
.../CompanionCharacterImportActivity.kt | 23 +
.../companion/ui/CompanionConfirmation.kt | 42 ++
.../vbhelper/companion/ui/CompanionLoading.kt | 32 ++
.../validation/CompanionValidatedDatabase.kt | 14 +
.../companion/validation/ValidatedCardDao.kt | 16 +
.../validation/ValidatedCardEntity.kt | 13 +
.../validation/ValidatedCardManager.kt | 35 ++
.../nacabaro/vbhelper/daos/AdventureDao.kt | 2 +
.../nacabaro/vbhelper/daos/CardFusionsDao.kt | 3 +-
.../nacabaro/vbhelper/daos/CharacterDao.kt | 6 +
.../nacabaro/vbhelper/daos/SpriteDao.kt | 3 +
.../vbhelper/daos/UserCharacterDao.kt | 11 +
.../nacabaro/vbhelper/database/AppDatabase.kt | 35 +-
.../nacabaro/vbhelper/di/AppContainer.kt | 2 +
.../vbhelper/di/DefaultAppContainer.kt | 4 +
.../github/nacabaro/vbhelper/di/VBHelper.kt | 25 +
.../vbhelper/domain/card/Background.kt | 2 +
.../vbhelper/domain/card/CardAdventure.kt | 5 +
.../vbhelper/domain/card/CardCharacter.kt | 3 +-
.../vbhelper/domain/card/CardFusions.kt | 5 +
.../domain/card/PossibleTransformations.kt | 5 +
.../domain/device_data/SpecialMissions.kt | 2 +
.../device_data/TransformationHistory.kt | 5 +
.../domain/device_data/UnifiedCharacter.kt | 31 ++
.../domain/device_data/UserCharacter.kt | 2 +
.../domain/device_data/VitalsHistory.kt | 2 +
.../vbhelper/navigation/AppNavigation.kt | 24 +-
.../navigation/BottomNavigationBar.kt | 41 +-
.../navigation/NavigationExtensions.kt | 0
.../vbhelper/navigation/NavigationItems.kt | 4 +-
.../vbhelper/screens/BattlesScreen.kt | 510 +++++++++++-------
.../screens/homeScreens/HomeScreen.kt | 38 +-
.../homeScreens/screens/UnifiedHomeScreen.kt | 241 +++++++++
.../screens/itemsScreen/ItemElement.kt | 56 ++
.../screens/itemsScreen/ItemsScreen.kt | 4 +-
.../itemsScreen/ItemsScreenControllerImpl.kt | 21 +-
.../scanScreen/ChooseConnectionScreen.kt | 2 +-
.../vbhelper/screens/scanScreen/ScanScreen.kt | 7 +-
.../scanScreen/ScanScreenController.kt | 7 +-
.../scanScreen/ScanScreenControllerImpl.kt | 337 ++++++------
.../screens/scanScreen/TransferHaptics.kt | 51 ++
.../scanScreen/converters/FromNfcConverter.kt | 57 +-
.../scanScreen/converters/ToNfcConverter.kt | 54 +-
.../scanScreen/screens/ActionScreen.kt | 82 +--
.../scanScreen/screens/ReadingScreen.kt | 27 +-
.../screens/TransferAnimationScreen.kt | 267 +++++++++
.../scanScreen/screens/WritingScreen.kt | 63 ++-
.../screens/settingsScreen/SettingsScreen.kt | 72 +++
.../SettingsScreenController.kt | 4 +
.../SettingsScreenControllerImpl.kt | 40 +-
.../controllers/CardImportController.kt | 53 +-
.../DatabaseManagementController.kt | 141 +++++
.../vbhelper/source/AuthRepository.kt | 6 +
.../vbhelper/source/CardSettingsRepository.kt | 28 +
.../vbhelper/source/DeviceTypeConverter.kt | 54 ++
.../source/VitalWearCharacterExporter.kt | 91 +++-
.../source/VitalWearCharacterImporter.kt | 367 +++++++++++--
.../transfer/hce/VitalWearHceReaderClient.kt | 53 +-
.../nacabaro/vbhelper/utils/BitmapData.kt | 4 +-
.../cfogrady/vitalwear/protos/Character.proto | 39 ++
app/src/main/res/values-pt-rBR/strings.xml | 2 +-
app/src/main/res/values/strings.xml | 35 +-
app/src/main/res/xml/provider_paths.xml | 3 +
.../ToNfcConverterProfileSelectionTest.kt | 25 +
gradle/libs.versions.toml | 13 +
settings.gradle.kts | 6 +-
90 files changed, 3889 insertions(+), 1323 deletions(-)
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/companion/card/ByteManipulation.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/companion/card/CompanionImportCardActivity.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/companion/card/CompanionValidateCardActivity.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/companion/card/VBNfcData.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/companion/common/ChannelTypes.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/companion/firmware/CompanionFirmwareImportActivity.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/companion/logging/TinyLogTree.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/companion/logs/CompanionLogService.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/companion/logs/CompanionWatchLogsActivity.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/companion/logs/WatchCommunicationService.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/companion/transfer/CompanionCharacterImportActivity.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/companion/ui/CompanionConfirmation.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/companion/ui/CompanionLoading.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/companion/validation/CompanionValidatedDatabase.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/companion/validation/ValidatedCardDao.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/companion/validation/ValidatedCardEntity.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/companion/validation/ValidatedCardManager.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/UnifiedCharacter.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/navigation/NavigationExtensions.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/screens/homeScreens/screens/UnifiedHomeScreen.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/TransferHaptics.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/screens/TransferAnimationScreen.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/source/CardSettingsRepository.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/source/DeviceTypeConverter.kt
create mode 100644 app/src/test/java/com/github/nacabaro/vbhelper/screens/scanScreen/converters/ToNfcConverterProfileSelectionTest.kt
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 99c898f..a2f1042 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -73,8 +73,12 @@ protobuf {
dependencies {
implementation(libs.androidx.room.runtime)
implementation(libs.vb.nfc.reader)
- implementation(libs.dim.reader)
- implementation(libs.androidx.core.ktx)
+ // Temporarily commented out due to Lombok compilation issues in VB-DIM-Reader
+ // implementation(libs.dim.reader)
+ implementation(files("../../VB-DIM-Reader-2.0.0/build/libs/VB-DIM-Reader.jar"))
+ //VB-DIM-Reader runtime dependency required by the local JAR
+ implementation("at.favre.lib:bytes:1.5.0")
+
implementation(libs.androidx.room.ktx)
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.navigation.compose)
@@ -90,6 +94,13 @@ dependencies {
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.androidx.compose.material.icons.extended)
+ implementation(libs.play.services.wearable)
+ implementation(libs.kotlinx.coroutines.play.services)
+ implementation(libs.timber)
+ implementation(libs.tiny.log)
+ implementation(libs.tiny.log.impl)
+ implementation(libs.slf4j.api)
+ runtimeOnly(libs.slf4j.timber)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
@@ -118,4 +129,7 @@ dependencies {
// HTTP request logging
implementation("com.squareup.okhttp3:logging-interceptor:4.11.0")
+
+ // In-app browser for OAuth login
+ implementation("androidx.browser:browser:1.8.0")
}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index b2c32af..538abe9 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -11,6 +11,7 @@
+
@@ -50,11 +51,17 @@
android:exported="true"
android:readPermission="com.github.nacabaro.vbhelper.permission.ACCESS_TRANSFER_SEEN"
android:writePermission="com.github.nacabaro.vbhelper.permission.ACCESS_TRANSFER_SEEN" />
+
+
+
+
+
@@ -75,6 +82,16 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/MainActivity.kt b/app/src/main/java/com/github/nacabaro/vbhelper/MainActivity.kt
index 3be27d1..a9676e3 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/MainActivity.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/MainActivity.kt
@@ -7,9 +7,13 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
import com.github.nacabaro.vbhelper.navigation.AppNavigation
import com.github.nacabaro.vbhelper.di.VBHelper
import com.github.nacabaro.vbhelper.navigation.AppNavigationHandlers
+import com.github.nacabaro.vbhelper.navigation.NavigationItems
import com.github.nacabaro.vbhelper.screens.homeScreens.HomeScreenControllerImpl
import com.github.nacabaro.vbhelper.screens.itemsScreen.ItemsScreenControllerImpl
import com.github.nacabaro.vbhelper.screens.scanScreen.ScanScreenControllerImpl
@@ -19,14 +23,14 @@ import com.github.nacabaro.vbhelper.screens.cardScreen.CardScreenControllerImpl
import com.github.nacabaro.vbhelper.screens.spriteViewer.SpriteViewerControllerImpl
import com.github.nacabaro.vbhelper.screens.storageScreen.StorageScreenControllerImpl
import com.github.nacabaro.vbhelper.ui.theme.VBHelperTheme
-import kotlinx.coroutines.flow.MutableSharedFlow
-import kotlinx.coroutines.flow.asSharedFlow
+import androidx.lifecycle.lifecycleScope
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.Dispatchers
class MainActivity : ComponentActivity() {
private val onActivityLifecycleListeners = HashMap()
- private var initialRoute: String? = null
- private val deepLinkNavigationEvents = MutableSharedFlow(extraBufferCapacity = 1)
+ private var initialRoute: String? by mutableStateOf(null)
private fun registerActivityLifecycleListener(key: String, activityLifecycleListener: ActivityLifecycleListener) {
if( onActivityLifecycleListeners[key] != null) {
@@ -60,7 +64,6 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge()
initialRoute = getInitialRouteFromIntent(intent)
- initialRoute?.let { deepLinkNavigationEvents.tryEmit(it) }
setContent {
VBHelperTheme {
@@ -73,8 +76,7 @@ class MainActivity : ComponentActivity() {
storageScreenController = storageScreenController,
spriteViewerController = spriteViewerController,
cardScreenController = cardScreenController,
- initialRoute = initialRoute,
- deepLinkNavigationEvents = deepLinkNavigationEvents.asSharedFlow(),
+ initialRoute = initialRoute
)
}
}
@@ -102,15 +104,35 @@ class MainActivity : ComponentActivity() {
super.onNewIntent(intent)
setIntent(intent)
initialRoute = getInitialRouteFromIntent(intent)
- initialRoute?.let { deepLinkNavigationEvents.tryEmit(it) }
}
private fun getInitialRouteFromIntent(intent: Intent?): String? {
if (intent == null) return null
val data = intent.data
if (intent.action == Intent.ACTION_VIEW && data != null) {
- if (data.scheme == "vbhelper" && data.host == "auth") {
- return "Battle"
+ val isAppAuthCallback = data.scheme == "vbhelper" && data.host == "auth"
+ val isLocalhostAuthCallback =
+ (data.scheme == "http" || data.scheme == "https") &&
+ (data.host == "localhost" || data.host == "127.0.0.1") &&
+ data.path?.startsWith("/authenticate") == true
+ val token = data.getQueryParameter("c") ?: data.getQueryParameter("token")
+ val hasAuthToken = !token.isNullOrEmpty()
+
+ if (isAppAuthCallback || isLocalhostAuthCallback || hasAuthToken) {
+ if (!token.isNullOrEmpty()) {
+ val application = applicationContext as VBHelper
+ val authRepository = com.github.nacabaro.vbhelper.battle.BattleAuthContainer(this).authRepository
+ val userId = data.getQueryParameter("userId")?.toLongOrNull()
+
+ lifecycleScope.launch(Dispatchers.IO) {
+ authRepository.setAuthenticated(
+ isAuthenticated = true,
+ nacatechToken = token,
+ userId = userId
+ )
+ }
+ }
+ return NavigationItems.Battles.route
}
}
return null
@@ -126,8 +148,7 @@ class MainActivity : ComponentActivity() {
homeScreenController: HomeScreenControllerImpl,
spriteViewerController: SpriteViewerControllerImpl,
cardScreenController: CardScreenControllerImpl,
- initialRoute: String? = null,
- deepLinkNavigationEvents: kotlinx.coroutines.flow.Flow,
+ initialRoute: String? = null
) {
AppNavigation(
applicationNavigationHandlers = AppNavigationHandlers(
@@ -140,8 +161,7 @@ class MainActivity : ComponentActivity() {
spriteViewerController,
cardScreenController
),
- initialRoute = initialRoute,
- navigationEvents = deepLinkNavigationEvents,
+ initialRoute = initialRoute
)
}
}
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/battle/AnimatedSpriteImage.kt b/app/src/main/java/com/github/nacabaro/vbhelper/battle/AnimatedSpriteImage.kt
index f75b935..7d6f6cc 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/battle/AnimatedSpriteImage.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/battle/AnimatedSpriteImage.kt
@@ -5,9 +5,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
-import androidx.compose.ui.platform.LocalContext
import kotlinx.coroutines.launch
-import kotlinx.coroutines.delay
@Composable
fun AnimatedSpriteImage(
@@ -15,12 +13,10 @@ fun AnimatedSpriteImage(
animationType: DigimonAnimationType = DigimonAnimationType.IDLE,
modifier: Modifier = Modifier,
contentScale: ContentScale = ContentScale.Fit,
- reloadMappings: Boolean = false,
animationOffset: Long = 0L // New parameter for offsetting animation timing
) {
- val context = LocalContext.current
- val spriteManager = remember { IndividualSpriteManager(context) }
-
+ val spriteManager = remember { IndividualSpriteManager() }
+
// Calculate frame offset based on animation offset
// 750ms is the idle animation duration, so we calculate how many frames to offset
val frameOffset = if (animationOffset > 0L) {
@@ -30,18 +26,11 @@ fun AnimatedSpriteImage(
0
}
- val animationStateMachine = remember { DigimonAnimationStateMachine(characterId, context, frameOffset, animationOffset) }
+ val animationStateMachine = remember { DigimonAnimationStateMachine(characterId, frameOffset, animationOffset) }
val coroutineScope = rememberCoroutineScope()
var bitmap by remember { mutableStateOf(null) }
- // Reload mappings when reloadMappings parameter changes
- LaunchedEffect(reloadMappings) {
- if (reloadMappings) {
- animationStateMachine.reloadMappings()
- }
- }
-
// Start the animation when the component is first created
LaunchedEffect(characterId) {
coroutineScope.launch {
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/battle/ArenaBattleSystem.kt b/app/src/main/java/com/github/nacabaro/vbhelper/battle/ArenaBattleSystem.kt
index cac56be..5a6beaf 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/battle/ArenaBattleSystem.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/battle/ArenaBattleSystem.kt
@@ -1,18 +1,11 @@
package com.github.nacabaro.vbhelper.battle
-import android.util.Log
-import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
-import androidx.compose.runtime.State
class ArenaBattleSystem {
- companion object {
- private const val TAG = "ArenaBattleSystem"
- }
-
- // Attack phases: 0=Idle, 1=Player attack on player screen, 2=Player attack on opponent screen,
+ // Attack phases: 0=Idle, 1=Player attack on player screen, 2=Player attack on opponent screen,
// 3=Opponent attack on opponent screen, 4=Opponent attack on player screen
private var _attackPhase by mutableStateOf(0)
val attackPhase: Int get() = _attackPhase
@@ -44,19 +37,6 @@ class ArenaBattleSystem {
private var _critBarProgress by mutableStateOf(0)
val critBarProgress: Int get() = _critBarProgress
- // Dodge animation states
- private var _isDodging by mutableStateOf(false)
- val isDodging: Boolean get() = _isDodging
-
- private var _dodgeProgress by mutableStateOf(0f)
- val dodgeProgress: Float get() = _dodgeProgress
-
- private var _dodgeDirection by mutableStateOf(1f) // 1f = up, -1f = down
- val dodgeDirection: Float get() = _dodgeDirection
-
- private var _isHit by mutableStateOf(false)
- val isHit: Boolean get() = _isHit
-
private var _hitProgress by mutableStateOf(0f)
val hitProgress: Float get() = _hitProgress
@@ -180,10 +160,6 @@ class ArenaBattleSystem {
_isPlayerAttacking = false
_attackIsHit = false
_currentView = 0
- _isDodging = false
- _dodgeProgress = 0f
- _dodgeDirection = 1f
- _isHit = false
_hitProgress = 0f
_isPlayerDodging = false
_isOpponentDodging = false
@@ -215,41 +191,10 @@ class ArenaBattleSystem {
//Log.d(TAG, "Updated crit bar progress: $progress")
}
- // Dodge animation methods
- fun startDodge() {
- _isDodging = true
- _dodgeProgress = 0f
- _dodgeDirection = 1f // Start moving up
- }
-
- fun setDodgeProgress(progress: Float) {
- _dodgeProgress = progress
- }
-
- fun setDodgeDirection(direction: Float) {
- _dodgeDirection = direction
- }
-
- fun endDodge() {
- _isDodging = false
- _dodgeProgress = 0f
- }
-
- // Hit animation methods
- fun startHit() {
- _isHit = true
- _hitProgress = 0f
- }
-
fun setHitProgress(progress: Float) {
_hitProgress = progress
}
- fun endHit() {
- _isHit = false
- _hitProgress = 0f
- }
-
// Player-specific dodge methods
fun startPlayerDodge() {
_isPlayerDodging = true
@@ -345,17 +290,6 @@ class ArenaBattleSystem {
_isOpponentShakeDelayed = false
}
- // Combined method to handle attack result
- fun handleAttackResult(isHit: Boolean) {
- _attackIsHit = isHit
- if (isHit) {
- // Player attack hit - opponent gets hit
- startOpponentHit()
- } else {
- // Player attack missed - opponent dodges
- startOpponentDodge()
- }
- }
// Method to handle opponent attack result
fun handleOpponentAttackResult(isHit: Boolean) {
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/battle/AuthInterceptor.kt b/app/src/main/java/com/github/nacabaro/vbhelper/battle/AuthInterceptor.kt
index df03762..6488586 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/battle/AuthInterceptor.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/battle/AuthInterceptor.kt
@@ -20,6 +20,7 @@ class AuthInterceptor(private val token: String) : Interceptor {
// Use X-Session-Token header (preferred) or Authorization: Bearer
val authenticatedRequest = originalRequest.newBuilder()
.header("X-Session-Token", token)
+ .header("Authorization", "Bearer $token")
.build()
// Debug: Log which header is being used (first few chars of token for security)
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/battle/AuthenticateResponse.kt b/app/src/main/java/com/github/nacabaro/vbhelper/battle/AuthenticateResponse.kt
index cb1f4fd..5b1f1b0 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/battle/AuthenticateResponse.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/battle/AuthenticateResponse.kt
@@ -1,21 +1,46 @@
package com.github.nacabaro.vbhelper.battle
+import com.google.gson.annotations.SerializedName
+
data class AdditionalInfo(
+ @SerializedName(value = "avatar", alternate = ["avatar_url"])
val avatar: String? = null,
+ @SerializedName(value = "id", alternate = ["user_id"])
val id: Long? = null,
+ @SerializedName(value = "name", alternate = ["username", "display_name"])
val name: String? = null,
val status: String? = null
)
data class UserInfo(
+ @SerializedName(value = "userId", alternate = ["user_id", "id"])
val userId: String? = null,
+ @SerializedName(value = "additionalInfo", alternate = ["additional_info"])
val additionalInfo: AdditionalInfo? = null
)
data class AuthenticateResponse(
+ @SerializedName(value = "success", alternate = ["ok"])
val success: Boolean,
val message: String? = null,
+ @SerializedName(value = "userInfo", alternate = ["user_info", "user"])
val userInfo: UserInfo? = null,
- val sessionToken: String? = null
+ @SerializedName(value = "sessionToken", alternate = ["session_token", "token", "session"])
+ val sessionToken: String? = null,
+ @SerializedName(value = "userId", alternate = ["user_id"])
+ val topLevelUserId: String? = null,
+ @SerializedName(value = "id", alternate = ["uid"])
+ val topLevelId: Long? = null,
)
+fun AuthenticateResponse.extractSessionToken(): String? {
+ return sessionToken?.takeIf { it.isNotBlank() }
+}
+
+fun AuthenticateResponse.extractUserId(): Long? {
+ return userInfo?.userId?.toLongOrNull()
+ ?: topLevelUserId?.toLongOrNull()
+ ?: userInfo?.additionalInfo?.id
+ ?: topLevelId
+}
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/battle/DigimonAnimationState.kt b/app/src/main/java/com/github/nacabaro/vbhelper/battle/DigimonAnimationState.kt
index c2ee41e..40c4b00 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/battle/DigimonAnimationState.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/battle/DigimonAnimationState.kt
@@ -4,22 +4,13 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kotlinx.coroutines.delay
-import android.content.Context
-import java.io.File
enum class DigimonAnimationType {
IDLE,
IDLE2,
WALK,
- WALK2,
- RUN,
- RUN2,
- WORKOUT,
- WORKOUT2,
- HAPPY,
SLEEP,
- ATTACK,
- FLEE
+ ATTACK
}
data class AnimationState(
@@ -31,7 +22,6 @@ data class AnimationState(
class DigimonAnimationStateMachine(
private val characterId: String,
- private val context: Context,
private val initialFrameOffset: Int = 0, // New parameter for offsetting the starting frame
private val timingOffset: Long = 0L // New parameter for offsetting the timing
) {
@@ -44,21 +34,13 @@ class DigimonAnimationStateMachine(
var isPlaying by mutableStateOf(false)
private set
- // Direct mapping of frame numbers (1-12) to animation types
- // This is based on the standard Digimon sprite frame order
+ // Direct mapping of frame numbers used by current battle animations.
private val frameToAnimationType = mapOf(
1 to DigimonAnimationType.IDLE,
2 to DigimonAnimationType.IDLE2,
3 to DigimonAnimationType.WALK,
- 4 to DigimonAnimationType.WALK2,
- 5 to DigimonAnimationType.RUN,
- 6 to DigimonAnimationType.RUN2,
- 7 to DigimonAnimationType.WORKOUT,
- 8 to DigimonAnimationType.WORKOUT2,
- 9 to DigimonAnimationType.HAPPY,
10 to DigimonAnimationType.SLEEP,
- 11 to DigimonAnimationType.ATTACK,
- 12 to DigimonAnimationType.FLEE
+ 11 to DigimonAnimationType.ATTACK
)
// Reverse mapping for getting frame numbers for each animation type
@@ -69,15 +51,8 @@ class DigimonAnimationStateMachine(
DigimonAnimationType.IDLE to 750L,
DigimonAnimationType.IDLE2 to 750L,
DigimonAnimationType.WALK to 200L,
- DigimonAnimationType.WALK2 to 200L,
- DigimonAnimationType.RUN to 150L,
- DigimonAnimationType.RUN2 to 150L,
- DigimonAnimationType.WORKOUT to 300L,
- DigimonAnimationType.WORKOUT2 to 300L,
- DigimonAnimationType.HAPPY to 400L,
DigimonAnimationType.SLEEP to 1500L,
- DigimonAnimationType.ATTACK to 650L,
- DigimonAnimationType.FLEE to 150L
+ DigimonAnimationType.ATTACK to 650L
)
/*
@@ -155,13 +130,4 @@ class DigimonAnimationStateMachine(
return currentFrameNumber
}
- fun getCurrentCharacterId(): String {
- return characterId
- }
-
- // Method to reload mappings (useful for testing)
- fun reloadMappings() {
- println("Reloading mappings for character: $characterId")
- // No need to reload since we use direct frame mapping
- }
-}
\ No newline at end of file
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/battle/HitEffectComposables.kt b/app/src/main/java/com/github/nacabaro/vbhelper/battle/HitEffectComposables.kt
index e4a3383..800a0f3 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/battle/HitEffectComposables.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/battle/HitEffectComposables.kt
@@ -10,7 +10,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
-import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
@@ -25,10 +24,9 @@ fun HitEffectOverlay(
) {
if (!isVisible) return
- val context = LocalContext.current
val configuration = LocalConfiguration.current
val isLandscapeMode = configuration.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE
- val hitEffectManager = remember { HitEffectSpriteManager(context) }
+ val hitEffectManager = remember { HitEffectSpriteManager() }
val coroutineScope = rememberCoroutineScope()
var currentFrame by remember { mutableStateOf(0) }
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/battle/HitEffectSpriteManager.kt b/app/src/main/java/com/github/nacabaro/vbhelper/battle/HitEffectSpriteManager.kt
index fa116be..e6fae95 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/battle/HitEffectSpriteManager.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/battle/HitEffectSpriteManager.kt
@@ -1,13 +1,10 @@
package com.github.nacabaro.vbhelper.battle
-import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
-import android.graphics.Rect
-import android.os.Environment
import java.io.File
-class HitEffectSpriteManager(private val context: Context) {
+class HitEffectSpriteManager {
private val spriteCache = mutableMapOf()
// 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 {
- 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 {
- 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()
- }
}
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/battle/IndividualSpriteManager.kt b/app/src/main/java/com/github/nacabaro/vbhelper/battle/IndividualSpriteManager.kt
index 84c68a7..b6bb2bc 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/battle/IndividualSpriteManager.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/battle/IndividualSpriteManager.kt
@@ -1,12 +1,10 @@
package com.github.nacabaro.vbhelper.battle
-import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
-import android.os.Environment
import java.io.File
-class IndividualSpriteManager(private val context: Context) {
+class IndividualSpriteManager {
private val spriteCache = mutableMapOf()
// 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 {
- 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 {
- 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()
- }
-}
\ No newline at end of file
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/battle/RetrofitHelper.kt b/app/src/main/java/com/github/nacabaro/vbhelper/battle/RetrofitHelper.kt
index b465ddc..fc0ce24 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/battle/RetrofitHelper.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/battle/RetrofitHelper.kt
@@ -12,10 +12,14 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withTimeoutOrNull
import com.github.nacabaro.vbhelper.battle.BattleAuthContainer
+import java.net.SocketTimeoutException
+import java.io.InterruptedIOException
+import java.util.concurrent.TimeUnit
class RetrofitHelper {
-
+
/**
* Creates an OkHttpClient with authentication interceptor for game endpoints.
* Requires a non-null, non-empty token.
@@ -28,9 +32,22 @@ class RetrofitHelper {
return OkHttpClient.Builder()
.addInterceptor(AuthInterceptor(token))
.addInterceptor(loggingInterceptor)
+ .connectTimeout(30, TimeUnit.SECONDS)
+ .readTimeout(30, TimeUnit.SECONDS)
+ .writeTimeout(30, TimeUnit.SECONDS)
+ .callTimeout(45, TimeUnit.SECONDS)
+ .retryOnConnectionFailure(true)
.build()
}
-
+
+ private fun classifyNetworkError(t: Throwable): String {
+ return when (t) {
+ is SocketTimeoutException -> "Authentication server timed out"
+ is InterruptedIOException -> "Request timed out"
+ else -> t.message ?: "Network error"
+ }
+ }
+
/**
* Gets the session token from AuthRepository for API calls.
* Falls back to nacatech token if session token is not available (backward compatibility).
@@ -38,7 +55,8 @@ class RetrofitHelper {
private fun getAuthToken(context: Context): String? {
return try {
val authContainer = BattleAuthContainer(context)
- runBlocking {
+ runBlocking(Dispatchers.IO) {
+ withTimeoutOrNull(5000L) {
// Prefer session token, fall back to nacatech token for backward compatibility
val sessionToken = authContainer.authRepository.sessionToken.first()
if (!sessionToken.isNullOrEmpty()) {
@@ -52,6 +70,10 @@ class RetrofitHelper {
}
nacatechToken
}
+ } ?: run {
+ println("RetrofitHelper: Timed out while reading auth token from DataStore")
+ null
+ }
}
} catch (e: Exception) {
println("RetrofitHelper: Error getting auth token: ${e.message}")
@@ -76,7 +98,30 @@ class RetrofitHelper {
.addConverterFactory(GsonConverterFactory.create())
.build()
}
-
+ /**
+ * Creates a Retrofit instance without authentication for public endpoints.
+ */
+ private fun createPublicRetrofit(): Retrofit {
+ val loggingInterceptor = HttpLoggingInterceptor().apply {
+ level = HttpLoggingInterceptor.Level.BODY
+ }
+
+ val client = OkHttpClient.Builder()
+ .addInterceptor(loggingInterceptor)
+ .connectTimeout(30, TimeUnit.SECONDS)
+ .readTimeout(30, TimeUnit.SECONDS)
+ .writeTimeout(30, TimeUnit.SECONDS)
+ .callTimeout(45, TimeUnit.SECONDS)
+ .retryOnConnectionFailure(true)
+ .build()
+
+ return Retrofit.Builder()
+ .baseUrl("http://battle.io-void.com:8080/")
+ .client(client)
+ .addConverterFactory(GsonConverterFactory.create())
+ .build()
+ }
+
/**
* Handles HTTP error responses (401, 403, 429).
* For 401/403, clears authentication state to trigger re-authentication.
@@ -121,16 +166,15 @@ class RetrofitHelper {
}
}
- fun getOpponents(context: Context, stage: String, callback: (OpponentsDataModel) -> Unit) {
+ fun getOpponents(context: Context, stage: String, onComplete: (() -> Unit)? = null, callback: (OpponentsDataModel) -> Unit) {
//println("RetrofitHelper: Starting API call for stage: $stage")
try {
- // Create an authenticated Retrofit instance
- val retrofit = createAuthenticatedRetrofit(context)
- if (retrofit == null) {
- println("RetrofitHelper: Cannot create authenticated Retrofit - no token available")
- Toast.makeText(context, "Authentication required. Please log in.", Toast.LENGTH_SHORT).show()
- return
+ // Opponents endpoint requires auth on the current backend.
+ // Prefer authenticated Retrofit; fall back to public client only if no token is available.
+ val retrofit = createAuthenticatedRetrofit(context) ?: run {
+ println("RetrofitHelper: No auth token for opponents request, falling back to public Retrofit")
+ createPublicRetrofit()
}
// Create an ApiService instance from the Retrofit instance.
@@ -146,9 +190,11 @@ class RetrofitHelper {
// make an asynchronous API request.
call.enqueue(object : Callback {
override fun onFailure(call: Call, t: Throwable) {
- println("RetrofitHelper: API call failed: ${t.message}")
+ val classified = classifyNetworkError(t)
+ println("RetrofitHelper: API call failed (${t::class.java.simpleName}): $classified")
t.printStackTrace()
- Toast.makeText(context, "Request Fail", Toast.LENGTH_SHORT).show()
+ Toast.makeText(context, classified, Toast.LENGTH_SHORT).show()
+ onComplete?.invoke()
}
override fun onResponse(call: Call, response: Response) {
@@ -156,13 +202,19 @@ class RetrofitHelper {
println("RetrofitHelper: Response body: ${response.body()}")
if(response.isSuccessful){
- //println("RetrofitHelper: Response successful, calling callback")
- val opponentsList: OpponentsDataModel = response.body() as OpponentsDataModel
- callback(opponentsList)
+ val opponentsList = response.body()
+ if (opponentsList != null) {
+ callback(opponentsList)
+ } else {
+ println("RetrofitHelper: Successful opponents response had empty body")
+ Toast.makeText(context, "Empty response from server", Toast.LENGTH_SHORT).show()
+ }
+ onComplete?.invoke()
} else {
val errorBody = response.errorBody()?.string()
- println("RetrofitHelper: Response not successful - Error: $errorBody")
- handleErrorResponse(context, response, errorBody ?: "Unknown error")
+ println("RetrofitHelper: Opponents response not successful - Code: ${response.code()}, Error: $errorBody")
+ handleErrorResponse(context, response, errorBody ?: "Unable to load opponents right now.")
+ onComplete?.invoke()
}
}
})
@@ -170,6 +222,7 @@ class RetrofitHelper {
println("RetrofitHelper: Exception in getOpponents: ${e.message}")
e.printStackTrace()
Toast.makeText(context, "Request failed: ${e.message}", Toast.LENGTH_SHORT).show()
+ onComplete?.invoke()
}
}
@@ -284,9 +337,10 @@ class RetrofitHelper {
override fun onFailure(call: Call, t: Throwable) {
// This method is called when the API request fails.
- println("RetrofitHelper: PVP API call failed: ${t.message}")
+ val classified = classifyNetworkError(t)
+ println("RetrofitHelper: PVP API call failed (${t::class.java.simpleName}): $classified")
t.printStackTrace()
- Toast.makeText(context, "Request Fail", Toast.LENGTH_SHORT).show()
+ Toast.makeText(context, classified, Toast.LENGTH_SHORT).show()
}
override fun onResponse(call: Call, response: Response) {
@@ -294,13 +348,13 @@ class RetrofitHelper {
println("RetrofitHelper: PVP API response received - Code: ${response.code()}")
if(response.isSuccessful){
- // If the response is successful, parse the
- // response body to a DataModel object.
- val apiResults: PVPDataModel = response.body() as PVPDataModel
-
- // Call the callback function with the DataModel
- // object as a parameter.
- callback(apiResults)
+ val apiResults = response.body()
+ if (apiResults != null) {
+ callback(apiResults)
+ } else {
+ println("RetrofitHelper: Successful PVP response had empty body")
+ Toast.makeText(context, "Empty response from server", Toast.LENGTH_SHORT).show()
+ }
} else {
val errorBody = response.errorBody()?.string()
println("RetrofitHelper: PVP API response not successful - Code: ${response.code()}, Error: $errorBody")
@@ -315,12 +369,20 @@ class RetrofitHelper {
}
}
- fun authenticate(context: Context, token: String, callback: (AuthenticateResponse) -> Unit) {
+ fun authenticate(
+ context: Context,
+ token: String,
+ showUserFacingErrors: Boolean = true,
+ callback: (AuthenticateResponse) -> Unit,
+ ) {
//println("RetrofitHelper: Starting validate API call with token: $token")
if (token.isEmpty()) {
println("RetrofitHelper: ERROR - Token is empty!")
- Toast.makeText(context, "Authentication failed: Token is empty", Toast.LENGTH_SHORT).show()
+ if (showUserFacingErrors) {
+ Toast.makeText(context, "Authentication failed: Token is empty", Toast.LENGTH_SHORT).show()
+ }
+ callback(AuthenticateResponse(success = false, message = "Token is empty"))
return
}
@@ -331,6 +393,10 @@ class RetrofitHelper {
}
val okHttpClient = OkHttpClient.Builder()
.addInterceptor(loggingInterceptor)
+ .connectTimeout(30, TimeUnit.SECONDS)
+ .readTimeout(30, TimeUnit.SECONDS)
+ .writeTimeout(30, TimeUnit.SECONDS)
+ .callTimeout(45, TimeUnit.SECONDS)
.build()
val retrofit: Retrofit = Retrofit.Builder()
@@ -348,7 +414,11 @@ class RetrofitHelper {
override fun onFailure(call: Call, t: Throwable) {
println("RetrofitHelper: Validate API call failed: ${t.message}")
t.printStackTrace()
- Toast.makeText(context, "Authentication failed: ${t.message}", Toast.LENGTH_SHORT).show()
+ val message = classifyNetworkError(t)
+ if (showUserFacingErrors) {
+ Toast.makeText(context, "Authentication failed: $message", Toast.LENGTH_SHORT).show()
+ }
+ callback(AuthenticateResponse(success = false, message = message))
}
override fun onResponse(call: Call, response: Response) {
@@ -360,11 +430,25 @@ class RetrofitHelper {
} else {
println("RetrofitHelper: Validation failed: Invalid response body")
Toast.makeText(context, "Authentication failed: Invalid response", Toast.LENGTH_SHORT).show()
+ callback(AuthenticateResponse(success = false, message = "Invalid response body"))
}
} else {
val errorBody = response.errorBody()?.string()
println("RetrofitHelper: Validate response not successful - Code: ${response.code()}, Error: $errorBody")
- Toast.makeText(context, "Authentication failed: ${response.code()}", Toast.LENGTH_SHORT).show()
+ val userMessage = when {
+ response.code() == 522 -> "Authentication server timed out. Please try again in a moment."
+ response.code() >= 500 -> "Authentication server is unavailable. Please try again later."
+ else -> "Authentication failed: ${response.code()}"
+ }
+ if (showUserFacingErrors) {
+ Toast.makeText(context, userMessage, Toast.LENGTH_SHORT).show()
+ }
+ callback(
+ AuthenticateResponse(
+ success = false,
+ message = "HTTP ${response.code()}: ${errorBody ?: "Authentication server error"}"
+ )
+ )
}
}
})
@@ -372,6 +456,7 @@ class RetrofitHelper {
println("RetrofitHelper: Exception in validate: ${e.message}")
e.printStackTrace()
Toast.makeText(context, "Authentication failed: ${e.message}", Toast.LENGTH_SHORT).show()
+ callback(AuthenticateResponse(success = false, message = e.message ?: "Authentication error"))
}
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/battle/SpriteFileManager.kt b/app/src/main/java/com/github/nacabaro/vbhelper/battle/SpriteFileManager.kt
index 65238e4..e2812c3 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/battle/SpriteFileManager.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/battle/SpriteFileManager.kt
@@ -1,275 +1,13 @@
package com.github.nacabaro.vbhelper.battle
-import android.content.Context
import java.io.File
-import java.io.FileOutputStream
-import java.io.IOException
-import java.io.FileInputStream
-class SpriteFileManager(private val context: Context) {
-
- // Get the external storage directory where files are already located
- fun getExternalSpriteBaseDir(): File {
+class SpriteFileManager {
+ private fun getExternalSpriteBaseDir(): File {
val externalDir = android.os.Environment.getExternalStorageDirectory()
return File(externalDir, "VBHelper/battle_sprites")
}
-
- // Get the internal storage directory for sprite files
- private fun getInternalSpriteBaseDir(): File {
- return File(context.filesDir, "battle_sprites")
- }
-
- fun copySpriteFilesToInternalStorage() {
- try {
- println("Starting sprite file copy process from external storage to internal storage...")
-
- val externalDir = getExternalSpriteBaseDir()
- val internalDir = getInternalSpriteBaseDir()
-
- // Check if external directory exists
- if (!externalDir.exists()) {
- println("External sprite directory does not exist: ${externalDir.absolutePath}")
- return
- }
-
- println("External sprite directory exists: ${externalDir.absolutePath}")
- println("Copying to internal storage: ${internalDir.absolutePath}")
-
- // Create internal directory if it doesn't exist
- if (!internalDir.exists()) {
- val created = internalDir.mkdirs()
- println("Created internal sprite directory: $created")
- }
-
- // Copy all subdirectories from external to internal storage
- val externalFiles = externalDir.listFiles()
- if (externalFiles != null) {
- println("Found ${externalFiles.size} items in external directory")
- externalFiles.forEach { item ->
- val targetItem = File(internalDir, item.name)
- if (item.isDirectory) {
- println("Copying directory: ${item.name}")
- copyDirectory(item, targetItem)
- } else {
- println("Copying file: ${item.name}")
- copyFile(item, targetItem)
- }
- }
- }
-
- println("Sprite files copied successfully to internal storage: ${internalDir.absolutePath}")
-
- } catch (e: Exception) {
- println("Error copying sprite files to internal storage: ${e.message}")
- e.printStackTrace()
- }
- }
-
- fun copySpriteFilesToExternalStorage() {
- try {
- println("Starting sprite file copy process to external storage...")
-
- // Debug: List what's in the assets directory
- val assetManager = context.assets
- val battleSpritesFiles = assetManager.list("battle_sprites")
- println("battle_sprites directory in assets contains: ${battleSpritesFiles?.joinToString(", ")}")
-
- val extractedAssetsFiles = assetManager.list("battle_sprites/extracted_assets")
- println("battle_sprites/extracted_assets directory in assets contains: ${extractedAssetsFiles?.joinToString(", ")}")
-
- // Check specifically for extracted_atksprites in assets (now directly under battle_sprites)
- val atkspritesInAssets = assetManager.list("battle_sprites/extracted_atksprites")
- println("extracted_atksprites in assets contains: ${atkspritesInAssets?.size ?: 0} files")
- if (atkspritesInAssets != null && atkspritesInAssets.isNotEmpty()) {
- println("First few attack files in assets: ${atkspritesInAssets.take(5).joinToString(", ")}")
- }
-
- // Check for extracted_battlebgs in assets (now directly under battle_sprites)
- val battlebgsInAssets = assetManager.list("battle_sprites/extracted_battlebgs")
- println("extracted_battlebgs in assets contains: ${battlebgsInAssets?.size ?: 0} files")
- if (battlebgsInAssets != null && battlebgsInAssets.isNotEmpty()) {
- println("First few battle background files in assets: ${battlebgsInAssets.take(5).joinToString(", ")}")
- }
-
- // Try to list all possible subdirectories in battle_sprites
- println("Checking all possible subdirectories in battle_sprites...")
- battleSpritesFiles?.forEach { subdir ->
- try {
- val subdirFiles = assetManager.list("battle_sprites/$subdir")
- println(" $subdir contains: ${subdirFiles?.size ?: 0} files")
- if (subdirFiles != null && subdirFiles.isNotEmpty()) {
- println(" First few files: ${subdirFiles.take(3).joinToString(", ")}")
- }
- } catch (e: Exception) {
- println(" Error listing $subdir: ${e.message}")
- }
- }
-
- // Create the base directory for battle_sprites in external storage
- val battleSpritesDir = getExternalSpriteBaseDir()
- if (!battleSpritesDir.exists()) {
- battleSpritesDir.mkdirs()
- println("Created battle_sprites directory in external storage: ${battleSpritesDir.absolutePath}")
- } else {
- println("battle_sprites directory already exists in external storage: ${battleSpritesDir.absolutePath}")
- }
-
- // Copy all subdirectories from battle_sprites assets to external storage
- println("Copying all battle_sprites subdirectories to external storage...")
- battleSpritesFiles?.forEach { subdir ->
- val sourcePath = "battle_sprites/$subdir"
- val targetDir = File(battleSpritesDir, subdir)
- println("Copying $sourcePath to ${targetDir.absolutePath}")
- copyAssetDirectory(sourcePath, targetDir)
- }
-
- println("Sprite files copied successfully to external storage: ${battleSpritesDir.absolutePath}")
-
- // Verify that attack sprites were copied
- val atkspritesDir = File(battleSpritesDir, "extracted_atksprites")
- if (atkspritesDir.exists()) {
- val attackFiles = atkspritesDir.listFiles()
- println("Attack sprites directory exists with ${attackFiles?.size ?: 0} files")
- if (attackFiles != null && attackFiles.isNotEmpty()) {
- println("First few attack files: ${attackFiles.take(5).map { it.name }}")
- }
- } else {
- println("WARNING: extracted_atksprites directory does not exist!")
- // List what's actually in the battle_sprites directory
- val battleSpritesContents = battleSpritesDir.listFiles()
- println("battle_sprites directory contains: ${battleSpritesContents?.map { it.name }?.joinToString(", ")}")
- }
-
- // Verify that battle backgrounds were copied
- val battlebgsDir = File(battleSpritesDir, "extracted_battlebgs")
- if (battlebgsDir.exists()) {
- val bgFiles = battlebgsDir.listFiles()
- println("Battle backgrounds directory exists with ${bgFiles?.size ?: 0} files")
- if (bgFiles != null && bgFiles.isNotEmpty()) {
- println("First few battle background files: ${bgFiles.take(5).map { it.name }}")
- }
- } else {
- println("WARNING: extracted_battlebgs directory does not exist!")
- }
-
- } catch (e: Exception) {
- println("Error copying sprite files to external storage: ${e.message}")
- e.printStackTrace()
- }
- }
-
- private fun copyAssetDirectory(assetPath: String, targetDir: File) {
- try {
- val assetManager = context.assets
- val files = assetManager.list(assetPath) ?: return
-
- println("Copying asset directory: $assetPath (${files.size} items)")
- println("Files found: ${files.joinToString(", ")}")
-
- for (file in files) {
- val assetFilePath = if (assetPath.isEmpty()) file else "$assetPath/$file"
- val targetFile = File(targetDir, file)
-
- // Create subdirectories if needed
- if (targetFile.parentFile != null && !targetFile.parentFile!!.exists()) {
- targetFile.parentFile!!.mkdirs()
- }
-
- // Check if it's a directory by trying to list its contents
- try {
- val subFiles = assetManager.list(assetFilePath)
- if (subFiles != null && subFiles.isNotEmpty()) {
- // It's a directory, create it and copy contents
- println("Copying subdirectory: $assetFilePath (${subFiles.size} files)")
- if (!targetFile.exists()) {
- targetFile.mkdirs()
- }
- copyAssetDirectory(assetFilePath, targetFile)
- } else {
- // It's a file, copy it
- copyAssetFile(assetFilePath, targetFile)
- }
- } catch (e: Exception) {
- // If we can't list contents, it's probably a file
- println("Treating $assetFilePath as file (could not list contents)")
- copyAssetFile(assetFilePath, targetFile)
- }
- }
-
- // Special handling for extracted_atksprites - try to copy it directly if it wasn't found
- if (assetPath == "battle_sprites/extracted_assets") {
- println("Special handling: Checking for extracted_atksprites directory...")
- try {
- val atkspritesFiles = assetManager.list("battle_sprites/extracted_assets/extracted_atksprites")
- if (atkspritesFiles != null && atkspritesFiles.isNotEmpty()) {
- println("Found extracted_atksprites with ${atkspritesFiles.size} files")
- val atkspritesDir = File(targetDir, "extracted_atksprites")
- if (!atkspritesDir.exists()) {
- atkspritesDir.mkdirs()
- }
- copyAssetDirectory("battle_sprites/extracted_assets/extracted_atksprites", atkspritesDir)
- } else {
- println("extracted_atksprites directory not found in assets")
- }
- } catch (e: Exception) {
- println("Error checking extracted_atksprites: ${e.message}")
- }
- }
-
- } catch (e: Exception) {
- println("Error copying asset directory $assetPath: ${e.message}")
- e.printStackTrace()
- }
- }
-
- private fun copyDirectory(sourceDir: File, targetDir: File) {
- if (!targetDir.exists()) {
- targetDir.mkdirs()
- }
-
- val files = sourceDir.listFiles()
- if (files != null) {
- files.forEach { file ->
- val targetFile = File(targetDir, file.name)
- if (file.isDirectory) {
- copyDirectory(file, targetFile)
- } else {
- copyFile(file, targetFile)
- }
- }
- }
- }
-
- private fun copyFile(sourceFile: File, targetFile: File) {
- try {
- val inputStream = FileInputStream(sourceFile)
- val outputStream = FileOutputStream(targetFile)
-
- inputStream.copyTo(outputStream)
- inputStream.close()
- outputStream.close()
-
- println("Copied: ${sourceFile.name} -> ${targetFile.absolutePath}")
- } catch (e: IOException) {
- println("Error copying file ${sourceFile.name}: ${e.message}")
- }
- }
-
- private fun copyAssetFile(assetPath: String, targetFile: File) {
- try {
- val inputStream = context.assets.open(assetPath)
- val outputStream = FileOutputStream(targetFile)
-
- inputStream.copyTo(outputStream)
- inputStream.close()
- outputStream.close()
-
- println("Copied: $assetPath -> ${targetFile.absolutePath}")
- } catch (e: IOException) {
- println("Error copying asset file $assetPath: ${e.message}")
- }
- }
-
+
fun checkSpriteFilesExist(): Boolean {
val battleSpritesDir = getExternalSpriteBaseDir()
val extractedAssetsDir = File(battleSpritesDir, "extracted_assets")
@@ -294,35 +32,4 @@ class SpriteFileManager(private val context: Context) {
return battleSpritesExist && assetsExist && statsExist && atkspritesExist && battlebgsExist
}
-
- fun clearSpriteFiles() {
- try {
- val battleSpritesDir = getInternalSpriteBaseDir()
-
- if (battleSpritesDir.exists()) {
- deleteDirectory(battleSpritesDir)
- println("Cleared battle_sprites directory from internal storage")
- }
-
- } catch (e: Exception) {
- println("Error clearing sprite files: ${e.message}")
- e.printStackTrace()
- }
- }
-
- private fun deleteDirectory(directory: File) {
- if (directory.exists()) {
- val files = directory.listFiles()
- if (files != null) {
- for (file in files) {
- if (file.isDirectory) {
- deleteDirectory(file)
- } else {
- file.delete()
- }
- }
- }
- directory.delete()
- }
- }
-}
\ No newline at end of file
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/battle/SpriteImage.kt b/app/src/main/java/com/github/nacabaro/vbhelper/battle/SpriteImage.kt
index 5e1c141..61cbee1 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/battle/SpriteImage.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/battle/SpriteImage.kt
@@ -5,7 +5,6 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
-import androidx.compose.ui.platform.LocalContext
@Composable
fun SpriteImage(
@@ -14,9 +13,8 @@ fun SpriteImage(
modifier: Modifier = Modifier,
contentScale: ContentScale = ContentScale.Fit
) {
- val context = LocalContext.current
- val spriteManager = remember { IndividualSpriteManager(context) }
-
+ val spriteManager = remember { IndividualSpriteManager() }
+
var bitmap by remember { mutableStateOf(null) }
LaunchedEffect(characterId, frameNumber) {
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/companion/card/ByteManipulation.kt b/app/src/main/java/com/github/nacabaro/vbhelper/companion/card/ByteManipulation.kt
new file mode 100644
index 0000000..327d3fc
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/companion/card/ByteManipulation.kt
@@ -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
+}
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/companion/card/CompanionImportCardActivity.kt b/app/src/main/java/com/github/nacabaro/vbhelper/companion/card/CompanionImportCardActivity.kt
new file mode 100644
index 0000000..1cc8e08
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/companion/card/CompanionImportCardActivity.kt
@@ -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>
+ private lateinit var validationLauncher: ActivityResultLauncher
+ 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 TransformationRequirements, out AdventureLevels, out AttributeFusions, out SpecificFusions>
+
+ 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> {
+ 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 {
+ 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()
+ 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
+ }
+}
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/companion/card/CompanionValidateCardActivity.kt b/app/src/main/java/com/github/nacabaro/vbhelper/companion/card/CompanionValidateCardActivity.kt
new file mode 100644
index 0000000..dcae9a5
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/companion/card/CompanionValidateCardActivity.kt
@@ -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")
+ }
+ }
+}
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/companion/card/VBNfcData.kt b/app/src/main/java/com/github/nacabaro/vbhelper/companion/card/VBNfcData.kt
new file mode 100644
index 0000000..224c084
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/companion/card/VBNfcData.kt
@@ -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)
+ }
+}
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/companion/common/ChannelTypes.kt b/app/src/main/java/com/github/nacabaro/vbhelper/companion/common/ChannelTypes.kt
new file mode 100644
index 0000000..3a76ee7
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/companion/common/ChannelTypes.kt
@@ -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"
+}
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/companion/firmware/CompanionFirmwareImportActivity.kt b/app/src/main/java/com/github/nacabaro/vbhelper/companion/firmware/CompanionFirmwareImportActivity.kt
new file mode 100644
index 0000000..b843189
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/companion/firmware/CompanionFirmwareImportActivity.kt
@@ -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>) {
+ 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> {
+ 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
+ }
+ }
+ }
+}
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/companion/logging/TinyLogTree.kt b/app/src/main/java/com/github/nacabaro/vbhelper/companion/logging/TinyLogTree.kt
new file mode 100644
index 0000000..579c593
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/companion/logging/TinyLogTree.kt
@@ -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): 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" }
+ }
+ }
+}
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/companion/logs/CompanionLogService.kt b/app/src/main/java/com/github/nacabaro/vbhelper/companion/logs/CompanionLogService.kt
new file mode 100644
index 0000000..ff4e694
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/companion/logs/CompanionLogService.kt
@@ -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"
+ }
+ }
+}
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/companion/logs/CompanionWatchLogsActivity.kt b/app/src/main/java/com/github/nacabaro/vbhelper/companion/logs/CompanionWatchLogsActivity.kt
new file mode 100644
index 0000000..3a43336
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/companion/logs/CompanionWatchLogsActivity.kt
@@ -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)
+ }
+ }
+ }
+ }
+}
+
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/companion/logs/WatchCommunicationService.kt b/app/src/main/java/com/github/nacabaro/vbhelper/companion/logs/WatchCommunicationService.kt
new file mode 100644
index 0000000..d19bfa9
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/companion/logs/WatchCommunicationService.kt
@@ -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}")
+ }
+ }
+}
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/companion/transfer/CompanionCharacterImportActivity.kt b/app/src/main/java/com/github/nacabaro/vbhelper/companion/transfer/CompanionCharacterImportActivity.kt
new file mode 100644
index 0000000..18f6a6e
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/companion/transfer/CompanionCharacterImportActivity.kt
@@ -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()
+ }
+}
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/companion/ui/CompanionConfirmation.kt b/app/src/main/java/com/github/nacabaro/vbhelper/companion/ui/CompanionConfirmation.kt
new file mode 100644
index 0000000..4fbd602
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/companion/ui/CompanionConfirmation.kt
@@ -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")
+ }
+ }
+ }
+}
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/companion/ui/CompanionLoading.kt b/app/src/main/java/com/github/nacabaro/vbhelper/companion/ui/CompanionLoading.kt
new file mode 100644
index 0000000..8326013
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/companion/ui/CompanionLoading.kt
@@ -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),
+ )
+ }
+}
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/companion/validation/CompanionValidatedDatabase.kt b/app/src/main/java/com/github/nacabaro/vbhelper/companion/validation/CompanionValidatedDatabase.kt
new file mode 100644
index 0000000..ac12102
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/companion/validation/CompanionValidatedDatabase.kt
@@ -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
+}
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/companion/validation/ValidatedCardDao.kt b/app/src/main/java/com/github/nacabaro/vbhelper/companion/validation/ValidatedCardDao.kt
new file mode 100644
index 0000000..39fb0a4
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/companion/validation/ValidatedCardDao.kt
@@ -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
+
+ @Insert(onConflict = OnConflictStrategy.IGNORE)
+ suspend fun insert(validatedCardEntity: ValidatedCardEntity)
+}
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/companion/validation/ValidatedCardEntity.kt b/app/src/main/java/com/github/nacabaro/vbhelper/companion/validation/ValidatedCardEntity.kt
new file mode 100644
index 0000000..b794d67
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/companion/validation/ValidatedCardEntity.kt
@@ -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"
+ }
+}
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/companion/validation/ValidatedCardManager.kt b/app/src/main/java/com/github/nacabaro/vbhelper/companion/validation/ValidatedCardManager.kt
new file mode 100644
index 0000000..fbb178f
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/companion/validation/ValidatedCardManager.kt
@@ -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? = null
+
+ private suspend fun ensureLoaded(): Set {
+ 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)
+ }
+}
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/daos/AdventureDao.kt b/app/src/main/java/com/github/nacabaro/vbhelper/daos/AdventureDao.kt
index 7167b97..8114a8b 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/daos/AdventureDao.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/daos/AdventureDao.kt
@@ -2,6 +2,7 @@ package com.github.nacabaro.vbhelper.daos
import androidx.room.Dao
import androidx.room.Query
+import androidx.room.RewriteQueriesToDropUnusedColumns
import com.github.nacabaro.vbhelper.dtos.CharacterDtos
import kotlinx.coroutines.flow.Flow
@@ -19,6 +20,7 @@ interface AdventureDao {
""")
fun getAdventureCount(): Int
+ @RewriteQueriesToDropUnusedColumns
@Query(
"""
SELECT
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/daos/CardFusionsDao.kt b/app/src/main/java/com/github/nacabaro/vbhelper/daos/CardFusionsDao.kt
index 86d6a9a..4382448 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/daos/CardFusionsDao.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/daos/CardFusionsDao.kt
@@ -2,6 +2,7 @@ package com.github.nacabaro.vbhelper.daos
import androidx.room.Dao
import androidx.room.Query
+import androidx.room.RewriteQueriesToDropUnusedColumns
import com.github.cfogrady.vbnfc.data.NfcCharacter
import com.github.nacabaro.vbhelper.dtos.CharacterDtos
import kotlinx.coroutines.flow.Flow
@@ -27,12 +28,12 @@ interface CardFusionsDao {
toCharaId: Int
)
+ @RewriteQueriesToDropUnusedColumns
@Query("""
SELECT
cf.toCharaId as charaId,
cf.fromCharaId as fromCharaId,
s.spriteIdle1 as spriteIdle,
- cc.attribute as attribute,
s.width as spriteWidth,
s.height as spriteHeight,
d.discoveredOn as discoveredOn,
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/daos/CharacterDao.kt b/app/src/main/java/com/github/nacabaro/vbhelper/daos/CharacterDao.kt
index 378e92f..e8f0de4 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/daos/CharacterDao.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/daos/CharacterDao.kt
@@ -13,9 +13,15 @@ interface CharacterDao {
@Insert
suspend fun insertCharacter(vararg characterData: CardCharacter)
+ @Query("SELECT * FROM CardCharacter WHERE id = :id LIMIT 1")
+ fun getCharacterById(id: Long): CardCharacter?
+
@Query("SELECT * FROM CardCharacter WHERE charaIndex = :monIndex AND cardId = :dimId LIMIT 1")
fun getCharacterByMonIndex(monIndex: Int, dimId: Long): CardCharacter
+ @Query("SELECT * FROM CardCharacter WHERE charaIndex = :monIndex AND cardId = :dimId LIMIT 1")
+ fun getCharacterByMonIndexOrNull(monIndex: Int, dimId: Long): CardCharacter?
+
@Insert
suspend fun insertSprite(vararg sprite: Sprite)
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/daos/SpriteDao.kt b/app/src/main/java/com/github/nacabaro/vbhelper/daos/SpriteDao.kt
index 4d8d6e6..62943a5 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/daos/SpriteDao.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/daos/SpriteDao.kt
@@ -11,6 +11,9 @@ interface SpriteDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertSprite(sprite: Sprite): Long
+ @Query("SELECT * FROM Sprite WHERE id = :id LIMIT 1")
+ fun getSpriteById(id: Long): Sprite?
+
@Query("SELECT * FROM Sprite")
suspend fun getAllSprites(): List
}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/daos/UserCharacterDao.kt b/app/src/main/java/com/github/nacabaro/vbhelper/daos/UserCharacterDao.kt
index 218191c..b8bd138 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/daos/UserCharacterDao.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/daos/UserCharacterDao.kt
@@ -4,6 +4,7 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
+import androidx.room.RewriteQueriesToDropUnusedColumns
import androidx.room.Upsert
import com.github.nacabaro.vbhelper.domain.card.CardCharacter
import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter
@@ -38,6 +39,7 @@ interface UserCharacterDao {
@Upsert
fun insertSpecialMissions(vararg specialMissions: SpecialMissions)
+ @RewriteQueriesToDropUnusedColumns
@Query(
"""
SELECT
@@ -55,6 +57,7 @@ interface UserCharacterDao {
)
fun getTransformationHistory(monId: Long): Flow>
+ @RewriteQueriesToDropUnusedColumns
@Query(
"""
SELECT
@@ -71,6 +74,7 @@ interface UserCharacterDao {
)
suspend fun getTransformationHistoryForExport(monId: Long): List
+ @RewriteQueriesToDropUnusedColumns
@Query(
"""
SELECT
@@ -96,6 +100,7 @@ interface UserCharacterDao {
)
fun getAllCharacters(): Flow>
+ @RewriteQueriesToDropUnusedColumns
@Query(
"""
SELECT
@@ -137,9 +142,13 @@ interface UserCharacterDao {
@Query("SELECT * FROM VBCharacterData WHERE id = :id")
suspend fun getVbDataOrNull(id: Long): VBCharacterData?
+ @Query("DELETE FROM BECharacterData WHERE id = :id")
+ suspend fun deleteBECharacterData(id: Long)
+
@Query("SELECT * FROM SpecialMissions WHERE characterId = :id")
fun getSpecialMissions(id: Long): Flow>
+ @RewriteQueriesToDropUnusedColumns
@Query(
"""
SELECT
@@ -205,6 +214,7 @@ interface UserCharacterDao {
@Query("""SELECT * FROM VitalsHistory WHERE charId = :charId ORDER BY id ASC""")
suspend fun getVitalsHistory(charId: Long): List
+ @RewriteQueriesToDropUnusedColumns
@Query(
"""
SELECT
@@ -231,6 +241,7 @@ interface UserCharacterDao {
)
suspend fun getBECharacters(): List
+ @RewriteQueriesToDropUnusedColumns
@Query(
"""
SELECT
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/database/AppDatabase.kt b/app/src/main/java/com/github/nacabaro/vbhelper/database/AppDatabase.kt
index 06b77b8..566f828 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/database/AppDatabase.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/database/AppDatabase.kt
@@ -7,7 +7,6 @@ import androidx.sqlite.db.SupportSQLiteDatabase
import com.github.nacabaro.vbhelper.daos.AdventureDao
import com.github.nacabaro.vbhelper.daos.CardAdventureDao
import com.github.nacabaro.vbhelper.daos.CharacterDao
-import com.github.nacabaro.vbhelper.daos.CharacterTransferPolicyDao
import com.github.nacabaro.vbhelper.daos.DexDao
import com.github.nacabaro.vbhelper.daos.CardDao
import com.github.nacabaro.vbhelper.daos.CardFusionsDao
@@ -28,7 +27,6 @@ import com.github.nacabaro.vbhelper.domain.characters.Sprite
import com.github.nacabaro.vbhelper.domain.characters.Adventure
import com.github.nacabaro.vbhelper.domain.characters.Dex
import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData
-import com.github.nacabaro.vbhelper.domain.device_data.CharacterTransferPolicy
import com.github.nacabaro.vbhelper.domain.device_data.SpecialMissions
import com.github.nacabaro.vbhelper.domain.device_data.TransformationHistory
import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter
@@ -39,6 +37,7 @@ import com.github.nacabaro.vbhelper.domain.items.Items
@Database(
version = 6,
+ exportSchema = false,
entities = [
Card::class,
CardProgress::class,
@@ -52,7 +51,6 @@ import com.github.nacabaro.vbhelper.domain.items.Items
SpecialMissions::class,
TransformationHistory::class,
VitalsHistory::class,
- CharacterTransferPolicy::class,
Dex::class,
Items::class,
Adventure::class,
@@ -74,7 +72,6 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun cardAdventureDao(): CardAdventureDao
abstract fun cardFusionsDao(): CardFusionsDao
abstract fun vitalWearSettingsDao(): VitalWearSettingsDao
- abstract fun characterTransferPolicyDao(): CharacterTransferPolicyDao
companion object {
val MIGRATION_1_2 = object : Migration(1, 2) {
@@ -145,23 +142,19 @@ abstract class AppDatabase : RoomDatabase() {
val MIGRATION_5_6 = object : Migration(5, 6) {
override fun migrate(db: SupportSQLiteDatabase) {
- db.execSQL(
- """
- CREATE TABLE IF NOT EXISTS `CharacterTransferPolicy` (
- `characterId` INTEGER NOT NULL,
- `nativeDeviceType` TEXT NOT NULL,
- `preferredHceExportFormat` TEXT NOT NULL,
- `preferredNfcaExportFormat` TEXT NOT NULL,
- `lastObservedImportFormat` TEXT,
- `lastTransferTransport` TEXT,
- `lastTransferTarget` TEXT,
- `preserveVbRoundTrip` INTEGER NOT NULL,
- `preserveBeRoundTrip` INTEGER NOT NULL,
- PRIMARY KEY(`characterId`),
- FOREIGN KEY(`characterId`) REFERENCES `UserCharacter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE
- )
- """.trimIndent()
- )
+ db.execSQL("CREATE INDEX IF NOT EXISTS `index_CardCharacter_spriteId` ON `CardCharacter` (`spriteId`)")
+ db.execSQL("CREATE INDEX IF NOT EXISTS `index_CardAdventure_cardId` ON `CardAdventure` (`cardId`)")
+ db.execSQL("CREATE INDEX IF NOT EXISTS `index_CardAdventure_characterId` ON `CardAdventure` (`characterId`)")
+ db.execSQL("CREATE INDEX IF NOT EXISTS `index_CardFusions_fromCharaId` ON `CardFusions` (`fromCharaId`)")
+ db.execSQL("CREATE INDEX IF NOT EXISTS `index_CardFusions_toCharaId` ON `CardFusions` (`toCharaId`)")
+ db.execSQL("CREATE INDEX IF NOT EXISTS `index_Background_cardId` ON `Background` (`cardId`)")
+ db.execSQL("CREATE INDEX IF NOT EXISTS `index_PossibleTransformations_charaId` ON `PossibleTransformations` (`charaId`)")
+ db.execSQL("CREATE INDEX IF NOT EXISTS `index_PossibleTransformations_toCharaId` ON `PossibleTransformations` (`toCharaId`)")
+ db.execSQL("CREATE INDEX IF NOT EXISTS `index_UserCharacter_charId` ON `UserCharacter` (`charId`)")
+ db.execSQL("CREATE INDEX IF NOT EXISTS `index_SpecialMissions_characterId` ON `SpecialMissions` (`characterId`)")
+ db.execSQL("CREATE INDEX IF NOT EXISTS `index_TransformationHistory_monId` ON `TransformationHistory` (`monId`)")
+ db.execSQL("CREATE INDEX IF NOT EXISTS `index_TransformationHistory_stageId` ON `TransformationHistory` (`stageId`)")
+ db.execSQL("CREATE INDEX IF NOT EXISTS `index_VitalsHistory_charId` ON `VitalsHistory` (`charId`)")
}
}
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/di/AppContainer.kt b/app/src/main/java/com/github/nacabaro/vbhelper/di/AppContainer.kt
index 5034497..ff6d8fc 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/di/AppContainer.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/di/AppContainer.kt
@@ -2,6 +2,7 @@ package com.github.nacabaro.vbhelper.di
import com.github.cfogrady.vitalwear.common.data.SharedTransferSeenDao
import com.github.nacabaro.vbhelper.database.AppDatabase
+import com.github.nacabaro.vbhelper.source.CardSettingsRepository
import com.github.nacabaro.vbhelper.source.CurrencyRepository
import com.github.nacabaro.vbhelper.source.DataStoreSecretsRepository
@@ -10,4 +11,5 @@ interface AppContainer {
val transferSeenDao: SharedTransferSeenDao
val dataStoreSecretsRepository: DataStoreSecretsRepository
val currencyRepository: CurrencyRepository
+ val cardSettingsRepository: CardSettingsRepository
}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/di/DefaultAppContainer.kt b/app/src/main/java/com/github/nacabaro/vbhelper/di/DefaultAppContainer.kt
index 49d0542..c949f86 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/di/DefaultAppContainer.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/di/DefaultAppContainer.kt
@@ -8,6 +8,7 @@ import com.github.cfogrady.vitalwear.common.data.SharedDatabaseFactory
import com.github.cfogrady.vitalwear.common.data.SharedTransferSeenDao
import com.github.nacabaro.vbhelper.di.AppContainer
import com.github.nacabaro.vbhelper.database.AppDatabase
+import com.github.nacabaro.vbhelper.source.CardSettingsRepository
import com.github.nacabaro.vbhelper.source.CurrencyRepository
import com.github.nacabaro.vbhelper.source.DataStoreSecretsRepository
import com.github.nacabaro.vbhelper.source.SecretsSerializer
@@ -37,6 +38,7 @@ class DefaultAppContainer(private val context: Context) : AppContainer {
.addMigrations(AppDatabase.MIGRATION_2_3)
.addMigrations(AppDatabase.MIGRATION_3_5)
.addMigrations(AppDatabase.MIGRATION_4_5)
+ .addMigrations(AppDatabase.MIGRATION_5_6)
.build()
}
@@ -47,5 +49,7 @@ class DefaultAppContainer(private val context: Context) : AppContainer {
override val dataStoreSecretsRepository = DataStoreSecretsRepository(context.secretsStore)
override val currencyRepository = CurrencyRepository(context.currencyStore)
+
+ override val cardSettingsRepository = CardSettingsRepository(context.currencyStore)
}
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/di/VBHelper.kt b/app/src/main/java/com/github/nacabaro/vbhelper/di/VBHelper.kt
index 0c66c99..b6e6c07 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/di/VBHelper.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/di/VBHelper.kt
@@ -2,12 +2,37 @@ package com.github.nacabaro.vbhelper.di
import DefaultAppContainer
import android.app.Application
+import androidx.room.Room
+import com.github.nacabaro.vbhelper.companion.logging.TinyLogTree
+import com.github.nacabaro.vbhelper.companion.logs.CompanionLogService
+import com.github.nacabaro.vbhelper.companion.validation.CompanionValidatedDatabase
+import com.github.nacabaro.vbhelper.companion.validation.ValidatedCardManager
+import timber.log.Timber
class VBHelper : Application() {
lateinit var container: DefaultAppContainer
+ lateinit var validatedCardDatabase: CompanionValidatedDatabase
+ lateinit var validatedCardManager: ValidatedCardManager
+ val companionLogService = CompanionLogService()
override fun onCreate() {
super.onCreate()
container = DefaultAppContainer(applicationContext)
+
+ Timber.plant(Timber.DebugTree())
+ Timber.plant(TinyLogTree(this))
+ val originalExceptionHandler = Thread.getDefaultUncaughtExceptionHandler()
+ Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
+ Timber.e(throwable, "VBHelper crashed on thread ${thread.name}")
+ TinyLogTree.shutdown()
+ originalExceptionHandler?.uncaughtException(thread, throwable)
+ }
+
+ validatedCardDatabase = Room.databaseBuilder(
+ applicationContext,
+ CompanionValidatedDatabase::class.java,
+ "vbhelper_companion_tools"
+ ).build()
+ validatedCardManager = ValidatedCardManager(validatedCardDatabase.validatedCardDao())
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/domain/card/Background.kt b/app/src/main/java/com/github/nacabaro/vbhelper/domain/card/Background.kt
index 23766d4..d49c49a 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/domain/card/Background.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/domain/card/Background.kt
@@ -2,9 +2,11 @@ package com.github.nacabaro.vbhelper.domain.card
import androidx.room.Entity
import androidx.room.ForeignKey
+import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
+ indices = [Index(value = ["cardId"])],
foreignKeys = [
ForeignKey(
entity = Card::class,
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/domain/card/CardAdventure.kt b/app/src/main/java/com/github/nacabaro/vbhelper/domain/card/CardAdventure.kt
index b8f4f56..5fe0e55 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/domain/card/CardAdventure.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/domain/card/CardAdventure.kt
@@ -2,9 +2,14 @@ package com.github.nacabaro.vbhelper.domain.card
import androidx.room.Entity
import androidx.room.ForeignKey
+import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
+ indices = [
+ Index(value = ["cardId"]),
+ Index(value = ["characterId"])
+ ],
foreignKeys = [
ForeignKey(
entity = CardCharacter::class,
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/domain/card/CardCharacter.kt b/app/src/main/java/com/github/nacabaro/vbhelper/domain/card/CardCharacter.kt
index d59b5fd..bfed547 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/domain/card/CardCharacter.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/domain/card/CardCharacter.kt
@@ -9,7 +9,8 @@ import com.github.nacabaro.vbhelper.domain.characters.Sprite
@Entity(
indices = [
- Index(value = ["cardId", "charaIndex"], unique = true)
+ Index(value = ["cardId", "charaIndex"], unique = true),
+ Index(value = ["spriteId"])
],
foreignKeys = [
ForeignKey(
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/domain/card/CardFusions.kt b/app/src/main/java/com/github/nacabaro/vbhelper/domain/card/CardFusions.kt
index 1769eda..ecc4e41 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/domain/card/CardFusions.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/domain/card/CardFusions.kt
@@ -2,10 +2,15 @@ package com.github.nacabaro.vbhelper.domain.card
import androidx.room.Entity
import androidx.room.ForeignKey
+import androidx.room.Index
import androidx.room.PrimaryKey
import com.github.cfogrady.vbnfc.data.NfcCharacter
@Entity(
+ indices = [
+ Index(value = ["fromCharaId"]),
+ Index(value = ["toCharaId"])
+ ],
foreignKeys = [
ForeignKey(
entity = CardCharacter::class,
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/domain/card/PossibleTransformations.kt b/app/src/main/java/com/github/nacabaro/vbhelper/domain/card/PossibleTransformations.kt
index 2dc2e8b..5312070 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/domain/card/PossibleTransformations.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/domain/card/PossibleTransformations.kt
@@ -2,9 +2,14 @@ package com.github.nacabaro.vbhelper.domain.card
import androidx.room.Entity
import androidx.room.ForeignKey
+import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
+ indices = [
+ Index(value = ["charaId"]),
+ Index(value = ["toCharaId"])
+ ],
foreignKeys = [
ForeignKey(
entity = CardCharacter::class,
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/SpecialMissions.kt b/app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/SpecialMissions.kt
index 13b092d..ef2e3dd 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/SpecialMissions.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/SpecialMissions.kt
@@ -2,10 +2,12 @@ package com.github.nacabaro.vbhelper.domain.device_data
import androidx.room.Entity
import androidx.room.ForeignKey
+import androidx.room.Index
import androidx.room.PrimaryKey
import com.github.cfogrady.vbnfc.vb.SpecialMission
@Entity(
+ indices = [Index(value = ["characterId"])],
foreignKeys = [
ForeignKey(
entity = UserCharacter::class,
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/TransformationHistory.kt b/app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/TransformationHistory.kt
index 0a560cc..436af2b 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/TransformationHistory.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/TransformationHistory.kt
@@ -2,10 +2,15 @@ package com.github.nacabaro.vbhelper.domain.device_data
import androidx.room.Entity
import androidx.room.ForeignKey
+import androidx.room.Index
import androidx.room.PrimaryKey
import com.github.nacabaro.vbhelper.domain.card.CardCharacter
@Entity(
+ indices = [
+ Index(value = ["monId"]),
+ Index(value = ["stageId"])
+ ],
foreignKeys = [
ForeignKey(
entity = UserCharacter::class,
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/UnifiedCharacter.kt b/app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/UnifiedCharacter.kt
new file mode 100644
index 0000000..0acb709
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/UnifiedCharacter.kt
@@ -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
+}
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/UserCharacter.kt b/app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/UserCharacter.kt
index e34b57d..3a68aa4 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/UserCharacter.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/UserCharacter.kt
@@ -2,12 +2,14 @@ package com.github.nacabaro.vbhelper.domain.device_data
import androidx.room.Entity
import androidx.room.ForeignKey
+import androidx.room.Index
import androidx.room.PrimaryKey
import com.github.cfogrady.vbnfc.data.NfcCharacter
import com.github.nacabaro.vbhelper.utils.DeviceType
import com.github.nacabaro.vbhelper.domain.card.CardCharacter
@Entity(
+ indices = [Index(value = ["charId"])],
foreignKeys = [
ForeignKey(
entity = CardCharacter::class,
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/VitalsHistory.kt b/app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/VitalsHistory.kt
index 313e7c1..163b44e 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/VitalsHistory.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/VitalsHistory.kt
@@ -2,9 +2,11 @@ package com.github.nacabaro.vbhelper.domain.device_data
import androidx.room.Entity
import androidx.room.ForeignKey
+import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
+ indices = [Index(value = ["charId"])],
foreignKeys = [
ForeignKey(
entity = UserCharacter::class,
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/navigation/AppNavigation.kt b/app/src/main/java/com/github/nacabaro/vbhelper/navigation/AppNavigation.kt
index 2b3d047..22f43be 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/navigation/AppNavigation.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/navigation/AppNavigation.kt
@@ -15,6 +15,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.navigation.compose.NavHost
+import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.github.nacabaro.vbhelper.di.VBHelper
@@ -40,7 +41,6 @@ import com.github.nacabaro.vbhelper.screens.settingsScreen.CreditsScreen
import com.github.nacabaro.vbhelper.screens.spriteViewer.SpriteViewerControllerImpl
import com.github.nacabaro.vbhelper.screens.storageScreen.StorageScreenControllerImpl
import com.github.nacabaro.vbhelper.source.StorageRepository
-import kotlinx.coroutines.flow.Flow
data class AppNavigationHandlers(
val settingsScreenController: SettingsScreenControllerImpl,
@@ -56,20 +56,22 @@ data class AppNavigationHandlers(
@Composable
fun AppNavigation(
applicationNavigationHandlers: AppNavigationHandlers,
- initialRoute: String? = null,
- navigationEvents: Flow? = null,
+ initialRoute: String? = null
) {
val navController = rememberNavController()
- LaunchedEffect(navController, navigationEvents) {
- navigationEvents?.collect { route ->
- navController.navigate(route) {
- launchSingleTop = true
- restoreState = true
- popUpTo(navController.graph.startDestinationId) {
- saveState = true
- }
+ LaunchedEffect(initialRoute) {
+ val route = initialRoute ?: return@LaunchedEffect
+ if (navController.currentBackStackEntry?.destination?.route == route) {
+ return@LaunchedEffect
+ }
+ navController.navigate(route) {
+ popUpTo(navController.graph.findStartDestination().id) {
+ inclusive = false
+ saveState = false
}
+ launchSingleTop = true
+ restoreState = false
}
}
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/navigation/BottomNavigationBar.kt b/app/src/main/java/com/github/nacabaro/vbhelper/navigation/BottomNavigationBar.kt
index 672ffd7..0118833 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/navigation/BottomNavigationBar.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/navigation/BottomNavigationBar.kt
@@ -7,6 +7,7 @@ import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.painterResource
import androidx.navigation.NavController
+import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.compose.ui.res.stringResource
@@ -30,40 +31,14 @@ fun BottomNavigationBar(navController: NavController) {
label = { Text(text = stringResource(item.label)) },
selected = currentRoute == item.route,
onClick = {
- if (item == NavigationItems.Home) {
- // Home should always show the Home root, not a restored nested route
- // like Settings that was opened from Home previously.
- val poppedToHome = navController.popBackStack(
- NavigationItems.Home.route,
- inclusive = false,
- )
- if (!poppedToHome) {
- navController.navigate(NavigationItems.Home.route) {
- popUpTo(navController.graph.startDestinationId) { inclusive = false }
- launchSingleTop = true
- restoreState = false
- }
- }
- } else if (item == NavigationItems.Storage) {
- // Adventure is launched from Storage; tapping Storage again should always
- // bring the user back to the Storage root instead of appearing stuck.
- val poppedToStorage = navController.popBackStack(
- NavigationItems.Storage.route,
- inclusive = false,
- )
- if (!poppedToStorage) {
- navController.navigate(NavigationItems.Storage.route) {
- popUpTo(navController.graph.startDestinationId) { saveState = true }
- launchSingleTop = true
- restoreState = true
- }
- }
- } else {
- navController.navigate(item.route) {
- popUpTo(navController.graph.startDestinationId) { saveState = true }
- launchSingleTop = true
- restoreState = true
+ navController.navigate(item.route) {
+ // Always route tab clicks to each tab's root screen.
+ popUpTo(navController.graph.findStartDestination().id) {
+ inclusive = false
+ saveState = false
}
+ launchSingleTop = true
+ restoreState = false
}
}
)
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/navigation/NavigationExtensions.kt b/app/src/main/java/com/github/nacabaro/vbhelper/navigation/NavigationExtensions.kt
new file mode 100644
index 0000000..e69de29
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/navigation/NavigationItems.kt b/app/src/main/java/com/github/nacabaro/vbhelper/navigation/NavigationItems.kt
index 77b9b91..27af488 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/navigation/NavigationItems.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/navigation/NavigationItems.kt
@@ -6,8 +6,8 @@ import androidx.annotation.StringRes
sealed class NavigationItems(
val route: String,
- @DrawableRes val icon: Int,
- @StringRes val label: Int
+ @param:DrawableRes val icon: Int,
+ @param:StringRes val label: Int
) {
object Scan : NavigationItems(
"Scan/{characterId}",
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/BattlesScreen.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/BattlesScreen.kt
index dad71ac..179776b 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/BattlesScreen.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/BattlesScreen.kt
@@ -51,6 +51,7 @@ import android.content.Intent
import android.net.Uri
import android.media.MediaPlayer
import android.os.Environment
+import android.widget.Toast
//import androidx.compose.animation.core.animateFloatAsState
//import androidx.compose.animation.core.tween
import kotlinx.coroutines.delay
@@ -64,6 +65,8 @@ import com.github.nacabaro.vbhelper.battle.APIBattleCharacter
import android.util.Log
import com.github.nacabaro.vbhelper.components.TopBanner
import com.github.nacabaro.vbhelper.battle.RetrofitHelper
+import com.github.nacabaro.vbhelper.battle.extractSessionToken
+import com.github.nacabaro.vbhelper.battle.extractUserId
import com.github.nacabaro.vbhelper.battle.AttackSpriteImage
import com.github.nacabaro.vbhelper.battle.SpriteFileManager
import com.github.nacabaro.vbhelper.battle.ArenaBattleSystem
@@ -71,6 +74,7 @@ import com.github.nacabaro.vbhelper.battle.DigimonAnimationType
import com.github.nacabaro.vbhelper.battle.AnimatedSpriteImage
import com.github.nacabaro.vbhelper.battle.HitEffectOverlay
import com.github.nacabaro.vbhelper.battle.BattleAuthContainer
+import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.collect
import androidx.compose.foundation.lazy.LazyColumn
@@ -899,7 +903,6 @@ fun MiddleBattleView(
y = enemyVerticalOffset + 40.dp
),
contentScale = ContentScale.Fit,
- reloadMappings = false,
animationOffset = 375L // Offset enemy animation by half the idle duration
)
@@ -986,7 +989,6 @@ fun MiddleBattleView(
y = playerVerticalOffset - 40.dp
),
contentScale = ContentScale.Fit,
- reloadMappings = false,
animationOffset = 0L // Player animation starts immediately
)
@@ -1072,19 +1074,30 @@ fun MiddleBattleView(
// Capture userId for use in this lambda
val playerUserId = userId
-
+ if (playerUserId == null) {
+ context?.let {
+ Toast.makeText(it, "Session missing user id. Please re-authenticate.", Toast.LENGTH_SHORT).show()
+ }
+ battleSystem.resetAttackState()
+ battleSystem.enableAttackButton()
+ return@Button
+ }
+
// Get crit bar progress as float (0.0f to 100.0f)
val critBarProgressFloat = battleSystem.critBarProgress.toFloat()
// Determine player and opponent stages
+ // activeCharacter.stage is DB stage (2=rookie, 3=champion, 4=ultimate, 5=mega)
+ // API expects stage 0-3
val playerStage = when (activeCharacter?.stage) {
- 0 -> 0 // rookie
- 1 -> 1 // champion
- 2 -> 2 // ultimate
- 3 -> 3 // mega
+ 2 -> 0 // rookie
+ 3 -> 1 // champion
+ 4 -> 2 // ultimate
+ 5 -> 3 // mega
else -> 0
}
+ // opponentCharacter.stage comes from the API and is already 0-3
val opponentStage = when (opponentCharacter?.stage) {
0 -> 0 // rookie
1 -> 1 // champion
@@ -1101,11 +1114,11 @@ fun MiddleBattleView(
RetrofitHelper().getPVPWinner(
ctx,
1,
- playerUserId ?: 2L,
- activeCharacter?.name ?: "Player",
- playerStage,
- opponentStage,
- opponentCharacter?.name ?: "Opponent",
+ playerUserId,
+ activeCharacter?.charaId ?: "dim011_mon01",
+ playerStage,
+ battleSystem.critBarProgress,
+ opponentCharacter?.charaId ?: "dim011_mon01",
opponentStage
) { apiResult ->
// Handle API response here
@@ -1309,7 +1322,6 @@ fun PlayerBattleView(
y = verticalOffset
),
contentScale = ContentScale.Fit,
- reloadMappings = false,
animationOffset = 0L // Player animation starts immediately
)
@@ -1491,7 +1503,6 @@ fun EnemyBattleView(
y = verticalOffset
),
contentScale = ContentScale.Fit,
- reloadMappings = false,
animationOffset = 375L // Offset enemy animation by half the idle duration
)
@@ -1641,8 +1652,63 @@ fun BattlesScreen() {
// Track processed tokens to prevent duplicate API calls
// Use rememberSaveable to persist across configuration changes (screen rotation)
var processedTokens by rememberSaveable { mutableStateOf>(emptySet()) }
+ var isProcessingAuthCallback by rememberSaveable { mutableStateOf(false) }
+ var authBrowserLaunchInFlight by rememberSaveable { mutableStateOf(false) }
var opponentsList by remember { mutableStateOf(ArrayList()) }
+ var isLoadingOpponents by remember { mutableStateOf(false) }
+ var authReadyToken by remember { mutableStateOf(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(null) }
var selectedOpponent by remember { mutableStateOf(null) }
@@ -1724,39 +1790,85 @@ fun BattlesScreen() {
// Determine if player can battle based on stage (derived from activeUserCharacter)
val canBattle = activeUserCharacter?.stage?.let { it >= 2 } ?: false
- // Load opponents automatically based on player's stage
- // Only load if authenticated and character is ready
- LaunchedEffect(activeUserCharacter, isAuthenticated) {
- // Wait for authentication to complete before loading opponents
- if (!isAuthenticated) {
- return@LaunchedEffect
- }
-
+ val loadOpponentsIfReady: () -> Unit = loadOpponents@{
val currentCharacter = activeUserCharacter
- if (currentCharacter != null && canBattle && playerBattleType != null) {
- try {
- RetrofitHelper().getOpponents(context, playerBattleType!!) { opponents ->
- try {
- // Create a new list to trigger UI recomposition
- opponentsList = ArrayList(opponents.opponentsList)
- } catch (e: Exception) {
- Log.d(TAG, "Error processing opponents data: ${e.message}")
- e.printStackTrace()
- }
- }
- } catch (e: Exception) {
- Log.d(TAG,"Error calling getOpponents: ${e.message}")
- e.printStackTrace()
- }
- } else {
- println("BATTLESCREEN: Cannot load opponents - activeUserCharacter: $currentCharacter")
- println("BATTLESCREEN: canBattle: $canBattle")
- println("BATTLESCREEN: playerBattleType: $playerBattleType")
- println("BATTLESCREEN: currentCharacter != null: ${currentCharacter != null}")
- if (currentCharacter != null) {
- println("BATTLESCREEN: currentCharacter.stage: ${currentCharacter.stage}")
- println("BATTLESCREEN: currentCharacter.stage >= 2: ${currentCharacter.stage >= 2}")
+ val readyToken = authReadyToken
+
+ if (!isAuthenticated) {
+ if (opponentsList.isNotEmpty()) {
+ opponentsList = ArrayList()
}
+ return@loadOpponents
+ }
+
+ if (readyToken.isNullOrBlank()) {
+ println("BATTLESCREEN: Skipping opponent load - auth token not persisted yet")
+ return@loadOpponents
+ }
+
+ if (isLoadingOpponents) {
+ println("BATTLESCREEN: Opponents load already in flight, skipping duplicate request")
+ return@loadOpponents
+ }
+
+ if (currentCharacter != null && currentCharacter.stage >= 2) {
+ val battleType = when (currentCharacter.stage) {
+ 2 -> "rookie"
+ 3 -> "champion"
+ 4 -> "ultimate"
+ 5 -> "mega"
+ else -> null
+ }
+ if (battleType == null) {
+ println("BATTLESCREEN: Stage ${currentCharacter.stage} has no battle type, skipping")
+ return@loadOpponents
+ }
+ try {
+ isLoadingOpponents = true
+ RetrofitHelper().getOpponents(context, battleType, onComplete = {
+ isLoadingOpponents = false
+ }) { opponents ->
+ try {
+ opponentsList = ArrayList(opponents.opponentsList)
+ println("BATTLESCREEN: Loaded ${opponents.opponentsList.size} opponents for stage $battleType")
+ } catch (e: Exception) {
+ Log.d(TAG, "Error processing opponents data: ${e.message}")
+ e.printStackTrace()
+ }
+ }
+ } catch (e: Exception) {
+ isLoadingOpponents = false
+ Log.d(TAG, "Error calling getOpponents: ${e.message}")
+ e.printStackTrace()
+ }
+ } else {
+ if (opponentsList.isNotEmpty()) {
+ opponentsList = ArrayList()
+ }
+ println("BATTLESCREEN: Cannot load opponents - stage ${currentCharacter?.stage}, must be >= 2")
+ }
+ }
+
+ // Load opponents automatically based on player's stage once auth is truly ready.
+ LaunchedEffect(activeUserCharacter, isAuthenticated, authReadyToken, playerBattleType) {
+ loadOpponentsIfReady()
+ }
+
+ // Re-fetch opponents when returning from browser (resume)
+ DisposableEffect(context, isAuthenticated, activeUserCharacter, canBattle, playerBattleType, authReadyToken) {
+ val activity = context as? ComponentActivity
+ val lifecycleOwner = activity as? LifecycleOwner
+
+ val observer = LifecycleEventObserver { _, event ->
+ if (event == Lifecycle.Event.ON_RESUME) {
+ if (isAuthenticated && !authReadyToken.isNullOrBlank()) {
+ loadOpponentsIfReady()
+ }
+ }
+ }
+ lifecycleOwner?.lifecycle?.addObserver(observer)
+ onDispose {
+ lifecycleOwner?.lifecycle?.removeObserver(observer)
}
}
@@ -1775,17 +1887,19 @@ fun BattlesScreen() {
if (!processedTokens.contains(token)) {
// Mark token as being processed IMMEDIATELY to prevent duplicate API calls
processedTokens = processedTokens + token
-
+ isProcessingAuthCallback = true
+ authBrowserLaunchInFlight = false
+
// Exchange token with battle server
RetrofitHelper().authenticate(context, token) { response ->
if (response.success) {
// Extract userId and sessionToken from response
- val extractedUserId = response.userInfo?.userId?.toLongOrNull()
- val sessionToken = response.sessionToken
-
+ val extractedUserId = response.extractUserId()
+ val sessionToken = response.extractSessionToken()
+
println("BATTLESCREEN: Authentication successful, userId: $extractedUserId, sessionToken: ${if (sessionToken != null) "present" else "missing"}")
- // Store both nacatech token (for re-auth) and sessionToken (for API calls)
+ // Persist auth first so follow-up API calls can immediately use the token.
kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch {
battleAuthContainer.authRepository.setAuthenticated(
isAuthenticated = true,
@@ -1793,14 +1907,16 @@ fun BattlesScreen() {
sessionToken = sessionToken,
userId = extractedUserId
)
- }
- // Update UI state on main thread
- kotlinx.coroutines.CoroutineScope(Dispatchers.Main).launch {
- isAuthenticated = true
- isCheckingAuth = false
- userId = extractedUserId
- println("BATTLESCREEN: Authentication successful, userId: $extractedUserId")
- android.widget.Toast.makeText(context, "Authentication successful!", android.widget.Toast.LENGTH_SHORT).show()
+
+ withContext(Dispatchers.Main) {
+ isAuthenticated = true
+ isCheckingAuth = false
+ userId = extractedUserId
+ isProcessingAuthCallback = false
+ authBrowserLaunchInFlight = false
+ println("BATTLESCREEN: Authentication successful, userId: $extractedUserId")
+ android.widget.Toast.makeText(context, "Authentication successful!", android.widget.Toast.LENGTH_SHORT).show()
+ }
}
} else {
println("BATTLESCREEN: Authentication failed: ${response.message}")
@@ -1815,23 +1931,16 @@ fun BattlesScreen() {
kotlinx.coroutines.CoroutineScope(Dispatchers.Main).launch {
isAuthenticated = false
isCheckingAuth = false
- // Small delay to ensure state is updated
- kotlinx.coroutines.delay(100)
- // Open auth URL to get a fresh token
- val authUrl = "http://auth.nacatech.es/begin?app=443654920&redirect_uri=vbhelper://auth?token="
- val authIntent = Intent(Intent.ACTION_VIEW, Uri.parse(authUrl))
- try {
- context.startActivity(authIntent)
- println("BATTLESCREEN: Opened auth URL after token expiration: $authUrl")
- } catch (e: Exception) {
- println("BATTLESCREEN: Failed to open auth URL: ${e.message}")
- e.printStackTrace()
- }
- }
+ isProcessingAuthCallback = false
+ authBrowserLaunchInFlight = false
+ println("BATTLESCREEN: Auth callback token was already used; avoiding automatic browser relaunch loop")
+ }
} else {
// For other errors, remove from processed set to allow retry with a new token
println("BATTLESCREEN: Authentication failed, removing token from processed set to allow retry")
processedTokens = processedTokens - token
+ isProcessingAuthCallback = false
+ authBrowserLaunchInFlight = false
}
// Show toast on main thread
kotlinx.coroutines.CoroutineScope(Dispatchers.Main).launch {
@@ -1839,12 +1948,47 @@ fun BattlesScreen() {
}
}
}
+ } else {
+ println("BATTLESCREEN: Ignoring already processed auth token")
}
} else {
println("BATTLESCREEN: No token found in URI: $uri (checked 'c' and 'token' parameters)")
}
}
-
+
+ val consumeAuthCallbackIntent: () -> Boolean = consume@{
+ val currentActivity = context as? ComponentActivity ?: return@consume false
+ val incomingIntent = currentActivity.intent ?: return@consume false
+ if (incomingIntent.action != Intent.ACTION_VIEW) return@consume false
+
+ val uri = incomingIntent.data ?: return@consume false
+ val isAppAuthCallback = uri.scheme == "vbhelper" && uri.host == "auth"
+ val isLocalhostAuthCallback =
+ (uri.scheme == "http" || uri.scheme == "https") &&
+ (uri.host == "localhost" || uri.host == "127.0.0.1") &&
+ uri.path?.startsWith("/authenticate") == true
+ val hasAuthToken = !uri.getQueryParameter("c").isNullOrEmpty() ||
+ !uri.getQueryParameter("token").isNullOrEmpty()
+
+ if (!hasAuthToken) {
+ return@consume false
+ }
+
+ if (!isAppAuthCallback && !isLocalhostAuthCallback) {
+ return@consume false
+ }
+
+ handleTokenFromUri(uri)
+
+ // Clear callback intent so reopening Battles does not replay stale deep links.
+ currentActivity.setIntent(Intent(incomingIntent).apply {
+ action = Intent.ACTION_MAIN
+ data = null
+ })
+
+ true
+ }
+
// Check authentication status on load
LaunchedEffect(Unit) {
try {
@@ -1852,7 +1996,8 @@ fun BattlesScreen() {
val localAuthState = authRepository.isAuthenticated.first()
val storedToken = authRepository.authToken.first()
val storedUserId = authRepository.userId.first()
-
+ val storedSessionToken = authRepository.sessionToken.first()
+
// Load stored userId if available
if (storedUserId != null) {
userId = storedUserId
@@ -1867,41 +2012,36 @@ fun BattlesScreen() {
userId = storedUserId
}
- // Only check for token in intent if it's a fresh deep link (ACTION_VIEW intent)
- // This prevents processing stale tokens from previous sessions
- val activity = context as? ComponentActivity
- val intent = activity?.intent
- if (intent?.action == Intent.ACTION_VIEW) {
- intent.data?.let { uri ->
- if (uri.getQueryParameter("c") != null || uri.getQueryParameter("token") != null) {
- handleTokenFromUri(uri)
- return@LaunchedEffect // Don't open auth URL if we're processing a token
- }
- }
+ if (consumeAuthCallbackIntent()) {
+ return@LaunchedEffect
}
// If we have a stored token and userId, assume session is still active
// The single-use token can't be re-validated, but the server maintains a session after initial validation
// We'll only re-authenticate if API calls fail with authentication errors
- if (localAuthState && storedToken != null && storedToken.isNotEmpty() && storedUserId != null) {
+ if (localAuthState && storedToken != null && storedToken.isNotEmpty() && (storedUserId != null || !storedSessionToken.isNullOrEmpty())) {
// Session appears to be active - user is already authenticated
// No need to re-validate the single-use token, just restore the session
- println("BATTLESCREEN: Restoring active session (userId: $storedUserId)")
+ println("BATTLESCREEN: Restoring active session (userId: $storedUserId, sessionTokenPresent=${!storedSessionToken.isNullOrEmpty()})")
isAuthenticated = true
isCheckingAuth = false
userId = storedUserId
// Session is restored, no need to validate token again
- } else if (localAuthState && storedToken != null && storedToken.isNotEmpty()) {
+ } else if (localAuthState && storedToken != null && storedToken.isNotEmpty() && storedSessionToken.isNullOrEmpty()) {
// We have a token but no userId - try to validate once to get userId
// This should only happen on first login or if userId was lost
println("BATTLESCREEN: Have token but no userId, validating once to get userId...")
- RetrofitHelper().authenticate(context, storedToken) { response ->
+ RetrofitHelper().authenticate(
+ context = context,
+ token = storedToken,
+ showUserFacingErrors = false,
+ ) { response ->
// Update UI on main thread
kotlinx.coroutines.CoroutineScope(Dispatchers.Main).launch {
if (response.success) {
- val extractedUserId = response.userInfo?.userId?.toLongOrNull()
- val sessionToken = response.sessionToken
-
+ val extractedUserId = response.extractUserId()
+ val sessionToken = response.extractSessionToken()
+
// Update stored userId and sessionToken
if (extractedUserId != null) {
kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch {
@@ -1924,8 +2064,22 @@ fun BattlesScreen() {
response.message?.contains("nonce") == true ||
response.message?.contains("invalid") == true ||
response.message?.contains("expired") == true
-
+ val latestSessionToken = withContext(Dispatchers.IO) {
+ authRepository.sessionToken.first()
+ }
+ val latestUserId = withContext(Dispatchers.IO) {
+ authRepository.userId.first()
+ }
+
if (isCriticalError) {
+ // A parallel callback auth can complete before this fallback check returns.
+ if (!latestSessionToken.isNullOrEmpty() || latestUserId != null) {
+ println("BATTLESCREEN: Ignoring nonce validation failure because a valid session already exists")
+ isAuthenticated = true
+ isCheckingAuth = false
+ userId = latestUserId ?: userId
+ return@launch
+ }
// Critical error - token is invalid, need to re-authenticate
println("BATTLESCREEN: Critical authentication error, clearing state and redirecting")
kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch {
@@ -1933,11 +2087,7 @@ fun BattlesScreen() {
}
isAuthenticated = false
isCheckingAuth = false
- // Open auth URL
- val authUrl = "http://auth.nacatech.es/begin?app=443654920&redirect_uri=vbhelper://auth?token="
- val authIntent = Intent(Intent.ACTION_VIEW, Uri.parse(authUrl))
- context.startActivity(authIntent)
- println("BATTLESCREEN: Opened auth URL after critical validation failure: $authUrl")
+ openBattleAuthPage()
} else {
// Non-critical error (e.g., network issue) - keep authenticated state
println("BATTLESCREEN: Non-critical validation error, keeping authenticated state")
@@ -1952,10 +2102,7 @@ fun BattlesScreen() {
isAuthenticated = false
isCheckingAuth = false
// If not authenticated and no fresh token in intent, open auth URL
- val authUrl = "http://auth.nacatech.es/begin?app=443654920&redirect_uri=vbhelper://auth?token="
- val authIntent = Intent(Intent.ACTION_VIEW, Uri.parse(authUrl))
- context.startActivity(authIntent)
- println("BATTLESCREEN: Opened auth URL: $authUrl")
+ openBattleAuthPage()
}
} catch (e: Exception) {
println("BATTLESCREEN: Error checking authentication status: ${e.message}")
@@ -1964,42 +2111,11 @@ fun BattlesScreen() {
}
}
- // Handle deep link callback to get token
- // Check intent data on initial load - handle both vbhelper:// and http://localhost:8080/authenticate?c=
- // Only process if it's a fresh ACTION_VIEW intent (deep link)
+ // Handle deep link callback once on initial load and consume it.
LaunchedEffect(Unit) {
// Small delay to ensure activity is fully initialized
kotlinx.coroutines.delay(100)
-
- val activity = context as? ComponentActivity
- val intent = activity?.intent
-
- // Only process if this is a fresh deep link (ACTION_VIEW)
- if (intent?.action == Intent.ACTION_VIEW) {
- val uri = intent.data
- if (uri != null) {
-
- // Handle vbhelper://auth?token= or vbhelper://auth?c= deep link
- if (uri.scheme == "vbhelper" && uri.host == "auth") {
- handleTokenFromUri(uri)
- }
- // Handle http://localhost:8080/authenticate?c= redirect
- else if ((uri.scheme == "http" || uri.scheme == "https") &&
- (uri.host == "localhost" || uri.host == "127.0.0.1" || uri.host?.contains("8080") == true)) {
- handleTokenFromUri(uri)
- }
- // Also check if there's a 'c' or 'token' parameter in any URL
- else if (uri.getQueryParameter("c") != null || uri.getQueryParameter("token") != null) {
- handleTokenFromUri(uri)
- } else {
- println("BATTLESCREEN: URI found but no token parameter detected")
- }
- } else {
- println("BATTLESCREEN: ACTION_VIEW intent but no URI found")
- }
- } else {
- println("BATTLESCREEN: Not an ACTION_VIEW intent, skipping deep link processing")
- }
+ consumeAuthCallbackIntent()
}
// Check intent when screen becomes visible or when authentication state changes
@@ -2009,15 +2125,10 @@ fun BattlesScreen() {
val lifecycleOwner = activity as? LifecycleOwner
val observer = LifecycleEventObserver { _, event ->
- if (event == Lifecycle.Event.ON_RESUME && !isAuthenticated) {
- // Check intent data when activity resumes - only if it's a fresh ACTION_VIEW intent
- val intent = activity?.intent
- if (intent?.action == Intent.ACTION_VIEW) {
- intent.data?.let { uri ->
- if (uri.getQueryParameter("c") != null || uri.getQueryParameter("token") != null) {
- handleTokenFromUri(uri)
- }
- }
+ if (event == Lifecycle.Event.ON_RESUME) {
+ authBrowserLaunchInFlight = false
+ if (!isAuthenticated) {
+ consumeAuthCallbackIntent()
}
}
}
@@ -2038,45 +2149,16 @@ fun BattlesScreen() {
isAuthenticated = false
isCheckingAuth = false
// Open auth URL to get a fresh token
- val authUrl = "http://auth.nacatech.es/begin?app=443654920&redirect_uri=vbhelper://auth?token="
- val authIntent = Intent(Intent.ACTION_VIEW, Uri.parse(authUrl))
- try {
- context.startActivity(authIntent)
- println("BATTLESCREEN: Opened auth URL after token expiration: $authUrl")
- } catch (e: Exception) {
- println("BATTLESCREEN: Failed to open auth URL: ${e.message}")
- e.printStackTrace()
- }
+ openBattleAuthPage()
}
}
}
- // Also check intent when authentication state changes
- // Only process if it's a fresh ACTION_VIEW intent (deep link)
+ // Also check for callback when auth state changes.
LaunchedEffect(isAuthenticated) {
if (!isAuthenticated) {
kotlinx.coroutines.delay(200) // Small delay to ensure intent is available
- val activity = context as? ComponentActivity
- val intent = activity?.intent
- // Only process if this is a fresh deep link (ACTION_VIEW)
- if (intent?.action == Intent.ACTION_VIEW) {
- intent.data?.let { uri ->
- println("BATTLESCREEN: Re-checking ACTION_VIEW intent data - URI: $uri, scheme: ${uri.scheme}, host: ${uri.host}")
- // Handle vbhelper://auth?token= or vbhelper://auth?c= deep link
- if (uri.scheme == "vbhelper" && uri.host == "auth") {
- handleTokenFromUri(uri)
- }
- // Handle http://localhost:8080/authenticate?c= redirect
- else if ((uri.scheme == "http" || uri.scheme == "https") &&
- (uri.host == "localhost" || uri.host == "127.0.0.1" || uri.host?.contains("8080") == true)) {
- handleTokenFromUri(uri)
- }
- // Also check if there's a 'c' or 'token' parameter in any URL
- else if (uri.getQueryParameter("c") != null || uri.getQueryParameter("token") != null) {
- handleTokenFromUri(uri)
- }
- }
- }
+ consumeAuthCallbackIntent()
}
}
@@ -2084,9 +2166,8 @@ fun BattlesScreen() {
// Only check if permission is granted
LaunchedEffect(hasStoragePermission) {
if (hasStoragePermission) {
- val spriteFileManager = SpriteFileManager(context)
- if (spriteFileManager.checkSpriteFilesExist()) {
- } else {
+ val spriteFileManager = SpriteFileManager()
+ if (!spriteFileManager.checkSpriteFilesExist()) {
println("BATTLESCREEN: Sprite files not found in external storage")
}
} else {
@@ -2233,6 +2314,10 @@ fun BattlesScreen() {
color = Color.Gray,
textAlign = TextAlign.Center
)
+ Spacer(modifier = Modifier.height(16.dp))
+ Button(onClick = { openBattleAuthPage() }) {
+ Text("Open Login")
+ }
}
}
}
@@ -2267,6 +2352,12 @@ fun BattlesScreen() {
items(opponentsList) { opponent ->
Button(
onClick = {
+ val currentUserId = userId
+ if (currentUserId == null) {
+ Toast.makeText(context, "Session missing user id. Please re-authenticate.", Toast.LENGTH_SHORT).show()
+ return@Button
+ }
+
activeCardId?.let { cardId ->
selectedOpponent = opponent
// Randomly select background set (0, 1, or 2)
@@ -2281,7 +2372,7 @@ fun BattlesScreen() {
else -> 0
}
- RetrofitHelper().getPVPWinner(context, 0, userId ?: 2L, cardId, apiStage, 0, opponent.charaId, apiStage) { apiResult ->
+ RetrofitHelper().getPVPWinner(context, 0, currentUserId, cardId, apiStage, 0, opponent.charaId, apiStage) { apiResult ->
// Check if there's an existing match
when {
apiResult.status.contains("Existing match found", ignoreCase = true) -> {
@@ -2382,14 +2473,17 @@ fun BattlesScreen() {
LaunchedEffect(Unit) {
// Determine player and opponent stages
+ // activeCharacter.stage is DB stage (2=rookie, 3=champion, 4=ultimate, 5=mega)
+ // API expects stage 0-3
val playerStage = when (activeCharacter?.stage) {
- 0 -> 0 // rookie
- 1 -> 1 // champion
- 2 -> 2 // ultimate
- 3 -> 3 // mega
+ 2 -> 0 // rookie
+ 3 -> 1 // champion
+ 4 -> 2 // ultimate
+ 5 -> 3 // mega
else -> 0
}
+ // selectedOpponent.stage comes from the API and is already 0-3
val opponentStage = when (selectedOpponent?.stage) {
0 -> 0 // rookie
1 -> 1 // champion
@@ -2402,11 +2496,16 @@ fun BattlesScreen() {
RetrofitHelper().getPVPWinner(
context,
1,
- userId ?: 2L,
- activeCharacter?.name ?: "Player",
- playerStage,
- opponentStage,
- selectedOpponent?.name ?: "Opponent",
+ userId ?: run {
+ Toast.makeText(context, "Session missing user id. Please re-authenticate.", Toast.LENGTH_SHORT).show()
+ isWinnerLoaded = true
+ winnerName = "Unknown"
+ return@LaunchedEffect
+ },
+ activeCharacter?.charaId ?: "dim011_mon01",
+ playerStage,
+ 0,
+ selectedOpponent?.charaId ?: "dim011_mon01",
opponentStage
) { apiResult ->
// Winner might be empty in first call, but we can check HP values
@@ -2415,7 +2514,7 @@ fun BattlesScreen() {
// Also check winner field if it's not empty
val playerWonFromWinner = activeCardId?.let { cardId ->
- val winner = apiResult.winner ?: ""
+ val winner = apiResult.winner
if (winner.isNotEmpty()) {
if (winner.contains("|")) {
// Pipe-separated format: first part is the winner
@@ -2442,18 +2541,21 @@ fun BattlesScreen() {
println("BATTLESCREEN: Battle result (first call) - winner: '${apiResult.winner}', playerHP: ${apiResult.playerHP}, opponentHP: ${apiResult.opponentHP}, playerWonFromHP: $playerWonFromHP, playerWonFromWinner: $playerWonFromWinner, final playerWon: $playerWon")
// Store winner name for display (will be updated in cleanup call if available)
- winnerName = apiResult.winner ?: if (playerWon) "You" else "Opponent"
+ winnerName = if (apiResult.winner.isNotEmpty()) apiResult.winner else if (playerWon) "You" else "Opponent"
isWinnerLoaded = true
// Then send the cleanup call - this will have the actual winner name
RetrofitHelper().getPVPWinner(
context,
2,
- userId ?: 2L,
- activeCharacter?.name ?: "Player",
+ userId ?: run {
+ Toast.makeText(context, "Session missing user id. Please re-authenticate.", Toast.LENGTH_SHORT).show()
+ return@getPVPWinner
+ },
+ activeCharacter?.charaId ?: "dim011_mon01",
playerStage,
- opponentStage,
- selectedOpponent?.name ?: "Opponent",
+ 0,
+ selectedOpponent?.charaId ?: "dim011_mon01",
opponentStage
) { cleanupResult ->
// Update winner name from cleanup call if available
@@ -2465,7 +2567,7 @@ fun BattlesScreen() {
// Primary method: Check HP values (opponentHP <= 0 means opponent lost = player won)
// Secondary method: Check winner name (if winner doesn't match opponent, player won)
val opponentName = selectedOpponent?.name ?: ""
- val winner = cleanupResult.winner ?: ""
+ val winner = cleanupResult.winner
// Primary: HP-based determination (most reliable)
// If opponentHP <= 0, opponent is dead = player won
@@ -2602,8 +2704,13 @@ fun BattlesScreen() {
RetrofitHelper().getPVPWinner(
context,
0,
- userId ?: 2L,
- cardId,
+ userId ?: run {
+ Toast.makeText(context, "Session missing user id. Please re-authenticate.", Toast.LENGTH_SHORT).show()
+ showResumeDialog = false
+ existingMatchState = null
+ return@let
+ },
+ cardId,
apiStage,
0,
clickedOpponent.charaId,
@@ -2727,8 +2834,13 @@ fun BattlesScreen() {
RetrofitHelper().getPVPWinner(
context,
0,
- userId ?: 2L,
- cardId,
+ userId ?: run {
+ Toast.makeText(context, "Session missing user id. Please re-authenticate.", Toast.LENGTH_SHORT).show()
+ showResumeDialog = false
+ existingMatchState = null
+ return@let
+ },
+ cardId,
apiStage,
0,
opponent.charaId,
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/homeScreens/HomeScreen.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/homeScreens/HomeScreen.kt
index 6ece429..a970896 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/homeScreens/HomeScreen.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/homeScreens/HomeScreen.kt
@@ -33,8 +33,6 @@ import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData
import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData
import com.github.nacabaro.vbhelper.dtos.ItemDtos
import com.github.nacabaro.vbhelper.navigation.NavigationItems
-import com.github.nacabaro.vbhelper.screens.homeScreens.screens.BEBEmHomeScreen
-import com.github.nacabaro.vbhelper.screens.homeScreens.screens.BEDiMHomeScreen
import com.github.nacabaro.vbhelper.screens.homeScreens.screens.VBDiMHomeScreen
import com.github.nacabaro.vbhelper.screens.itemsScreen.ObtainedItemDialog
import com.github.nacabaro.vbhelper.source.StorageRepository
@@ -74,9 +72,8 @@ fun HomeScreen(
?: flowOf(emptyList())
).collectAsState(initial = emptyList())
- val vbSpecialMissions by (
+ val specialMissions by (
activeMon
- ?.takeIf { it.characterType == DeviceType.VBDevice }
?.let { chara ->
storageRepository.getSpecialMissions(chara.id)
}
@@ -144,29 +141,20 @@ fun HomeScreen(
height = cardIconData!!.cardIconHeight
)
- if (activeMon!!.isBemCard && beData != null) {
- BEBEmHomeScreen(
- activeMon = activeMon!!,
- beData = beData!!,
- transformationHistory = transformationHistory,
- contentPadding = PaddingValues(0.dp),
- cardIcon = cardIcon
- )
- } else if (!activeMon!!.isBemCard && activeMon!!.characterType == DeviceType.BEDevice && beData != null) {
- BEDiMHomeScreen(
- activeMon = activeMon!!,
- beData = beData!!,
- transformationHistory = transformationHistory,
- contentPadding = PaddingValues(0.dp),
- cardIcon = cardIcon
- )
- } else if (vbData != null) {
+ val activeCharacter = activeMon!!
+ val displaySpecialMissions = specialMissions
+
+ if (activeCharacter.characterType == DeviceType.BEDevice || vbData != null) {
VBDiMHomeScreen(
- activeMon = activeMon!!,
- vbData = vbData!!,
+ activeMon = activeCharacter,
+ vbData = vbData ?: VBCharacterData(
+ id = activeCharacter.id,
+ generation = 0,
+ totalTrophies = activeCharacter.trophies,
+ ),
transformationHistory = transformationHistory,
contentPadding = PaddingValues(0.dp),
- specialMissions = vbSpecialMissions,
+ specialMissions = displaySpecialMissions,
homeScreenController = homeScreenController,
onClickCollect = { item, currency ->
collectedItem = item
@@ -225,3 +213,5 @@ fun HomeScreen(
}
}
}
+
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/homeScreens/screens/UnifiedHomeScreen.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/homeScreens/screens/UnifiedHomeScreen.kt
new file mode 100644
index 0000000..e7db1bf
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/homeScreens/screens/UnifiedHomeScreen.kt
@@ -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,
+ transformationHistory: List,
+ contentPadding: PaddingValues,
+ homeScreenController: HomeScreenControllerImpl,
+ onClickCollect: (ItemDtos.PurchasedItem?, Int?) -> Unit
+) {
+ var selectedSpecialMissionId by remember { mutableStateOf(-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 -> ""
+ }
+}
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/itemsScreen/ItemElement.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/itemsScreen/ItemElement.kt
index e08effa..1aafa84 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/itemsScreen/ItemElement.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/itemsScreen/ItemElement.kt
@@ -8,12 +8,17 @@ import androidx.compose.foundation.layout.size
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.dp
import com.github.nacabaro.vbhelper.dtos.ItemDtos
+import com.github.nacabaro.vbhelper.domain.items.ItemType
import com.github.nacabaro.vbhelper.R
@Composable
@@ -28,6 +33,12 @@ fun ItemElement(
.aspectRatio(1f)
) {
Box(modifier = Modifier.fillMaxSize()) {
+ ItemCompatibilityBadge(
+ itemType = item.itemType,
+ modifier = Modifier
+ .align(Alignment.TopEnd)
+ .padding(8.dp)
+ )
Icon(
painter = painterResource(id = getIconResource(item.itemIcon)),
contentDescription = null,
@@ -48,3 +59,48 @@ fun ItemElement(
}
}
}
+
+@Composable
+private fun ItemCompatibilityBadge(
+ itemType: ItemType,
+ modifier: Modifier = Modifier,
+) {
+ val (label, containerColor, contentColor) = when (itemType) {
+ ItemType.VBITEM -> Triple(
+ "VB",
+ MaterialTheme.colorScheme.primaryContainer,
+ MaterialTheme.colorScheme.onPrimaryContainer,
+ )
+ ItemType.BEITEM -> Triple(
+ "BE",
+ MaterialTheme.colorScheme.tertiaryContainer,
+ MaterialTheme.colorScheme.onTertiaryContainer,
+ )
+ ItemType.UNIVERSAL -> Triple(
+ "VB+BE",
+ MaterialTheme.colorScheme.secondaryContainer,
+ MaterialTheme.colorScheme.onSecondaryContainer,
+ )
+ ItemType.SPECIALMISSION -> Triple(
+ "MISSION",
+ MaterialTheme.colorScheme.errorContainer,
+ MaterialTheme.colorScheme.onErrorContainer,
+ )
+ }
+
+ Surface(
+ modifier = modifier,
+ shape = RoundedCornerShape(12.dp),
+ color = containerColor,
+ tonalElevation = 2.dp,
+ shadowElevation = 1.dp,
+ ) {
+ Text(
+ text = label,
+ color = contentColor,
+ fontSize = 10.sp,
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
+ )
+ }
+}
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/itemsScreen/ItemsScreen.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/itemsScreen/ItemsScreen.kt
index 63ca4c5..c233134 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/itemsScreen/ItemsScreen.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/itemsScreen/ItemsScreen.kt
@@ -6,7 +6,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Tab
-import androidx.compose.material3.TabRow
+import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -34,7 +34,7 @@ fun ItemsScreen(
topBar = {
Column {
TopBanner(text = stringResource(R.string.items_title))
- TabRow(
+ PrimaryTabRow(
selectedTabIndex = selectedTabItem,
modifier = Modifier
) {
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/itemsScreen/ItemsScreenControllerImpl.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/itemsScreen/ItemsScreenControllerImpl.kt
index eb3f477..45847f3 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/itemsScreen/ItemsScreenControllerImpl.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/itemsScreen/ItemsScreenControllerImpl.kt
@@ -7,7 +7,6 @@ import com.github.nacabaro.vbhelper.domain.device_data.SpecialMissions
import com.github.nacabaro.vbhelper.database.AppDatabase
import com.github.nacabaro.vbhelper.di.VBHelper
import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData
-import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData
import com.github.nacabaro.vbhelper.dtos.ItemDtos
import com.github.nacabaro.vbhelper.utils.DeviceType
import kotlinx.coroutines.Dispatchers
@@ -51,19 +50,12 @@ class ItemsScreenControllerImpl (
val item = getItem(itemId)
val characterData = database.userCharacterDao().getCharacter(characterId)
var beCharacterData: BECharacterData? = null
- var vbCharacterData: VBCharacterData? = null
if (characterData.characterType == DeviceType.BEDevice) {
beCharacterData = database
.userCharacterDao()
.getBeData(characterId)
.firstOrNull()
-
- } else if (characterData.characterType == DeviceType.VBDevice) {
- vbCharacterData = database
- .userCharacterDao()
- .getVbData(characterId)
- .firstOrNull()
}
if (
@@ -122,8 +114,7 @@ class ItemsScreenControllerImpl (
.updateCharacter(characterData)
} else if (item.itemIcon in ItemTypes.Step8k.id .. ItemTypes.Win4.id &&
- characterData.characterType == DeviceType.VBDevice &&
- vbCharacterData != null
+ (characterData.characterType == DeviceType.VBDevice || characterData.characterType == DeviceType.BEDevice)
) {
applySpecialMission(item.itemIcon, item.itemLength, characterId)
}
@@ -180,12 +171,12 @@ class ItemsScreenControllerImpl (
.getSpecialMissions(characterId)
.first()
- var newSpecialMission = availableSpecialMissions[specialMissionSlot]
- newSpecialMission = SpecialMissions(
- id = newSpecialMission.id,
- characterId = newSpecialMission.characterId,
+ val existingMission = availableSpecialMissions.firstOrNull { it.watchId == specialMissionSlot }
+ val newSpecialMission = SpecialMissions(
+ id = existingMission?.id ?: 0,
+ characterId = characterId,
goal = specialMissionGoal,
- watchId = newSpecialMission.watchId,
+ watchId = specialMissionSlot,
progress = 0,
status = SpecialMission.Status.AVAILABLE,
timeElapsedInMinutes = 0,
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/ChooseConnectionScreen.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/ChooseConnectionScreen.kt
index d5fe878..03de44f 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/ChooseConnectionScreen.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/ChooseConnectionScreen.kt
@@ -43,7 +43,7 @@ fun ChooseConnectOption(
.padding(contentPadding)
) {
Text(
- text = "Bandai Toys = Vital Bracelet, Vital Hero, BE Bracelet",
+ text = "VitalWear/VBH",
fontSize = 14.sp,
modifier = Modifier.padding(bottom = 24.dp)
)
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/ScanScreen.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/ScanScreen.kt
index 18aab56..7ec6b52 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/ScanScreen.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/ScanScreen.kt
@@ -144,7 +144,12 @@ fun ScanScreenPreview() {
override fun flushCharacter(cardId: Long) {}
override fun onClickRead(secrets: Secrets, onComplete: ()->Unit, onMultipleCards: (List) -> Unit) {}
override fun onClickCheckCard(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: () -> Unit) {}
- override fun onClickWrite(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: (ScanScreenController.WriteResult) -> Unit) {}
+ override fun onClickWrite(
+ secrets: Secrets,
+ nfcCharacter: NfcCharacter,
+ characterId: Long?,
+ onComplete: (ScanScreenController.WriteResult) -> Unit
+ ) {}
override fun cancelRead() {}
override fun characterFromNfc(nfcCharacter: NfcCharacter, onMultipleCards: (List, NfcCharacter) -> Unit): String { return "" }
override suspend fun characterToNfc(characterId: Long): NfcCharacter? { return null }
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/ScanScreenController.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/ScanScreenController.kt
index e1d0286..23d97e1 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/ScanScreenController.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/ScanScreenController.kt
@@ -20,7 +20,12 @@ interface ScanScreenController {
fun onClickRead(secrets: Secrets, onComplete: ()->Unit, onMultipleCards: (List) -> 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()
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/ScanScreenControllerImpl.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/ScanScreenControllerImpl.kt
index ff3506f..5f1de65 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/ScanScreenControllerImpl.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/ScanScreenControllerImpl.kt
@@ -12,8 +12,10 @@ import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.lifecycle.lifecycleScope
import com.github.cfogrady.vbnfc.TagCommunicator
+import com.github.cfogrady.vitalwear.protos.Character
import com.github.cfogrady.vbnfc.be.BENfcCharacter
import com.github.cfogrady.vbnfc.data.NfcCharacter
+import com.github.cfogrady.vbnfc.data.DeviceType as NfcDeviceTypeId
import com.github.cfogrady.vbnfc.vb.VBNfcCharacter
import com.github.nacabaro.vbhelper.ActivityLifecycleListener
import com.github.nacabaro.vbhelper.domain.card.Card
@@ -22,13 +24,11 @@ import com.github.nacabaro.vbhelper.screens.scanScreen.converters.FromNfcConvert
import com.github.nacabaro.vbhelper.screens.scanScreen.converters.ToNfcConverter
import com.github.nacabaro.vbhelper.source.VitalWearCharacterExporter
import com.github.nacabaro.vbhelper.source.VitalWearCharacterImporter
-import com.github.nacabaro.vbhelper.transfer.ExportFormat
import com.github.nacabaro.vbhelper.source.getCryptographicTransformerMap
import com.github.nacabaro.vbhelper.source.isMissingSecrets
import com.github.nacabaro.vbhelper.source.proto.Secrets
-import com.github.nacabaro.vbhelper.transfer.TransferTarget
-import com.github.nacabaro.vbhelper.transfer.TransferTransport
import com.github.nacabaro.vbhelper.transfer.hce.VitalWearHceReaderClient
+import com.github.nacabaro.vbhelper.utils.DeviceType
import com.github.nacabaro.vbhelper.R
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
@@ -57,6 +57,7 @@ class ScanScreenControllerImpl(
private val _transferStatusFlow = MutableStateFlow(null)
private var lastScannedCharacter: NfcCharacter? = null
private var lastRequestedCharacterId: Long? = null
+ private var lastWriteCharacterId: Long? = null
private val nfcAdapter: NfcAdapter
private val isHandlingTag = AtomicBoolean(false)
private var lastTagId: ByteArray? = null
@@ -122,7 +123,6 @@ class ScanScreenControllerImpl(
val options = Bundle()
options.putInt(NfcAdapter.EXTRA_READER_PRESENCE_CHECK_DELAY, 250)
val flags = NfcAdapter.FLAG_READER_NFC_A or
- NfcAdapter.FLAG_READER_NFC_B or
NfcAdapter.FLAG_READER_NO_PLATFORM_SOUNDS or
NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK
runCatching {
@@ -162,177 +162,153 @@ class ScanScreenControllerImpl(
componentActivity.getString(R.string.scan_error_generic)
}
},
- isoDepHandler = { isoDep ->
- val application = componentActivity.applicationContext as VBHelper
- val importer = VitalWearCharacterImporter(application.container.db, application.container.transferSeenDao)
- try {
- var importResult: VitalWearCharacterImporter.ImportResult? = null
- val moved = VitalWearHceReaderClient(isoDep).moveCharacterFromWatch { character ->
- val result = importer.importCharacter(character)
- importResult = result
- result.success
- }
- onComplete.invoke()
- if (moved) {
- importResult?.message ?: componentActivity.getString(R.string.scan_sent_character_success)
- } else {
- importResult?.message
- ?: "VitalWear import was rejected. Source character remains on the watch."
- }
- } catch (readError: Exception) {
- Log.e("NFC_READ", "HCE read failed; watch may be armed as destination", readError)
- onComplete.invoke()
- "No source character detected on watch. If the watch is waiting to receive, use VBH to Watch."
- }
- }
+ isoDepHandler = { isoDep ->
+ val application = componentActivity.applicationContext as VBHelper
+ val importer = VitalWearCharacterImporter(application.container.db, application.container.transferSeenDao)
+ try {
+ // ISO-DEP/HCE route: VitalWear characters are always imported as BE device type.
+ var importResult: VitalWearCharacterImporter.ImportResult? = null
+ val moved = VitalWearHceReaderClient(isoDep).moveCharacterFromWatch { character ->
+ // Force imported device type to BE since source is VitalWear HCE (BE only).
+ val result = importer.importCharacter(character, forcedDeviceType = DeviceType.BEDevice)
+ importResult = result
+ result.success
+ }
+ onComplete.invoke()
+ if (moved) {
+ importResult?.message ?: componentActivity.getString(R.string.scan_sent_character_success)
+ } else {
+ importResult?.message
+ ?: "VitalWear import was rejected. Source character remains on the watch."
+ }
+ } catch (readError: Exception) {
+ Log.e("NFC_READ", "HCE read failed; watch may be armed as destination", readError)
+ onComplete.invoke()
+ "No source character detected on watch. If the watch is waiting to receive, use VBH to Watch."
+ }
+ }
)
}
// ---- Write (phone sends character TO watch or bracelet) ------------------------
- override fun onClickWrite(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: (ScanScreenController.WriteResult) -> Unit) {
+ override fun onClickWrite(
+ secrets: Secrets,
+ nfcCharacter: NfcCharacter,
+ characterId: Long?,
+ onComplete: (ScanScreenController.WriteResult) -> Unit
+ ) {
_detectedTransportFlow.value = DetectedTransport.UNKNOWN
setTransferStatus(R.string.scan_transfer_waiting_tap)
handleTag(
secrets,
- nfcAHandler = { tagCommunicator ->
- try {
- val initialSlotState = readNfcASlotState(tagCommunicator)
- if (initialSlotState.isFull()) {
- onComplete.invoke(ScanScreenController.WriteResult.BLOCKED_DEVICE_FULL)
- return@handleTag componentActivity.getString(R.string.scan_target_device_full)
- }
+ nfcAHandler = { tagCommunicator ->
+ try {
+ val targetHeader = runCatching { tagCommunicator.readTargetHeader() }.getOrNull()
+ val normalizedDeviceType = when (targetHeader?.deviceTypeId) {
+ 512u.toUShort() -> NfcDeviceTypeId.VitalSeriesDeviceType
+ 768u.toUShort() -> NfcDeviceTypeId.VitalCharactersDeviceType
+ 1024u.toUShort() -> NfcDeviceTypeId.VitalBraceletBEDeviceType
+ else -> targetHeader?.deviceTypeId
+ }
+ val forcedProfile = when (normalizedDeviceType) {
+ NfcDeviceTypeId.VitalBraceletBEDeviceType -> DeviceType.BEDevice
+ else -> DeviceType.VBDevice
+ }
+ Log.i(
+ "NFC_WRITE_A",
+ "Target NFC-A header rawDeviceType=${targetHeader?.deviceTypeId}, normalizedDeviceType=$normalizedDeviceType, dimId=${targetHeader?.getDimId()}, forcedProfile=$forcedProfile"
+ )
- val migrationCheck = verifyActiveToBackupMigration(tagCommunicator, initialSlotState)
- if (!migrationCheck.canProceed) {
- onComplete.invoke(ScanScreenController.WriteResult.COPIED)
- return@handleTag migrationCheck.message
- }
+ // NFC-A route: choose export profile by target bracelet type.
+ // - Vital Series / Vital Characters => VB profile
+ // - Vital Bracelet BE => BE profile
+ val nfcACharacter = if (characterId != null) {
+ runBlocking {
+ ToNfcConverter(componentActivity = componentActivity)
+ .characterToNfc(characterId, forcedProfile)
+ }
+ } else {
+ nfcCharacter
+ }
- when (nfcCharacter) {
- is VBNfcCharacter -> tagCommunicator.sendCharacter(nfcCharacter)
- is BENfcCharacter -> tagCommunicator.sendCharacter(nfcCharacter)
- }
- onComplete.invoke(ScanScreenController.WriteResult.MOVE_CONFIRMED)
- componentActivity.getString(R.string.scan_sent_character_success)
- } catch (writeError: Throwable) {
- Log.e("NFC_WRITE_A", "NFC-A write failed; trying alternate payload format", writeError)
- val characterId = lastRequestedCharacterId
- val alternateFormat = when (nfcCharacter) {
- is BENfcCharacter -> ExportFormat.VB
- is VBNfcCharacter -> ExportFormat.BE
- else -> null
- }
+ val initialSlotState = readNfcASlotState(tagCommunicator)
+ if (initialSlotState.isFull()) {
+ onComplete.invoke(ScanScreenController.WriteResult.BLOCKED_DEVICE_FULL)
+ return@handleTag componentActivity.getString(R.string.scan_target_device_full)
+ }
- val alternateSent = if (characterId != null && alternateFormat != null) {
- runCatching {
- val alternateCharacter = runBlocking {
- ToNfcConverter(componentActivity = componentActivity).characterToNfc(
- characterId = characterId,
- target = TransferTarget.REAL_BRACELET,
- transport = TransferTransport.NFCA,
- forcedFormat = alternateFormat,
- )
- }
- when (alternateCharacter) {
- is VBNfcCharacter -> tagCommunicator.sendCharacter(alternateCharacter)
- is BENfcCharacter -> tagCommunicator.sendCharacter(alternateCharacter)
- }
- true
- }.getOrElse { alternateError ->
- Log.e("NFC_WRITE_A", "Alternate payload write failed", alternateError)
- false
- }
- } else {
- false
- }
+ val migrationCheck = verifyActiveToBackupMigration(tagCommunicator, initialSlotState)
+ if (!migrationCheck.canProceed) {
+ onComplete.invoke(ScanScreenController.WriteResult.COPIED)
+ return@handleTag migrationCheck.message
+ }
- if (alternateSent) {
- onComplete.invoke(ScanScreenController.WriteResult.MOVE_CONFIRMED)
- "Sent character after converting payload for the tapped bracelet."
- } else {
- Log.e("NFC_WRITE_A", "NFC-A write failed; attempting opposite direction fallback", writeError)
- runCatching {
- val receivedCharacter = tagCommunicator.receiveCharacter()
- val fallbackMessage = importCharacterFromNfcAFallback(receivedCharacter)
- onComplete.invoke(ScanScreenController.WriteResult.COPIED)
- fallbackMessage
- }.getOrElse { readFallbackError ->
- Log.e("NFC_WRITE_A", "NFC-A opposite-direction fallback failed", readFallbackError)
- componentActivity.getString(R.string.scan_error_generic)
- }
- }
- }
- },
- isoDepHandler = { isoDep ->
- val characterId = lastRequestedCharacterId
- ?: throw IllegalStateException("No character id available for VitalWear HCE write")
- val application = componentActivity.applicationContext as VBHelper
- val hceClient = VitalWearHceReaderClient(isoDep)
- try {
- val proto = runBlocking {
- VitalWearCharacterExporter(application.container.db)
- .buildCharacterProto(characterId)
- }
- hceClient.sendCharacterToWatch(proto)
- onComplete.invoke(ScanScreenController.WriteResult.MOVE_CONFIRMED)
- componentActivity.getString(R.string.scan_sent_character_success)
- } catch (writeError: Throwable) {
- Log.e("NFC_WRITE_HCE", "HCE write failed; attempting opposite direction", writeError)
- runCatching {
- var importResult: VitalWearCharacterImporter.ImportResult? = null
- val moved = hceClient.moveCharacterFromWatch { character ->
- val result = VitalWearCharacterImporter(application.container.db, application.container.transferSeenDao).importCharacter(character)
- importResult = result
- result.success
- }
- onComplete.invoke(ScanScreenController.WriteResult.COPIED)
- if (moved) {
- importResult?.message
- ?: "Detected source on watch and imported it instead of sending."
- } else {
- importResult?.message
- ?: "Watch is in source mode, but import was rejected."
- }
- }.getOrElse { readFallbackError ->
- Log.e("NFC_WRITE_HCE", "HCE opposite-direction fallback failed", readFallbackError)
- componentActivity.getString(R.string.scan_error_generic)
- }
- }
- }
+ // Send with VB profile (which was forced above). Real bracelets only accept VBNfcCharacter.
+ return@handleTag try {
+ when (nfcACharacter) {
+ is VBNfcCharacter -> tagCommunicator.sendCharacter(nfcACharacter)
+ is BENfcCharacter -> {
+ // This should not happen since we forced VBDevice above, but fail if it does.
+ Log.e("NFC_WRITE_A", "BENfcCharacter sent to NFC-A (real bracelet) — protocol error")
+ throw IllegalStateException("NFC-A forced VBDevice but received BENfcCharacter")
+ }
+ }
+ onComplete.invoke(ScanScreenController.WriteResult.MOVE_CONFIRMED)
+ componentActivity.getString(R.string.scan_sent_character_success)
+ } catch (deviceTypeMismatch: Exception) {
+ // Device type mismatch on NFC-A is terminal for this tap.
+ if (deviceTypeMismatch.message?.contains("Character doesn't match device type") == true) {
+ Log.e("NFC_WRITE_A", "Device type mismatch on NFC-A with selected transfer profile", deviceTypeMismatch)
+ onComplete.invoke(ScanScreenController.WriteResult.COPIED)
+ "Transfer failed: bracelet rejected character profile for this tap. Retry and keep bracelet steady."
+ } else {
+ Log.e("NFC_WRITE_A", "NFC-A write failed", deviceTypeMismatch)
+ onComplete.invoke(ScanScreenController.WriteResult.COPIED)
+ throw deviceTypeMismatch
+ }
+ }
+ } catch (writeError: Throwable) {
+ Log.e("NFC_WRITE_A", "NFC-A setup failed", writeError)
+ onComplete.invoke(ScanScreenController.WriteResult.COPIED)
+ throw writeError
+ }
+ },
+ isoDepHandler = { isoDep ->
+ val characterId = lastRequestedCharacterId
+ ?: throw IllegalStateException("No character id available for VitalWear HCE write")
+ val application = componentActivity.applicationContext as VBHelper
+ val hceClient = VitalWearHceReaderClient(isoDep)
+ try {
+ // ISO-DEP/HCE route: VitalWear always uses BE profile (enforced).
+ // Create protobuf with forced BE device type for correct serialization.
+ val proto = runBlocking {
+ VitalWearCharacterExporter(application.container.db)
+ .buildCharacterProto(characterId, forcedTransferProfile = DeviceType.BEDevice)
+ }
+ if (proto.characterStats.deviceType != Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_BE) {
+ throw IllegalStateException("VitalWear HCE export must use BE transfer profile")
+ }
+ // Send with status confirmation: only mark MOVE_CONFIRMED after watch confirms import.
+ val confirmedMove = hceClient.sendCharacterToWatchAndConfirm(proto)
+ if (confirmedMove) {
+ onComplete.invoke(ScanScreenController.WriteResult.MOVE_CONFIRMED)
+ componentActivity.getString(R.string.scan_sent_character_success)
+ } else {
+ // Transfer acknowledging but watch import failed or did not confirm.
+ onComplete.invoke(ScanScreenController.WriteResult.COPIED)
+ "Transfer sent but watch did not confirm import. Source was kept in VBH."
+ }
+ } catch (writeError: Throwable) {
+ Log.e("NFC_WRITE_HCE", "HCE write failed", writeError)
+ onComplete.invoke(ScanScreenController.WriteResult.COPIED)
+ throw writeError
+ }
+ }
)
}
- private fun importCharacterFromNfcAFallback(receivedCharacter: NfcCharacter): String {
- val application = componentActivity.applicationContext as VBHelper
- val fromNfcConverter = FromNfcConverter(componentActivity)
- val candidateCards = application.container.db
- .cardDao()
- .getCardByCardId(receivedCharacter.dimId.toInt())
- if (candidateCards.isEmpty()) {
- return "Detected source character on bracelet, but matching card is not imported in VBHelper."
- }
-
- val preferredCardId = receivedCharacter.appReserved2
- .firstOrNull()
- ?.toLong()
- ?.takeIf { it > 0L }
- val preferredCard = preferredCardId?.let { id ->
- candidateCards.firstOrNull { it.id == id }
- }
-
- val selectedCard = when {
- preferredCard != null -> preferredCard
- candidateCards.size == 1 -> candidateCards.single()
- else -> {
- lastScannedCharacter = receivedCharacter
- return "Detected source character, but multiple matching cards exist. Use Read and select the card."
- }
- }
-
- return fromNfcConverter.addCharacterUsingCard(receivedCharacter, selectedCard.id)
- }
// ---- Check card (NFC-A only; ISO-DEP watches don't need a DIM prep) -----------
@@ -407,7 +383,6 @@ class ScanScreenControllerImpl(
// Work around for some broken Nfc firmware implementations that poll the card too fast
options.putInt(NfcAdapter.EXTRA_READER_PRESENCE_CHECK_DELAY, 250)
val flags = NfcAdapter.FLAG_READER_NFC_A or
- NfcAdapter.FLAG_READER_NFC_B or
NfcAdapter.FLAG_READER_NO_PLATFORM_SOUNDS or
NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK
val sessionId = readerSessionCounter.incrementAndGet()
@@ -450,18 +425,19 @@ class ScanScreenControllerImpl(
setTransferStatus(R.string.scan_transfer_detected_keep_tap)
// Detect transport once per tap and lock to that route for this transfer.
+ // Real Bandai bracelets route through NFC-A; VitalWear routes through ISO-DEP/HCE.
val isoDep = IsoDep.get(tag)
- val hasIsoDepRoute = isoDep != null && isoDepHandler != null
val hasNfcARoute = NfcA.get(tag) != null
+ val hasIsoDepHandler = isoDep != null && isoDepHandler != null
+ val confirmedVitalWear = if (hasIsoDepHandler) {
+ runCatching { isVitalWearHceTarget(isoDep) }.getOrDefault(false)
+ } else {
+ false
+ }
try {
- // Prefer ISO-DEP whenever present so HCE sessions do not accidentally fall through to NFC-A.
- if (hasIsoDepRoute) {
+ if (hasIsoDepHandler && confirmedVitalWear) {
val isoDepTarget = isoDep
val isoDepAction = isoDepHandler
- val confirmedVitalWear = runCatching { isVitalWearHceTarget(isoDepTarget) }.getOrDefault(false)
- if (!confirmedVitalWear) {
- Log.w("NFC_HCE", "IsoDep tag did not confirm VitalWear AID; attempting ISO-DEP handler without NFC-A fallback")
- }
try {
_detectedTransportFlow.value = DetectedTransport.ISO_DEP
isoDepTarget.connect()
@@ -480,7 +456,19 @@ class ScanScreenControllerImpl(
setTransferStatus(R.string.scan_transfer_failed_try_again)
}
}
+ } else if (hasIsoDepHandler && !confirmedVitalWear && !hasNfcARoute) {
+ // Hard-fail only when IsoDep is present without any NFC-A fallback.
+ // This keeps HCE safety while allowing real bracelets to route via NFC-A.
+ Log.w("NFC_ROUTE", "IsoDep present but VitalWear AID not confirmed and no NFC-A route; cancelling transfer")
+ _detectedTransportFlow.value = DetectedTransport.UNKNOWN
+ componentActivity.runOnUiThread {
+ Toast.makeText(componentActivity, "VitalWear HCE not detected. Transfer cancelled.", Toast.LENGTH_SHORT).show()
+ setTransferStatus(R.string.scan_transfer_failed_try_again)
+ }
} else if (hasNfcARoute) {
+ if (hasIsoDepHandler && !confirmedVitalWear) {
+ Log.i("NFC_ROUTE", "IsoDep present but VitalWear AID not confirmed; routing to NFC-A fallback")
+ }
if (!handleNfcATag(tag, secrets, nfcAHandler)) {
componentActivity.runOnUiThread {
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_tag_not_vb), Toast.LENGTH_SHORT).show()
@@ -488,6 +476,9 @@ class ScanScreenControllerImpl(
}
}
} else {
+ if (hasIsoDepHandler && !confirmedVitalWear) {
+ Log.w("NFC_ROUTE", "IsoDep tag does not expose VitalWear HCE AID and has no NFC-A fallback")
+ }
_detectedTransportFlow.value = DetectedTransport.UNKNOWN
componentActivity.runOnUiThread {
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_tag_not_vb), Toast.LENGTH_SHORT).show()
@@ -677,10 +668,10 @@ class ScanScreenControllerImpl(
val migrationVerified = afterState.backupPresent == true ||
(beforeState.count != null && afterState.count != null && afterState.count >= beforeState.count)
if (!migrationVerified) {
- return SlotMigrationCheck(
- canProceed = false,
- message = "Transfer blocked. Backup slot could not be verified after migration."
- )
+ // Some real bracelets do not expose stable occupancy signals through the current
+ // reflection-based probing. In that case, prefer compatibility over false blocks.
+ Log.w("NFC_A", "Could not verify active->backup migration from slot signals; proceeding with write")
+ return SlotMigrationCheck(canProceed = true, message = "")
}
return SlotMigrationCheck(canProceed = true, message = "")
@@ -746,11 +737,7 @@ class ScanScreenControllerImpl(
override suspend fun characterToNfc(characterId: Long): NfcCharacter {
lastRequestedCharacterId = characterId
- val character = ToNfcConverter(componentActivity = componentActivity).characterToNfc(
- characterId = characterId,
- target = TransferTarget.REAL_BRACELET,
- transport = TransferTransport.NFCA,
- )
+ val character = ToNfcConverter(componentActivity = componentActivity).characterToNfc(characterId)
Log.d("CharacterType", character.toString())
return character
}
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/TransferHaptics.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/TransferHaptics.kt
new file mode 100644
index 0000000..85ac89f
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/TransferHaptics.kt
@@ -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")
+ }
+ }
+}
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/converters/FromNfcConverter.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/converters/FromNfcConverter.kt
index 6f20581..6f85764 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/converters/FromNfcConverter.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/converters/FromNfcConverter.kt
@@ -11,8 +11,6 @@ import com.github.nacabaro.vbhelper.domain.device_data.SpecialMissions
import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter
import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData
import com.github.nacabaro.vbhelper.domain.device_data.VitalsHistory
-import com.github.nacabaro.vbhelper.transfer.CharacterTransferPolicyResolver
-import com.github.nacabaro.vbhelper.transfer.ExportFormat
import com.github.nacabaro.vbhelper.utils.DeviceType
import java.util.GregorianCalendar
@@ -22,7 +20,6 @@ class FromNfcConverter (
private val application = componentActivity.applicationContext as VBHelper
private val database = application.container.db
private val transferSeenDao = application.container.transferSeenDao
- private val transferPolicyResolver = CharacterTransferPolicyResolver(database.characterTransferPolicyDao())
fun addCharacterUsingCard(
@@ -130,6 +127,8 @@ class FromNfcConverter (
characterId = characterId,
nfcCharacter = nfcCharacter
)
+ // Keep a VB profile alongside BE stats for later VB-target exports.
+ addVbCharacterProfileFromBe(characterId, nfcCharacter)
} else if (nfcCharacter is VBNfcCharacter) {
addVbCharacterToDatabase(
characterId = characterId,
@@ -137,18 +136,6 @@ class FromNfcConverter (
)
}
- database
- .characterTransferPolicyDao()
- .upsert(
- transferPolicyResolver.policyForNfcaImport(
- characterId = characterId,
- observedFormat = when (nfcCharacter) {
- is BENfcCharacter -> ExportFormat.BE
- else -> ExportFormat.VB
- }
- )
- )
-
addTransformationHistoryToDatabase(
characterId = characterId,
nfcCharacter = nfcCharacter,
@@ -248,8 +235,8 @@ class FromNfcConverter (
itemType = nfcCharacter.itemType.toInt(),
itemMultiplier = nfcCharacter.itemMultiplier.toInt(),
itemRemainingTime = nfcCharacter.itemRemainingTime.toInt(),
- otp0 = "", //nfcCharacter.value!!.otp0.toString(),
- otp1 = "", //nfcCharacter.value!!.otp1.toString(),
+ otp0 = nfcCharacter.getOtp0().joinToString("") { "%02x".format(it) },
+ otp1 = nfcCharacter.getOtp1().joinToString("") { "%02x".format(it) },
minorVersion = nfcCharacter.characterCreationFirmwareVersion.minorVersion.toInt(),
majorVersion = nfcCharacter.characterCreationFirmwareVersion.majorVersion.toInt(),
)
@@ -259,6 +246,42 @@ class FromNfcConverter (
.insertBECharacterData(extraCharacterData)
}
+ private fun addVbCharacterProfileFromBe(
+ characterId: Long,
+ nfcCharacter: BENfcCharacter,
+ ) {
+ val historyCount = nfcCharacter.transformationHistory.count { it.toCharIndex.toInt() != 255 }
+ val vbData = VBCharacterData(
+ id = characterId,
+ generation = (historyCount - 1).coerceAtLeast(0),
+ totalTrophies = nfcCharacter.trophies.toInt(),
+ )
+ database.userCharacterDao().insertVBCharacterData(vbData)
+
+ // Populate empty mission slots for BE characters for UI consistency
+ addEmptyMissionSlotsForBe(characterId)
+ }
+
+ private fun addEmptyMissionSlotsForBe(characterId: Long) {
+ // Create 4 empty mission slots (one for each watch position 0-3)
+ val emptyMissions = (0..3).map { watchId ->
+ SpecialMissions(
+ characterId = characterId,
+ watchId = watchId,
+ missionType = com.github.cfogrady.vbnfc.data.MissionType.NONE,
+ status = com.github.cfogrady.vbnfc.data.MissionStatus.UNAVAILABLE,
+ goal = 0,
+ progress = 0,
+ timeElapsedInMinutes = 0,
+ timeLimitInMinutes = 0,
+ )
+ }
+
+ database
+ .userCharacterDao()
+ .insertSpecialMissions(*emptyMissions.toTypedArray())
+ }
+
private fun addVitalsHistoryToDatabase(
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/converters/ToNfcConverter.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/converters/ToNfcConverter.kt
index 86d3d52..bb105dd 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/converters/ToNfcConverter.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/converters/ToNfcConverter.kt
@@ -15,10 +15,6 @@ import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData
import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter
import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData
import com.github.nacabaro.vbhelper.dtos.CharacterDtos
-import com.github.nacabaro.vbhelper.transfer.CharacterTransferPolicyResolver
-import com.github.nacabaro.vbhelper.transfer.ExportFormat
-import com.github.nacabaro.vbhelper.transfer.TransferTarget
-import com.github.nacabaro.vbhelper.transfer.TransferTransport
import com.github.nacabaro.vbhelper.utils.DeviceType
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
@@ -30,19 +26,27 @@ class ToNfcConverter(
companion object {
private const val MIN_NFC_TRANSFORMATION_YEAR = 2021
private const val MAX_NFC_TRANSFORMATION_YEAR = 2035
+
+ internal fun shouldEncodeAsBem(
+ forcedProfile: DeviceType?,
+ storedDeviceType: DeviceType,
+ ): Boolean {
+ return when (forcedProfile) {
+ DeviceType.BEDevice -> true
+ DeviceType.VBDevice -> false
+ else -> storedDeviceType == DeviceType.BEDevice
+ }
+ }
}
private val application: VBHelper = componentActivity.applicationContext as VBHelper
private val database: AppDatabase = application.container.db
- private val transferPolicyResolver = CharacterTransferPolicyResolver(database.characterTransferPolicyDao())
suspend fun characterToNfc(
characterId: Long,
- target: TransferTarget = TransferTarget.REAL_BRACELET,
- transport: TransferTransport = TransferTransport.NFCA,
- forcedFormat: ExportFormat? = null,
+ forcedProfile: DeviceType? = null,
): NfcCharacter {
val app = componentActivity.applicationContext as VBHelper
val database = app.container.db
@@ -54,20 +58,14 @@ class ToNfcConverter(
val characterInfo = database
.characterDao()
.getCharacterInfo(userCharacter.charId)
- val card = database
- .cardDao()
- .getCardByCharacterIdSync(characterId)
- ?: error("Card not found for character $characterId")
- val exportFormat = forcedFormat ?: transferPolicyResolver.resolveExportFormat(
- characterId = characterId,
- characterType = userCharacter.characterType,
- transport = transport,
- target = target,
- isBemCard = card.isBEm,
- )
+ // Forced profile overrides stored device type for transfer semantics:
+ // - NFC-A route forces VBDevice (real bracelets only understand VB)
+ // - HCE route forces BEDevice (VitalWear only sends/accepts BE)
+ // Otherwise, use the character's own stored type.
+ val shouldEncodeAsBem = shouldEncodeAsBem(forcedProfile, userCharacter.characterType)
- return if (exportFormat == ExportFormat.BE)
+ return if (shouldEncodeAsBem)
nfcToBENfc(characterId, characterInfo, userCharacter)
else
nfcToVBNfc(characterId, characterInfo, userCharacter)
@@ -238,6 +236,18 @@ class ToNfcConverter(
val paddedTransformationArray = generateTransformationHistory(characterId)
+ // Convert hex-encoded OTP strings back to ByteArrays; use empty arrays if not stored
+ val otp0ByteArray = if (beData.otp0.isNotEmpty()) {
+ beData.otp0.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
+ } else {
+ ByteArray(8) // Default to 8-byte zero array if not available
+ }
+ val otp1ByteArray = if (beData.otp1.isNotEmpty()) {
+ beData.otp1.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
+ } else {
+ ByteArray(8) // Default to 8-byte zero array if not available
+ }
+
val nfcData = BENfcCharacter(
dimId = characterInfo.cardId.toUShort(),
charIndex = characterInfo.charId.toUShort(),
@@ -278,8 +288,8 @@ class ToNfcConverter(
itemType = beData.itemType.toByte(),
itemMultiplier = beData.itemMultiplier.toByte(),
itemRemainingTime = beData.itemRemainingTime.toByte(),
- otp0 = byteArrayOf(8),
- otp1 = byteArrayOf(8),
+ otp0 = otp0ByteArray,
+ otp1 = otp1ByteArray,
characterCreationFirmwareVersion = FirmwareVersion(
minorVersion = beData.minorVersion.toByte(),
majorVersion = beData.majorVersion.toByte()
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/screens/ActionScreen.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/screens/ActionScreen.kt
index 8d58f95..8f2a1f5 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/screens/ActionScreen.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/screens/ActionScreen.kt
@@ -1,54 +1,54 @@
package com.github.nacabaro.vbhelper.screens.scanScreen.screens
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.Button
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
-import com.github.nacabaro.vbhelper.components.TopBanner
-import com.github.nacabaro.vbhelper.R
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.platform.LocalContext
+import com.github.nacabaro.vbhelper.screens.scanScreen.TransferHaptics
+import com.github.nacabaro.vbhelper.utils.ImageBitmapData
@Composable
fun ActionScreen(
topBannerText: String,
+ detectedTransportMessage: String?,
transferStatusMessage: String?,
+ characterPreview: ImageBitmapData? = null,
onClickCancel: () -> Unit,
+ isTransferring: Boolean = true,
) {
- Scaffold (
- topBar = {
- TopBanner(
- text = topBannerText,
- onBackClick = onClickCancel
- )
- }
- ) { innerPadding ->
- Column (
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.Center,
- modifier = Modifier
- .padding(innerPadding)
- .fillMaxSize()
- ) {
- Text(stringResource(R.string.action_place_near_reader))
- if (!transferStatusMessage.isNullOrBlank()) {
- Text(
- text = transferStatusMessage,
- modifier = Modifier.padding(top = 8.dp, start = 16.dp, end = 16.dp)
- )
- }
- Button(
- onClick = onClickCancel,
- modifier = Modifier.padding(16.dp)
- ) {
- Text(stringResource(R.string.action_cancel))
- }
+ val context = LocalContext.current
+ val transferHaptics = remember(context) { TransferHaptics(context) }
+
+ LaunchedEffect(transferStatusMessage) {
+ if (transferStatusMessage != null && transferStatusMessage.contains("progress", ignoreCase = true)) {
+ transferHaptics.onTransferProgress()
}
}
+
+ if (transferStatusMessage?.contains("complete", ignoreCase = true) == true) {
+ TransferCompleteScreen(
+ topBannerText = topBannerText,
+ resultMessage = transferStatusMessage,
+ onClickOk = onClickCancel,
+ isSuccess = !transferStatusMessage.contains("fail", ignoreCase = true)
+ )
+ } else if (isTransferring) {
+ TransferAnimationScreen(
+ topBannerText = topBannerText,
+ detectedTransportMessage = detectedTransportMessage,
+ transferStatusMessage = transferStatusMessage,
+ characterPreview = characterPreview,
+ onClickCancel = onClickCancel,
+ isTransferring = true
+ )
+ } else {
+ TransferAnimationScreen(
+ topBannerText = topBannerText,
+ detectedTransportMessage = detectedTransportMessage,
+ transferStatusMessage = transferStatusMessage,
+ characterPreview = characterPreview,
+ onClickCancel = onClickCancel,
+ isTransferring = false
+ )
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/screens/ReadingScreen.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/screens/ReadingScreen.kt
index 3dd2d67..3b08d90 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/screens/ReadingScreen.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/screens/ReadingScreen.kt
@@ -3,17 +3,21 @@ package com.github.nacabaro.vbhelper.screens.scanScreen.screens
import android.util.Log
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import com.github.nacabaro.vbhelper.ActivityLifecycleListener
import com.github.nacabaro.vbhelper.domain.card.Card
import com.github.nacabaro.vbhelper.screens.cardScreen.ChooseCard
+import com.github.nacabaro.vbhelper.screens.scanScreen.DetectedTransport
import com.github.nacabaro.vbhelper.screens.scanScreen.SCAN_SCREEN_ACTIVITY_LIFECYCLE_LISTENER
import com.github.nacabaro.vbhelper.screens.scanScreen.ScanScreenController
+import com.github.nacabaro.vbhelper.screens.scanScreen.TransferHaptics
import com.github.nacabaro.vbhelper.R
private const val TAG = "ReadingScreen"
@@ -24,8 +28,17 @@ fun ReadingScreen(
onCancel: () -> Unit,
onComplete: () -> Unit
) {
+ val context = LocalContext.current
+ val transferHaptics = remember(context) { TransferHaptics(context) }
val secrets by scanScreenController.secretsFlow.collectAsState(null)
val transferStatus by scanScreenController.transferStatusFlow.collectAsState(null)
+ val detectedTransport by scanScreenController.detectedTransportFlow.collectAsState()
+
+ val transportMessage = when (detectedTransport) {
+ DetectedTransport.NFC_A -> stringResource(R.string.scan_transport_bandai_bracelet)
+ DetectedTransport.ISO_DEP -> stringResource(R.string.scan_transport_vitalwear_hce)
+ DetectedTransport.UNKNOWN -> null
+ }
var cardsRead by remember { mutableStateOf?>(null) }
@@ -33,6 +46,12 @@ fun ReadingScreen(
var isDoneReadingCharacter by remember { mutableStateOf(false) }
var cardSelectScreen by remember { mutableStateOf(false) }
+ LaunchedEffect(readingScreen) {
+ if (readingScreen) {
+ transferHaptics.onTransferStart()
+ }
+ }
+
fun startReadIfNeeded() {
val availableSecrets = secrets
if (availableSecrets == null) {
@@ -52,11 +71,13 @@ fun ReadingScreen(
secrets = availableSecrets,
onComplete = {
Log.d(TAG, "onClickRead.onComplete: marking read complete")
+ transferHaptics.onTransferComplete()
readingScreen = false
isDoneReadingCharacter = true
},
onMultipleCards = { cards ->
Log.d(TAG, "onClickRead.onMultipleCards: showing card select (count=${cards.size})")
+ transferHaptics.onTransferProgress()
cardsRead = cards
readingScreen = false
cardSelectScreen = true
@@ -119,13 +140,15 @@ fun ReadingScreen(
if (readingScreen) {
ActionScreen(
topBannerText = stringResource(R.string.reading_character_title),
+ detectedTransportMessage = transportMessage,
transferStatusMessage = transferStatus,
- ) {
+ onClickCancel = {
Log.d(TAG, "ActionScreen.onCancel: cancelRead + onCancel")
readingScreen = false
scanScreenController.cancelRead()
onCancel()
- }
+ }
+ )
} else if (cardSelectScreen) {
ChooseCard(
cards = cardsRead!!,
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/screens/TransferAnimationScreen.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/screens/TransferAnimationScreen.kt
new file mode 100644
index 0000000..137478b
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/screens/TransferAnimationScreen.kt
@@ -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")
+ }
+ }
+ }
+}
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/screens/WritingScreen.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/screens/WritingScreen.kt
index 53265bf..f4a8141 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/screens/WritingScreen.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/screens/WritingScreen.kt
@@ -7,15 +7,21 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.produceState
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import com.github.cfogrady.vbnfc.data.NfcCharacter
import com.github.nacabaro.vbhelper.ActivityLifecycleListener
import com.github.nacabaro.vbhelper.di.VBHelper
+import com.github.nacabaro.vbhelper.screens.scanScreen.DetectedTransport
import com.github.nacabaro.vbhelper.screens.scanScreen.SCAN_SCREEN_ACTIVITY_LIFECYCLE_LISTENER
import com.github.nacabaro.vbhelper.screens.scanScreen.ScanScreenController
import com.github.nacabaro.vbhelper.screens.scanScreen.ScanScreenController.WriteResult
+import com.github.nacabaro.vbhelper.screens.scanScreen.TransferHaptics
import com.github.nacabaro.vbhelper.source.StorageRepository
+import com.github.nacabaro.vbhelper.utils.BitmapData
+import com.github.nacabaro.vbhelper.utils.ImageBitmapData
+import com.github.nacabaro.vbhelper.utils.getImageBitmap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import androidx.compose.ui.res.stringResource
@@ -31,9 +37,39 @@ fun WritingScreen(
) {
val secrets by scanScreenController.secretsFlow.collectAsState(null)
val transferStatus by scanScreenController.transferStatusFlow.collectAsState(null)
+ val detectedTransport by scanScreenController.detectedTransportFlow.collectAsState()
+
+ val transportMessage = when (detectedTransport) {
+ DetectedTransport.NFC_A -> stringResource(R.string.scan_transport_bandai_bracelet)
+ DetectedTransport.ISO_DEP -> stringResource(R.string.scan_transport_vitalwear_hce)
+ DetectedTransport.UNKNOWN -> null
+ }
val application = LocalContext.current.applicationContext as VBHelper
val storageRepository = StorageRepository(application.container.db)
+ val context = LocalContext.current
+
+ val characterPreview by produceState(initialValue = null, key1 = characterId) {
+ value = withContext(Dispatchers.IO) {
+ runCatching {
+ storageRepository.getSingleCharacter(characterId)
+ }.getOrNull()?.let { character ->
+ if (character.spriteWidth > 0 && character.spriteHeight > 0 && character.spriteIdle.isNotEmpty()) {
+ BitmapData(
+ bitmap = character.spriteIdle,
+ width = character.spriteWidth,
+ height = character.spriteHeight
+ ).getImageBitmap(
+ context = context,
+ multiplier = 4,
+ obscure = false
+ )
+ } else {
+ null
+ }
+ }
+ }
+ }
var writing by remember { mutableStateOf(false) }
var writingScreen by remember { mutableStateOf(false) }
@@ -44,6 +80,9 @@ fun WritingScreen(
DisposableEffect(writing) {
if (writing) {
+ val transferHaptics = TransferHaptics(application)
+ transferHaptics.onTransferStart()
+
scanScreenController.registerActivityLifecycleListener(
SCAN_SCREEN_ACTIVITY_LIFECYCLE_LISTENER,
object : ActivityLifecycleListener {
@@ -55,11 +94,13 @@ fun WritingScreen(
if (!isDoneSendingCard) {
scanScreenController.onClickCheckCard(secrets!!, nfcCharacter) {
isDoneSendingCard = true
+ transferHaptics.onTransferProgress()
}
} else if (!isDoneWritingCharacter) {
- scanScreenController.onClickWrite(secrets!!, nfcCharacter) { result ->
+ scanScreenController.onClickWrite(secrets!!, nfcCharacter, characterId) { result ->
writeResult = result
isDoneWritingCharacter = true
+ transferHaptics.onTransferProgress()
}
}
}
@@ -70,11 +111,13 @@ fun WritingScreen(
if (!isDoneSendingCard) {
scanScreenController.onClickCheckCard(secrets!!, nfcCharacter) {
isDoneSendingCard = true
+ transferHaptics.onTransferProgress()
}
} else if (!isDoneWritingCharacter) {
- scanScreenController.onClickWrite(secrets!!, nfcCharacter) { result ->
+ scanScreenController.onClickWrite(secrets!!, nfcCharacter, characterId) { result ->
writeResult = result
isDoneWritingCharacter = true
+ transferHaptics.onTransferProgress()
}
}
}
@@ -106,11 +149,14 @@ fun WritingScreen(
writing = true
ActionScreen(
topBannerText = stringResource(R.string.sending_card_title),
+ detectedTransportMessage = transportMessage,
transferStatusMessage = transferStatus,
- ) {
+ characterPreview = characterPreview,
+ onClickCancel = {
scanScreenController.cancelRead()
onCancel()
- }
+ }
+ )
} else if (!writingConfirmScreen) {
writing = false
WriteCharacterScreen (
@@ -127,12 +173,15 @@ fun WritingScreen(
writing = true
ActionScreen(
topBannerText = stringResource(R.string.writing_character_action_title),
+ detectedTransportMessage = transportMessage,
transferStatusMessage = transferStatus,
- ) {
+ characterPreview = characterPreview,
+ onClickCancel = {
isDoneSendingCard = false
scanScreenController.cancelRead()
onCancel()
- }
+ }
+ )
}
var completedWriting by remember { mutableStateOf(false) }
@@ -140,6 +189,8 @@ fun WritingScreen(
LaunchedEffect(isDoneSendingCard, isDoneWritingCharacter) {
withContext(Dispatchers.IO) {
if (isDoneSendingCard && isDoneWritingCharacter) {
+ // Only delete source character if transfer was confirmed as move.
+ // COPIED means transfer was sent but not confirmed, so keep source.
if (writeResult == WriteResult.MOVE_CONFIRMED) {
storageRepository.deleteCharacter(characterId)
}
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/SettingsScreen.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/SettingsScreen.kt
index 6a71765..0b4bfc9 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/SettingsScreen.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/SettingsScreen.kt
@@ -5,6 +5,7 @@ import android.net.Uri
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -12,8 +13,12 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
@@ -67,6 +72,7 @@ fun SettingsScreen(
settingsScreenController.onClickImportCard()
}
+
SettingsSection(title = stringResource(R.string.settings_section_about))
SettingsEntry(
title = stringResource(R.string.settings_credits_title),
@@ -98,6 +104,38 @@ fun SettingsScreen(
) {
settingsScreenController.onClickImportDatabase()
}
+
+ SettingsSection(title = stringResource(R.string.settings_section_companion_tools))
+ SettingsEntry(
+ title = stringResource(R.string.settings_companion_import_card_image_title),
+ description = stringResource(R.string.settings_companion_import_card_image_desc)
+ ) {
+ settingsScreenController.onClickCompanionImportCardImage()
+ }
+ SettingsEntry(
+ title = stringResource(R.string.settings_companion_import_firmware_title),
+ description = stringResource(R.string.settings_companion_import_firmware_desc)
+ ) {
+ settingsScreenController.onClickCompanionImportFirmware()
+ }
+ SettingsEntry(
+ title = stringResource(R.string.settings_companion_send_watch_logs_title),
+ description = stringResource(R.string.settings_companion_send_watch_logs_desc)
+ ) {
+ settingsScreenController.onClickCompanionSendWatchLogs()
+ }
+ SettingsEntry(
+ title = stringResource(R.string.settings_companion_send_phone_logs_title),
+ description = stringResource(R.string.settings_companion_send_phone_logs_desc)
+ ) {
+ settingsScreenController.onClickCompanionSendPhoneLogs()
+ }
+ SettingsSwitchEntry(
+ title = stringResource(R.string.settings_enable_dim_to_bem_title),
+ description = stringResource(R.string.settings_enable_dim_to_bem_desc),
+ isEnabled = settingsScreenController,
+ onToggle = { settingsScreenController.onToggleDimToBemConversion(it) }
+ )
}
}
}
@@ -123,6 +161,40 @@ fun SettingsEntry(
}
}
+@Composable
+fun SettingsSwitchEntry(
+ title: String,
+ description: String,
+ isEnabled: SettingsScreenControllerImpl,
+ onToggle: (Boolean) -> Unit
+) {
+ val dimToBemEnabled by isEnabled.dimToBemConversionEnabled.collectAsState(false)
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .padding(end = 16.dp)
+ ) {
+ Text(text = title)
+ Text(
+ text = description,
+ fontSize = 12.sp,
+ color = MaterialTheme.colorScheme.outline
+ )
+ }
+ Switch(
+ checked = dimToBemEnabled,
+ onCheckedChange = onToggle
+ )
+ }
+}
+
@Composable
fun SettingsSection(
title: String
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/SettingsScreenController.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/SettingsScreenController.kt
index ea0fb19..45f8565 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/SettingsScreenController.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/SettingsScreenController.kt
@@ -5,4 +5,8 @@ interface SettingsScreenController {
fun onClickImportDatabase()
fun onClickImportApk()
fun onClickImportCard()
+ fun onClickCompanionImportCardImage()
+ fun onClickCompanionImportFirmware()
+ fun onClickCompanionSendWatchLogs()
+ fun onClickCompanionSendPhoneLogs()
}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/SettingsScreenControllerImpl.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/SettingsScreenControllerImpl.kt
index dd78c14..dc065af 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/SettingsScreenControllerImpl.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/SettingsScreenControllerImpl.kt
@@ -1,5 +1,6 @@
package com.github.nacabaro.vbhelper.screens.settingsScreen
+import android.content.Intent
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import android.net.Uri
@@ -7,8 +8,13 @@ import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
+import com.github.nacabaro.vbhelper.companion.card.CompanionImportCardActivity
+import com.github.nacabaro.vbhelper.companion.firmware.CompanionFirmwareImportActivity
+import com.github.nacabaro.vbhelper.companion.logs.CompanionWatchLogsActivity
+import com.github.nacabaro.vbhelper.companion.logging.TinyLogTree
import com.github.nacabaro.vbhelper.database.AppDatabase
import com.github.nacabaro.vbhelper.di.VBHelper
+import com.github.nacabaro.vbhelper.R
import com.github.nacabaro.vbhelper.screens.settingsScreen.controllers.CardImportController
import com.github.nacabaro.vbhelper.screens.settingsScreen.controllers.DatabaseManagementController
import com.github.nacabaro.vbhelper.source.ApkSecretsImporter
@@ -16,6 +22,7 @@ import com.github.nacabaro.vbhelper.source.SecretsImporter
import com.github.nacabaro.vbhelper.source.SecretsRepository
import com.github.nacabaro.vbhelper.source.proto.Secrets
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.first
class SettingsScreenControllerImpl(
@@ -29,11 +36,14 @@ class SettingsScreenControllerImpl(
private val application = context.applicationContext as VBHelper
private val secretsRepository: SecretsRepository = application.container.dataStoreSecretsRepository
private val database: AppDatabase = application.container.db
+ private val cardSettingsRepository = application.container.cardSettingsRepository
private val databaseManagementController = DatabaseManagementController(
componentActivity = context,
application = application
)
+ val dimToBemConversionEnabled = cardSettingsRepository.enableDimToBemConversion
+
init {
filePickerLauncher = context.registerForActivityResult(
ActivityResultContracts.CreateDocument("application/octet-stream")
@@ -101,13 +111,41 @@ class SettingsScreenControllerImpl(
filePickerCard.launch(arrayOf("*/*"))
}
+ override fun onClickCompanionImportCardImage() {
+ context.startActivity(Intent(context, CompanionImportCardActivity::class.java))
+ }
+
+ override fun onClickCompanionImportFirmware() {
+ context.startActivity(Intent(context, CompanionFirmwareImportActivity::class.java))
+ }
+
+ override fun onClickCompanionSendWatchLogs() {
+ context.startActivity(Intent(context, CompanionWatchLogsActivity::class.java))
+ }
+
+ override fun onClickCompanionSendPhoneLogs() {
+ val file = TinyLogTree.getMostRecentLogFile(context.applicationContext)
+ if (file == null) {
+ Toast.makeText(context, context.getString(R.string.companion_logs_no_files), Toast.LENGTH_SHORT).show()
+ return
+ }
+ application.companionLogService.sendLogFile(context.applicationContext, file, context)
+ }
+
+ fun onToggleDimToBemConversion(enabled: Boolean) {
+ context.lifecycleScope.launch(Dispatchers.IO) {
+ cardSettingsRepository.setEnableDimToBemConversion(enabled)
+ }
+ }
+
private fun importCard(uri: Uri) {
context.lifecycleScope.launch(Dispatchers.IO) {
val contentResolver = context.contentResolver
val inputStream = contentResolver.openInputStream(uri)
inputStream.use { fileReader ->
- val cardImportController = CardImportController(database)
+ val dimToBemEnabled = cardSettingsRepository.enableDimToBemConversion.first()
+ val cardImportController = CardImportController(database, dimToBemEnabled)
cardImportController.importCard(fileReader)
}
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/controllers/CardImportController.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/controllers/CardImportController.kt
index 1626405..5af2606 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/controllers/CardImportController.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/controllers/CardImportController.kt
@@ -13,7 +13,8 @@ import com.github.nacabaro.vbhelper.domain.characters.Sprite
import java.io.InputStream
class CardImportController(
- private val database: AppDatabase
+ private val database: AppDatabase,
+ private val enableDimToBemConversion: Boolean = false
) {
suspend fun importCard(
fileReader: InputStream?
@@ -117,6 +118,16 @@ class CardImportController(
.spriteDao()
.insertSprite(domainSprite)
+ // Apply DIM-to-BEM conversion if enabled and card is a DIM card (not BEM)
+ var baseHp = characters[index].hp
+ var baseBp = characters[index].dp
+ var baseAp = characters[index].ap
+
+ if (enableDimToBemConversion && card is DimCard) {
+ baseHp = convertDimStatToBem(baseHp, characters[index].stage, Stat.HP)
+ baseBp = convertDimStatToBem(baseBp, characters[index].stage, Stat.BP)
+ baseAp = convertDimStatToBem(baseAp, characters[index].stage, Stat.AP)
+ }
domainCharacters.add(
CardCharacter(
@@ -126,9 +137,9 @@ class CardImportController(
nameSprite = card.spriteData.sprites[spriteCounter].pixelData,
stage = characters[index].stage,
attribute = NfcCharacter.Attribute.entries[characters[index].attribute],
- baseHp = characters[index].hp,
- baseBp = characters[index].dp,
- baseAp = characters[index].ap,
+ baseHp = baseHp,
+ baseBp = baseBp,
+ baseAp = baseAp,
nameWidth = card.spriteData.sprites[spriteCounter].spriteDimensions.width,
nameHeight = card.spriteData.sprites[spriteCounter].spriteDimensions.height
)
@@ -150,6 +161,40 @@ class CardImportController(
.insertCharacter(*domainCharacters.toTypedArray())
}
+ private enum class Stat { HP, BP, AP }
+
+ private fun convertDimStatToBem(dimStat: Int, phase: Int, statType: Stat): Int {
+ // DIM-to-BEM conversion factors based on phase and stat type
+ // These are approximations based on the average conversion ratios
+ val conversionFactor = when (phase) {
+ 1 -> 0.0f
+ 2 -> 0.0f
+ 3 -> when (statType) {
+ Stat.HP -> 4657.5f
+ Stat.BP -> 4657.5f
+ Stat.AP -> 1022.5f
+ }
+ 4 -> when (statType) {
+ Stat.HP -> 5171.528f
+ Stat.BP -> 5028.528f
+ Stat.AP -> 1222.9167f
+ }
+ 5 -> when (statType) {
+ Stat.HP -> 5664.4f
+ Stat.BP -> 5036.4f
+ Stat.AP -> 1505.7333f
+ }
+ 6 -> when (statType) {
+ Stat.HP -> 5972.2974f
+ Stat.BP -> 5256.2974f
+ Stat.AP -> 1976.7568f
+ }
+ else -> 0.0f
+ }
+
+ return (dimStat * conversionFactor).toInt()
+ }
+
private suspend fun importAdventureMissions(
cardId: Long,
card: com.github.cfogrady.vb.dim.card.Card<*, *, *, *, *, *>
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/controllers/DatabaseManagementController.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/controllers/DatabaseManagementController.kt
index f3fb931..0f9e34e 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/controllers/DatabaseManagementController.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/controllers/DatabaseManagementController.kt
@@ -1,11 +1,14 @@
package com.github.nacabaro.vbhelper.screens.settingsScreen.controllers
+import androidx.room.Room
+import android.database.sqlite.SQLiteDatabase
import android.net.Uri
import android.provider.OpenableColumns
import android.util.Log
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.lifecycle.lifecycleScope
+import com.github.nacabaro.vbhelper.database.AppDatabase
import com.github.nacabaro.vbhelper.di.VBHelper
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -18,6 +21,19 @@ class DatabaseManagementController(
val application: VBHelper
) {
private val roomDbName = "internalDb"
+ private val requiredLegacyVersions = setOf(1, 2, 3, 4, 5)
+ private val requiredCoreTables = setOf(
+ "UserCharacter",
+ "Character",
+ "Card",
+ "CardCharacter",
+ "TransformationHistory",
+ )
+
+ private data class ValidationResult(
+ val isValid: Boolean,
+ val reason: String? = null,
+ )
fun exportDatabase( destinationUri: Uri) {
componentActivity.lifecycleScope.launch(Dispatchers.IO) {
@@ -59,6 +75,19 @@ class DatabaseManagementController(
return@launch
}
+ val validationResult = validateImportBackup(sourceUri)
+ if (!validationResult.isValid) {
+ componentActivity.runOnUiThread {
+ Toast.makeText(
+ componentActivity,
+ validationResult.reason
+ ?: "Import blocked: backup is incompatible with this VBH-VW version.",
+ Toast.LENGTH_LONG
+ ).show()
+ }
+ return@launch
+ }
+
application.container.db.close()
val dbPath = componentActivity.getDatabasePath(roomDbName)
@@ -92,6 +121,118 @@ class DatabaseManagementController(
}
}
+ private fun validateImportBackup(sourceUri: Uri): ValidationResult {
+ val tempDbName = "import_validation_temp.db"
+ val tempDbPath = componentActivity.getDatabasePath(tempDbName)
+ val tempShm = File(tempDbPath.parentFile, "$tempDbName-shm")
+ val tempWal = File(tempDbPath.parentFile, "$tempDbName-wal")
+
+ try {
+ if (tempDbPath.exists()) tempDbPath.delete()
+ if (tempShm.exists()) tempShm.delete()
+ if (tempWal.exists()) tempWal.delete()
+
+ componentActivity.contentResolver.openInputStream(sourceUri)?.use { inputStream ->
+ tempDbPath.outputStream().use { outputStream ->
+ copyFile(inputStream, outputStream)
+ }
+ } ?: return ValidationResult(false, "Import blocked: unable to read selected backup file.")
+
+ // Fast static checks before attempting full Room open/migration.
+ val legacyVersion = readPragmaUserVersion(tempDbPath)
+ if (legacyVersion !in requiredLegacyVersions) {
+ return ValidationResult(
+ false,
+ "Import blocked: unsupported DB version ($legacyVersion). Use a VBH-VW export from a compatible app version."
+ )
+ }
+
+ val tableNames = readTableNames(tempDbPath)
+ if (!tableNames.contains("room_master_table")) {
+ return ValidationResult(
+ false,
+ "Import blocked: backup is not a Room VBH-VW database (room metadata missing)."
+ )
+ }
+
+ val missingTables = requiredCoreTables.filterNot { tableNames.contains(it) }
+ if (missingTables.isNotEmpty()) {
+ return ValidationResult(
+ false,
+ "Import blocked: backup is missing required tables (${missingTables.joinToString(", ")})."
+ )
+ }
+
+ // Hard validation: open with the app's exact Room schema + migrations.
+ val probeDb = Room.databaseBuilder(
+ componentActivity,
+ AppDatabase::class.java,
+ tempDbName
+ )
+ .addMigrations(AppDatabase.MIGRATION_1_2)
+ .addMigrations(AppDatabase.MIGRATION_2_3)
+ .addMigrations(AppDatabase.MIGRATION_3_5)
+ .addMigrations(AppDatabase.MIGRATION_4_5)
+ .addMigrations(AppDatabase.MIGRATION_5_6)
+ .build()
+
+ try {
+ probeDb.openHelper.writableDatabase.query("SELECT 1").close()
+ } catch (e: Exception) {
+ return ValidationResult(
+ false,
+ "Import blocked: backup cannot be opened/migrated by this VBH-VW build."
+ )
+ } finally {
+ probeDb.close()
+ }
+
+ return ValidationResult(true)
+ } finally {
+ if (tempDbPath.exists()) tempDbPath.delete()
+ if (tempShm.exists()) tempShm.delete()
+ if (tempWal.exists()) tempWal.delete()
+ }
+ }
+
+ private fun readPragmaUserVersion(dbFile: File): Int {
+ val sqliteDb = SQLiteDatabase.openDatabase(
+ dbFile.absolutePath,
+ null,
+ SQLiteDatabase.OPEN_READONLY
+ )
+ try {
+ sqliteDb.rawQuery("PRAGMA user_version", null).use { cursor ->
+ if (cursor.moveToFirst()) {
+ return cursor.getInt(0)
+ }
+ }
+ return -1
+ } finally {
+ sqliteDb.close()
+ }
+ }
+
+ private fun readTableNames(dbFile: File): Set {
+ val sqliteDb = SQLiteDatabase.openDatabase(
+ dbFile.absolutePath,
+ null,
+ SQLiteDatabase.OPEN_READONLY
+ )
+ try {
+ val tableNames = mutableSetOf()
+ sqliteDb.rawQuery("SELECT name FROM sqlite_master WHERE type='table'", null)
+ .use { cursor ->
+ while (cursor.moveToNext()) {
+ tableNames.add(cursor.getString(0))
+ }
+ }
+ return tableNames
+ } finally {
+ sqliteDb.close()
+ }
+ }
+
private fun copyFile(inputStream: InputStream, outputStream: OutputStream) {
val buffer = ByteArray(1024)
var bytesRead: Int
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/source/AuthRepository.kt b/app/src/main/java/com/github/nacabaro/vbhelper/source/AuthRepository.kt
index 53bc737..c379a27 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/source/AuthRepository.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/source/AuthRepository.kt
@@ -44,12 +44,18 @@ class AuthRepository(
preferences[IS_AUTHENTICATED] = isAuthenticated
if (nacatechToken != null) {
preferences[AUTH_TOKEN] = nacatechToken
+ } else {
+ preferences.remove(AUTH_TOKEN)
}
if (sessionToken != null) {
preferences[SESSION_TOKEN] = sessionToken
+ } else {
+ preferences.remove(SESSION_TOKEN)
}
if (userId != null) {
preferences[USER_ID] = userId
+ } else {
+ preferences.remove(USER_ID)
}
}
}
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/source/CardSettingsRepository.kt b/app/src/main/java/com/github/nacabaro/vbhelper/source/CardSettingsRepository.kt
new file mode 100644
index 0000000..557cf67
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/source/CardSettingsRepository.kt
@@ -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
+) {
+ private companion object {
+ val ENABLE_DIM_TO_BEM_CONVERSION = booleanPreferencesKey("enable_dim_to_bem_conversion")
+ }
+
+ val enableDimToBemConversion: Flow = 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
+ }
+ }
+}
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/source/DeviceTypeConverter.kt b/app/src/main/java/com/github/nacabaro/vbhelper/source/DeviceTypeConverter.kt
new file mode 100644
index 0000000..6a97062
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/source/DeviceTypeConverter.kt
@@ -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)
+ }
+ }
+}
+
+
+
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterExporter.kt b/app/src/main/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterExporter.kt
index 2f710e6..45078e0 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterExporter.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterExporter.kt
@@ -3,11 +3,8 @@ package com.github.nacabaro.vbhelper.source
import com.github.cfogrady.vitalwear.protos.Character
import com.github.nacabaro.vbhelper.database.AppDatabase
import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData
-import com.github.nacabaro.vbhelper.transfer.CharacterTransferPolicyResolver
-import com.github.nacabaro.vbhelper.transfer.TransferTarget
-import com.github.nacabaro.vbhelper.transfer.TransferTransport
-import com.github.nacabaro.vbhelper.transfer.toTransferDeviceType
import com.github.nacabaro.vbhelper.utils.DeviceType
+import com.google.protobuf.ByteString
import kotlinx.coroutines.flow.firstOrNull
internal const val DEFAULT_VITALWEAR_TRAINING_TIME_SECONDS = 100L * 60L * 60L
@@ -34,15 +31,25 @@ internal fun resolveTrainingSeconds(
class VitalWearCharacterExporter(
private val database: AppDatabase
) {
- private val transferPolicyResolver = CharacterTransferPolicyResolver(database.characterTransferPolicyDao())
-
/**
* Builds a Character proto from the stored character data.
* Used by the HCE ISO-DEP transfer path.
+ *
+ * @param characterId ID of the character to export
+ * @param forcedTransferProfile If specified (e.g., DeviceType.BEDevice for HCE), overrides stored device type
+ * and ensures stats are serialized for the target ecosystem.
+ * Used to lock VitalWear (HCE) transfers to BE profile.
*/
- suspend fun buildCharacterProto(characterId: Long): Character {
+ suspend fun buildCharacterProto(
+ characterId: Long,
+ forcedTransferProfile: DeviceType? = null,
+ ): Character {
val characterWithSprites = database.userCharacterDao().getCharacterWithSprites(characterId)
val userCharacter = database.userCharacterDao().getCharacter(characterId)
+ val cardCharacter = database.characterDao().getCharacterById(userCharacter.charId)
+ ?: error("Card character not found for user character $characterId")
+ val sprite = database.spriteDao().getSpriteById(cardCharacter.spriteId)
+ ?: error("Sprite not found for user character $characterId")
val characterInfo = database.characterDao().getCharacterInfo(userCharacter.charId)
val vwSettings = database.vitalWearSettingsDao().getByCharacterId(characterId)
val card = database.cardDao().getCardByCharacterIdSync(characterId)
@@ -50,17 +57,25 @@ class VitalWearCharacterExporter(
val cardProgress = database.cardProgressDao().getCardProgressSync(card.id) ?: 0
val beData = database.userCharacterDao().getBeDataOrNull(characterId)
val vbData = database.userCharacterDao().getVbDataOrNull(characterId)
- val exportFormat = transferPolicyResolver.resolveExportFormat(
- characterId = characterId,
- characterType = userCharacter.characterType,
- transport = TransferTransport.HCE,
- target = TransferTarget.VITAL_WEAR,
- isBemCard = card.isBEm,
- )
val normalizedTransformationCountdownMinutes = normalizeTransformationCountdownMinutes(
transformationCountdownMinutes = userCharacter.transformationCountdown,
hasPossibleTransformations = hasPossibleTransformations(userCharacter.charId),
)
+
+ // Use forced profile if specified (e.g., BE for HCE route), otherwise use stored type.
+ // For HCE/VitalWear routes, forcedTransferProfile = BE ensures BE stats are serialized.
+ val transferDeviceType = forcedTransferProfile ?: userCharacter.characterType
+
+ val settingsBuilder = Character.Settings.newBuilder()
+ .setTrainingInBackground(vwSettings?.trainingInBackground ?: false)
+ .setAllowedBattles(resolveAllowedBattles(vwSettings?.allowedBattles))
+
+ // VitalWear chooses DIM-vs-BE runtime path from card type + assumedFranchise.
+ // For forced BE HCE exports of DIM cards, provide a stable assumed franchise hint.
+ if (forcedTransferProfile == DeviceType.BEDevice && !card.isBEm) {
+ settingsBuilder.setAssumedFranchise(0)
+ }
+
return Character.newBuilder()
.setCardId(card.cardId)
.setCardName(card.name)
@@ -68,7 +83,7 @@ class VitalWearCharacterExporter(
Character.CharacterStats.newBuilder()
.setSlotId(characterInfo.charId)
.setVitals(characterWithSprites.vitalPoints)
- .setTrainingTimeRemainingInSeconds(resolveTrainingSeconds(userCharacter.characterType, beData))
+ .setTrainingTimeRemainingInSeconds(resolveTrainingSeconds(transferDeviceType, beData))
.setTimeUntilNextTransformation(normalizedTransformationCountdownMinutes.toLong() * 60L)
.setTrainedBp(resolveTrainedBp(beData))
.setTrainedHp(resolveTrainedHp(beData))
@@ -81,7 +96,7 @@ class VitalWearCharacterExporter(
.setTotalWins(characterWithSprites.totalBattlesWon)
.setCurrentPhaseWins(characterWithSprites.currentPhaseBattlesWon)
.setMood(characterWithSprites.mood)
- .setDeviceType(exportFormat.toTransferDeviceType())
+ .setDeviceType(transferDeviceType.toTransferDeviceType())
.setAgeInDays(userCharacter.ageInDays.coerceAtLeast(0))
.setActivityLevel(userCharacter.activityLevel.coerceAtLeast(0))
.setHeartRateCurrent(userCharacter.heartRateCurrent.coerceAtLeast(0))
@@ -106,12 +121,7 @@ class VitalWearCharacterExporter(
.setFirmwareMajorVersion(beData?.majorVersion ?: 0)
.build()
)
- .setSettings(
- Character.Settings.newBuilder()
- .setTrainingInBackground(vwSettings?.trainingInBackground ?: false)
- .setAllowedBattles(resolveAllowedBattles(vwSettings?.allowedBattles))
- .build()
- )
+ .setSettings(settingsBuilder.build())
.putMaxAdventureCompletedByCard(card.name, (cardProgress - 1).coerceAtLeast(0))
.addAllTransformationHistory(
database.userCharacterDao().getTransformationHistoryForExport(characterId).map {
@@ -122,6 +132,43 @@ class VitalWearCharacterExporter(
.build()
}
)
+ .setTransferCard(
+ Character.TransferCard.newBuilder()
+ .setCardId(card.cardId)
+ .setCardName(card.name)
+ .setIsBem(card.isBEm)
+ .setStageCount(card.stageCount)
+ .setFranchise(0)
+ .build()
+ )
+ .setTransferSpecies(
+ Character.TransferSpecies.newBuilder()
+ .setSlotId(characterInfo.charId)
+ .setStage(cardCharacter.stage)
+ .setAttribute(cardCharacter.attribute.ordinal)
+ .setBaseHp(cardCharacter.baseHp)
+ .setBaseBp(cardCharacter.baseBp)
+ .setBaseAp(cardCharacter.baseAp)
+ .setNameSprite(ByteString.copyFrom(cardCharacter.nameSprite))
+ .setNameSpriteWidth(cardCharacter.nameWidth)
+ .setNameSpriteHeight(cardCharacter.nameHeight)
+ .setIdle1(ByteString.copyFrom(sprite.spriteIdle1))
+ .setIdle2(ByteString.copyFrom(sprite.spriteIdle2))
+ .setWalk1(ByteString.copyFrom(sprite.spriteWalk1))
+ .setWalk2(ByteString.copyFrom(sprite.spriteWalk2))
+ .setRun1(ByteString.copyFrom(sprite.spriteRun1))
+ .setRun2(ByteString.copyFrom(sprite.spriteRun2))
+ .setTrain1(ByteString.copyFrom(sprite.spriteTrain1))
+ .setTrain2(ByteString.copyFrom(sprite.spriteTrain2))
+ .setWin(ByteString.copyFrom(sprite.spriteHappy))
+ .setDown(ByteString.copyFrom(sprite.spriteSleep))
+ .setAttack(ByteString.copyFrom(sprite.spriteAttack))
+ .setDodge(ByteString.copyFrom(sprite.spriteDodge))
+ .setSplash(ByteString.copyFrom(sprite.spriteIdle1))
+ .setSpriteWidth(sprite.width)
+ .setSpriteHeight(sprite.height)
+ .build()
+ )
.build()
}
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterImporter.kt b/app/src/main/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterImporter.kt
index fdd5593..704b0f9 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterImporter.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterImporter.kt
@@ -5,12 +5,13 @@ import com.github.cfogrady.vitalwear.common.data.SharedTransferSeenDao
import com.github.cfogrady.vitalwear.protos.Character
import com.github.nacabaro.vbhelper.database.AppDatabase
import com.github.nacabaro.vbhelper.domain.card.Card
+import com.github.nacabaro.vbhelper.domain.card.CardCharacter
+import com.github.nacabaro.vbhelper.domain.card.CardProgress
import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData
import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter
import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData
import com.github.nacabaro.vbhelper.domain.device_data.VitalWearCharacterSettings
-import com.github.nacabaro.vbhelper.transfer.CharacterTransferPolicyResolver
-import com.github.nacabaro.vbhelper.transfer.ExportFormat
+import com.github.nacabaro.vbhelper.domain.characters.Sprite
import com.github.nacabaro.vbhelper.utils.DeviceType
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.runBlocking
@@ -20,8 +21,6 @@ class VitalWearCharacterImporter(
private val database: AppDatabase,
private val transferSeenDao: SharedTransferSeenDao,
) {
- private val transferPolicyResolver = CharacterTransferPolicyResolver(database.characterTransferPolicyDao())
-
data class ImportResult(
val success: Boolean,
val message: String
@@ -29,15 +28,16 @@ class VitalWearCharacterImporter(
fun importCharacter(character: Character): ImportResult {
val importedCard = resolveCard(character)
+ ?: createPlaceholderCard(character)
?: return ImportResult(
success = false,
message = "Matching card not found in VBHelper. Import that card first."
)
val slotId = character.characterStats.slotId
- val cardCharacter = runCatching {
- database.characterDao().getCharacterByMonIndex(slotId, importedCard.id)
- }.getOrNull() ?: return ImportResult(
+ val cardCharacter = database.characterDao().getCharacterByMonIndexOrNull(slotId, importedCard.id)
+ ?: createPlaceholderCharacter(importedCard, character)
+ ?: return ImportResult(
success = false,
message = "Character slot $slotId was not found on card ${importedCard.name}."
)
@@ -62,11 +62,6 @@ class VitalWearCharacterImporter(
fallbackIsBeCharacter = fallbackIsBeCharacter,
)
val isBeCharacter = deviceType == DeviceType.BEDevice
- val observedImportFormat = resolveObservedHceImportFormat(
- transferDeviceType = character.characterStats.deviceType,
- importedCard = importedCard,
- hasBePayloadStats = hasBePayloadStats,
- )
val userCharacterId = database.userCharacterDao().insertCharacterData(
UserCharacter(
@@ -100,6 +95,21 @@ class VitalWearCharacterImporter(
)
}
+ val vbProfile = VBCharacterData(
+ id = userCharacterId,
+ generation = if (character.characterStats.generation > 0) {
+ character.characterStats.generation
+ } else {
+ max(character.transformationHistoryCount - 1, 0)
+ },
+ totalTrophies = if (character.characterStats.totalTrophies > 0) {
+ character.characterStats.totalTrophies
+ } else {
+ character.characterStats.trainedPp.coerceAtLeast(0)
+ }
+ )
+ database.userCharacterDao().insertVBCharacterData(vbProfile)
+
if (isBeCharacter) {
val abilityRarity = resolveAbilityRarity(character.characterStats.abilityRarity)
database.userCharacterDao().insertBECharacterData(
@@ -129,31 +139,179 @@ class VitalWearCharacterImporter(
majorVersion = character.characterStats.firmwareMajorVersion
)
)
- } else {
- database.userCharacterDao().insertVBCharacterData(
- VBCharacterData(
- id = userCharacterId,
- generation = if (character.characterStats.generation > 0) {
- character.characterStats.generation
- } else {
- max(character.transformationHistoryCount - 1, 0)
- },
- totalTrophies = if (character.characterStats.totalTrophies > 0) {
- character.characterStats.totalTrophies
- } else {
- character.characterStats.trainedPp.coerceAtLeast(0)
- }
+ }
+
+ val now = System.currentTimeMillis()
+ markSeen(importedCard.name, slotId, importedCard.id, now)
+
+ var insertedTransformationCount = 0
+ for (transformation in character.transformationHistoryList) {
+ val transformationCard = resolveRelatedCard(
+ incomingCardName = transformation.cardName,
+ incomingCardId = character.cardId,
+ matchedRootCard = importedCard,
+ incomingRootCardName = character.cardName,
+ )
+ if (transformationCard != null) {
+ database.userCharacterDao().insertTransformation(
+ userCharacterId,
+ transformation.slotId,
+ transformationCard.id,
+ now
+ )
+ insertedTransformationCount++
+ markSeen(transformationCard.name, transformation.slotId, transformationCard.id, now)
+ }
+ }
+
+ if (insertedTransformationCount == 0) {
+ // Keep HomeScreen renderable for freshly imported characters with empty history.
+ database.userCharacterDao().insertTransformation(
+ userCharacterId,
+ slotId,
+ importedCard.id,
+ now
+ )
+ }
+
+ for ((cardName, maxAdventureCompleted) in character.maxAdventureCompletedByCardMap) {
+ val adventureCard = resolveRelatedCard(
+ incomingCardName = cardName,
+ incomingCardId = null,
+ matchedRootCard = importedCard,
+ incomingRootCardName = character.cardName,
+ ) ?: continue
+ val currentStage = (maxAdventureCompleted + 1).coerceAtLeast(0)
+ database.cardProgressDao().updateCardProgress(
+ currentStage = currentStage,
+ cardId = adventureCard.id,
+ unlocked = currentStage > adventureCard.stageCount
+ )
+ }
+
+ val nextAdventureMissionStage = character.characterStats.nextAdventureMissionStage.coerceAtLeast(0)
+ if (nextAdventureMissionStage > 0) {
+ database.cardProgressDao().updateCardProgress(
+ currentStage = nextAdventureMissionStage,
+ cardId = importedCard.id,
+ unlocked = nextAdventureMissionStage > importedCard.stageCount,
+ )
+ }
+
+ return ImportResult(
+ success = true,
+ message = "Imported ${importedCard.name} slot $slotId from VitalWear."
+ )
+ }
+
+ /**
+ * Import a VitalWear character with a forced device type.
+ * Used by HCE (VitalWear) read path to ensure imports are marked as BE.
+ *
+ * @param character Protobuf character from VitalWear HCE
+ * @param forcedDeviceType Always use this device type for the imported character (e.g., BEDevice for HCE)
+ */
+ fun importCharacter(character: Character, forcedDeviceType: DeviceType): ImportResult {
+ val importedCard = resolveCard(character)
+ ?: createPlaceholderCard(character)
+ ?: return ImportResult(
+ success = false,
+ message = "Matching card not found in VBHelper. Import that card first."
+ )
+
+ val slotId = character.characterStats.slotId
+ val cardCharacter = database.characterDao().getCharacterByMonIndexOrNull(slotId, importedCard.id)
+ ?: createPlaceholderCharacter(importedCard, character)
+ ?: return ImportResult(
+ success = false,
+ message = "Character slot $slotId was not found on card ${importedCard.name}."
+ )
+
+ database.userCharacterDao().clearActiveCharacter()
+
+ val totalBattles = character.characterStats.totalBattles
+ val totalWins = character.characterStats.totalWins.coerceAtMost(totalBattles)
+ val currentPhaseBattles = character.characterStats.currentPhaseBattles
+ val currentPhaseWins = character.characterStats.currentPhaseWins.coerceAtMost(currentPhaseBattles)
+ val normalizedTransformationCountdown = normalizeTransformationCountdownMinutes(
+ transformationCountdownMinutes = secondsToMinutes(character.characterStats.timeUntilNextTransformation),
+ hasPossibleTransformations = hasPossibleTransformations(cardCharacter.id),
+ )
+
+ val userCharacterId = database.userCharacterDao().insertCharacterData(
+ UserCharacter(
+ charId = cardCharacter.id,
+ ageInDays = character.characterStats.ageInDays.takeIf { it > 0 }
+ ?: max(character.transformationHistoryCount - 1, 0),
+ mood = character.characterStats.mood,
+ vitalPoints = character.characterStats.vitals,
+ transformationCountdown = normalizedTransformationCountdown,
+ injuryStatus = resolveInjuryStatus(character.characterStats.injured),
+ trophies = character.characterStats.trainedPp,
+ currentPhaseBattlesWon = currentPhaseWins,
+ currentPhaseBattlesLost = (currentPhaseBattles - currentPhaseWins).coerceAtLeast(0),
+ totalBattlesWon = totalWins,
+ totalBattlesLost = (totalBattles - totalWins).coerceAtLeast(0),
+ activityLevel = character.characterStats.activityLevel.coerceAtLeast(0),
+ heartRateCurrent = character.characterStats.heartRateCurrent.coerceAtLeast(0),
+ characterType = forcedDeviceType,
+ isActive = true
+ )
+ )
+
+ runBlocking {
+ database.vitalWearSettingsDao().upsert(
+ VitalWearCharacterSettings(
+ characterId = userCharacterId,
+ trainingInBackground = character.settings.trainingInBackground,
+ allowedBattles = character.settings.allowedBattles.number,
+ accumulatedDailyInjuries = character.characterStats.accumulatedDailyInjuries,
)
)
}
- runBlocking {
- database.characterTransferPolicyDao().upsert(
- transferPolicyResolver.policyForHceImport(
- characterId = userCharacterId,
- importedCardIsBem = importedCard.isBEm,
- resolvedDeviceType = deviceType,
- observedFormat = observedImportFormat,
+ val vbProfile = VBCharacterData(
+ id = userCharacterId,
+ generation = if (character.characterStats.generation > 0) {
+ character.characterStats.generation
+ } else {
+ max(character.transformationHistoryCount - 1, 0)
+ },
+ totalTrophies = if (character.characterStats.totalTrophies > 0) {
+ character.characterStats.totalTrophies
+ } else {
+ character.characterStats.trainedPp.coerceAtLeast(0)
+ }
+ )
+ database.userCharacterDao().insertVBCharacterData(vbProfile)
+
+ if (forcedDeviceType == DeviceType.BEDevice) {
+ val abilityRarity = resolveAbilityRarity(character.characterStats.abilityRarity)
+ database.userCharacterDao().insertBECharacterData(
+ BECharacterData(
+ id = userCharacterId,
+ trainingHp = character.characterStats.trainedHp,
+ trainingAp = character.characterStats.trainedAp,
+ trainingBp = character.characterStats.trainedBp,
+ remainingTrainingTimeInMinutes = secondsToMinutes(character.characterStats.trainingTimeRemainingInSeconds),
+ itemEffectMentalStateValue = character.characterStats.itemEffectMentalStateValue,
+ itemEffectMentalStateMinutesRemaining = character.characterStats.itemEffectMentalStateMinutesRemaining,
+ itemEffectActivityLevelValue = character.characterStats.itemEffectActivityLevelValue,
+ itemEffectActivityLevelMinutesRemaining = character.characterStats.itemEffectActivityLevelMinutesRemaining,
+ itemEffectVitalPointsChangeValue = character.characterStats.itemEffectVitalPointsChangeValue,
+ itemEffectVitalPointsChangeMinutesRemaining = character.characterStats.itemEffectVitalPointsChangeMinutesRemaining,
+ abilityRarity = abilityRarity,
+ abilityType = character.characterStats.abilityType,
+ abilityBranch = character.characterStats.abilityBranch,
+ abilityReset = character.characterStats.abilityReset,
+ rank = character.characterStats.rank,
+ itemType = character.characterStats.itemType,
+ itemMultiplier = character.characterStats.itemMultiplier,
+ itemRemainingTime = character.characterStats.itemRemainingTime,
+ otp0 = "",
+ otp1 = "",
+ minorVersion = character.characterStats.firmwareMinorVersion,
+ majorVersion = character.characterStats.firmwareMajorVersion
)
)
}
@@ -182,7 +340,6 @@ class VitalWearCharacterImporter(
}
if (insertedTransformationCount == 0) {
- // Keep HomeScreen renderable for freshly imported characters with empty history.
database.userCharacterDao().insertTransformation(
userCharacterId,
slotId,
@@ -252,6 +409,131 @@ class VitalWearCharacterImporter(
return resolveCard(incomingCardName, incomingCardId)
}
+ private fun createPlaceholderCard(character: Character): Card? {
+ if (!character.hasTransferCard() || !character.hasTransferSpecies()) {
+ return null
+ }
+
+ val transferCard = character.transferCard
+ selectImportedCard(
+ candidates = database.cardDao().getAllCards(),
+ incomingCardName = transferCard.cardName,
+ incomingCardId = transferCard.cardId,
+ )?.let { return it }
+
+ val transferSpecies = character.transferSpecies
+ val baseName = transferCard.cardName
+ .takeIf { it.isNotBlank() }
+ ?: character.cardName.takeIf { it.isNotBlank() }
+ ?: "Transferred Card ${transferCard.cardId}"
+ val placeholderName = buildPlaceholderCardName(baseName, transferCard.cardId)
+ val logoBytes = when {
+ !transferSpecies.nameSprite.isEmpty -> transferSpecies.nameSprite.toByteArray()
+ !transferSpecies.idle1.isEmpty -> transferSpecies.idle1.toByteArray()
+ else -> byteArrayOf()
+ }
+ val logoWidth = when {
+ transferSpecies.nameSpriteWidth > 0 -> transferSpecies.nameSpriteWidth
+ transferSpecies.spriteWidth > 0 -> transferSpecies.spriteWidth
+ else -> 0
+ }
+ val logoHeight = when {
+ transferSpecies.nameSpriteHeight > 0 -> transferSpecies.nameSpriteHeight
+ transferSpecies.spriteHeight > 0 -> transferSpecies.spriteHeight
+ else -> 0
+ }
+
+ val insertedId = runBlocking {
+ database.cardDao().insertNewCard(
+ Card(
+ cardId = transferCard.cardId,
+ logo = logoBytes,
+ logoWidth = logoWidth,
+ logoHeight = logoHeight,
+ name = placeholderName,
+ stageCount = transferCard.stageCount.coerceAtLeast(0),
+ isBEm = transferCard.isBem,
+ )
+ )
+ }
+ val resolvedCard = when {
+ insertedId > 0L -> database.cardDao().getCardById(insertedId)
+ else -> database.cardDao().getCardByName(placeholderName)
+ } ?: return null
+
+ if (database.cardProgressDao().getCardProgressSync(resolvedCard.id) == null) {
+ database.cardProgressDao().insertCardProgress(
+ CardProgress(
+ cardId = resolvedCard.id,
+ currentStage = 1,
+ unlocked = false,
+ )
+ )
+ }
+
+ return resolvedCard
+ }
+
+ private fun createPlaceholderCharacter(card: Card, character: Character): CardCharacter? {
+ if (!character.hasTransferSpecies()) {
+ return null
+ }
+
+ val transferSpecies = character.transferSpecies
+ database.characterDao().getCharacterByMonIndexOrNull(transferSpecies.slotId, card.id)?.let { return it }
+
+ val spriteId = database.spriteDao().insertSprite(
+ Sprite(
+ spriteIdle1 = transferSpecies.idle1.toByteArray(),
+ spriteIdle2 = transferSpecies.idle2.toByteArray(),
+ spriteWalk1 = transferSpecies.walk1.toByteArray(),
+ spriteWalk2 = transferSpecies.walk2.toByteArray(),
+ spriteRun1 = transferSpecies.run1.toByteArray(),
+ spriteRun2 = transferSpecies.run2.toByteArray(),
+ spriteTrain1 = transferSpecies.train1.toByteArray(),
+ spriteTrain2 = transferSpecies.train2.toByteArray(),
+ spriteHappy = transferSpecies.win.toByteArray(),
+ spriteSleep = transferSpecies.down.toByteArray(),
+ spriteAttack = transferSpecies.attack.toByteArray(),
+ spriteDodge = transferSpecies.dodge.toByteArray(),
+ width = transferSpecies.spriteWidth,
+ height = transferSpecies.spriteHeight,
+ )
+ )
+
+ val attribute = enumValues().getOrElse(transferSpecies.attribute) {
+ enumValues().first()
+ }
+
+ runBlocking {
+ database.characterDao().insertCharacter(
+ CardCharacter(
+ cardId = card.id,
+ spriteId = spriteId,
+ charaIndex = transferSpecies.slotId,
+ stage = transferSpecies.stage,
+ attribute = attribute,
+ baseHp = transferSpecies.baseHp,
+ baseBp = transferSpecies.baseBp,
+ baseAp = transferSpecies.baseAp,
+ nameSprite = transferSpecies.nameSprite.toByteArray(),
+ nameWidth = transferSpecies.nameSpriteWidth,
+ nameHeight = transferSpecies.nameSpriteHeight,
+ )
+ )
+ }
+
+ return database.characterDao().getCharacterByMonIndexOrNull(transferSpecies.slotId, card.id)
+ }
+
+ private fun buildPlaceholderCardName(baseName: String, cardId: Int): String {
+ val trimmedBaseName = baseName.trim().ifBlank { "Transferred Card" }
+ database.cardDao().getCardByName(trimmedBaseName)?.let {
+ return if (cardId > 0) "$trimmedBaseName [Transfer $cardId]" else "$trimmedBaseName [Transfer]"
+ }
+ return trimmedBaseName
+ }
+
private fun secondsToMinutes(seconds: Long): Int {
if (seconds <= 0L) {
return 0
@@ -290,23 +572,6 @@ class VitalWearCharacterImporter(
return rarities.getOrElse(rawValue) { resolveDefaultAbilityRarity() }
}
- private fun resolveObservedHceImportFormat(
- transferDeviceType: Character.CharacterStats.TransferDeviceType,
- importedCard: Card,
- hasBePayloadStats: Boolean,
- ): ExportFormat {
- return when (transferDeviceType) {
- Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_BE -> ExportFormat.BE
- Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_VB -> ExportFormat.VB
- Character.CharacterStats.TransferDeviceType.UNRECOGNIZED,
- Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_UNSPECIFIED -> if (importedCard.isBEm || hasBePayloadStats) {
- ExportFormat.BE
- } else {
- ExportFormat.VB
- }
- }
- }
-
private fun markSeen(cardName: String, slotId: Int, cardId: Long, timestamp: Long) {
database.dexDao().insertCharacter(slotId, cardId, timestamp)
transferSeenDao.markSeen(cardName, slotId, timestamp)
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/transfer/hce/VitalWearHceReaderClient.kt b/app/src/main/java/com/github/nacabaro/vbhelper/transfer/hce/VitalWearHceReaderClient.kt
index 65f5f00..de2e047 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/transfer/hce/VitalWearHceReaderClient.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/transfer/hce/VitalWearHceReaderClient.kt
@@ -2,6 +2,8 @@
import android.nfc.tech.IsoDep
import android.util.Log
import com.github.cfogrady.vitalwear.protos.Character
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.runBlocking
/**
* Phone-side ISO-DEP client that drives the VitalWear HCE session.
* Mirrors the APDU protocol defined in VitalWearHceProtocol on the watch.
@@ -20,10 +22,17 @@ class VitalWearHceReaderClient(private val isoDep: IsoDep) {
private val INS_READ_CHUNK: Byte = 0x20
private val INS_WRITE_CHUNK: Byte = 0x30
private val INS_COMMIT: Byte = 0x40
+ private val INS_STATUS: Byte = 0x50
+ private val INS_SYNC_UI: Byte = 0x60
+ private val INS_VIBRATE: Byte = 0x70
private val MODE_WATCH_TO_PHONE: Byte = 0x01
private val MODE_PHONE_TO_WATCH: Byte = 0x02
private val VERSION: Byte = 0x01
private val SW_OK = 0x9000
+ private val STATUS_SUCCESS: Byte = 0x04
+ private val STATUS_FAILURE: Byte = 0x05
+ private val STATUS_SYNCING: Byte = 0x03
+
/** MOVE READ: read character bytes first, let caller import/validate, then COMMIT only on success.
* If [onCharacterRead] returns false, COMMIT is skipped so source can remain on the watch.
*/
@@ -67,8 +76,16 @@ class VitalWearHceReaderClient(private val isoDep: IsoDep) {
}
/** WRITE: watch has called armReceive() — phone pushes a character to the watch. */
- fun sendCharacterToWatch(character: Character) {
+ fun sendCharacterToWatch(character: Character, closeSyncUiAfterCommit: Boolean = true) {
selectAid()
+
+ // --- True to Toy Ceremony ---
+ // 1. Haptic Lock-on
+ sendApdu(INS_VIBRATE, byteArrayOf())
+
+ // 2. Visual Sync (Start Beam)
+ sendApdu(INS_SYNC_UI, byteArrayOf(0x01))
+
val payload = character.toByteArray()
val negResponse = sendApdu(INS_NEGOTIATE, byteArrayOf(MODE_PHONE_TO_WATCH, VERSION))
requireOk(negResponse, "NEGOTIATE")
@@ -82,6 +99,40 @@ class VitalWearHceReaderClient(private val isoDep: IsoDep) {
offset = chunkEnd
}
requireOk(sendApdu(INS_COMMIT, byteArrayOf()), "COMMIT")
+
+ // 3. Visual Sync (End Beam)
+ if (closeSyncUiAfterCommit) {
+ sendApdu(INS_SYNC_UI, byteArrayOf(0x00))
+ }
+ }
+
+ /**
+ * WRITE + confirmation polling.
+ * Returns true only when the watch reports import success.
+ */
+ fun sendCharacterToWatchAndConfirm(character: Character): Boolean {
+ sendCharacterToWatch(character, closeSyncUiAfterCommit = false)
+ val confirmed = runBlocking {
+ repeat(12) {
+ val statusResponse = sendApdu(INS_STATUS, byteArrayOf())
+ requireOk(statusResponse, "STATUS")
+ val statusByte = statusResponse.dropLast(2).firstOrNull()
+ Log.d("HCE_CLIENT", "STATUS poll[$it]: statusByte=${statusByte?.toInt()?.and(0xFF)}")
+ if (statusByte == STATUS_SUCCESS) {
+ return@runBlocking true
+ }
+ if (statusByte == STATUS_FAILURE) {
+ return@runBlocking false
+ }
+ delay(250)
+ }
+ false
+ }
+ // End the sync UI after we have consumed terminal status.
+ runCatching {
+ sendApdu(INS_SYNC_UI, byteArrayOf(0x00))
+ }
+ return confirmed
}
/**
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/utils/BitmapData.kt b/app/src/main/java/com/github/nacabaro/vbhelper/utils/BitmapData.kt
index eae66e2..a169743 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/utils/BitmapData.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/utils/BitmapData.kt
@@ -42,7 +42,7 @@ fun BitmapData.getImageBitmap(
}
fun BitmapData.getBitmap(): Bitmap {
- return Bitmap.createBitmap(createARGBIntArray(), this.width, this.height, Bitmap.Config.HARDWARE)
+ return Bitmap.createBitmap(createARGBIntArray(), this.width, this.height, Bitmap.Config.ARGB_8888)
}
object ARGBMasks {
@@ -64,7 +64,7 @@ fun BitmapData.getObscuredBitmap(): Bitmap {
argbPixels[i] = BLACK
}
}
- return Bitmap.createBitmap(argbPixels, this.width, this.height, Bitmap.Config.HARDWARE)
+ return Bitmap.createBitmap(argbPixels, this.width, this.height, Bitmap.Config.ARGB_8888)
}
diff --git a/app/src/main/proto/com/github/cfogrady/vitalwear/protos/Character.proto b/app/src/main/proto/com/github/cfogrady/vitalwear/protos/Character.proto
index 3c7a06c..973ab02 100644
--- a/app/src/main/proto/com/github/cfogrady/vitalwear/protos/Character.proto
+++ b/app/src/main/proto/com/github/cfogrady/vitalwear/protos/Character.proto
@@ -7,6 +7,41 @@ message Character {
string card_name = 1;
int32 card_id = 2;
+ message TransferCard {
+ int32 card_id = 1;
+ string card_name = 2;
+ bool is_bem = 3;
+ int32 stage_count = 4;
+ int32 franchise = 5;
+ }
+
+ message TransferSpecies {
+ int32 slot_id = 1;
+ int32 stage = 2;
+ int32 attribute = 3;
+ int32 base_hp = 4;
+ int32 base_bp = 5;
+ int32 base_ap = 6;
+ bytes name_sprite = 7;
+ int32 name_sprite_width = 8;
+ int32 name_sprite_height = 9;
+ bytes idle1 = 10;
+ bytes idle2 = 11;
+ bytes walk1 = 12;
+ bytes walk2 = 13;
+ bytes run1 = 14;
+ bytes run2 = 15;
+ bytes train1 = 16;
+ bytes train2 = 17;
+ bytes win = 18;
+ bytes down = 19;
+ bytes attack = 20;
+ bytes dodge = 21;
+ bytes splash = 22;
+ int32 sprite_width = 23;
+ int32 sprite_height = 24;
+ }
+
message CharacterStats {
enum TransferDeviceType {
TRANSFER_DEVICE_TYPE_UNSPECIFIED = 0;
@@ -77,4 +112,8 @@ message Character {
repeated TransformationEvent transformation_history = 5;
map max_adventure_completed_by_card = 6;
+
+ optional TransferCard transfer_card = 7;
+
+ optional TransferSpecies transfer_species = 8;
}
diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml
index 17a5f50..8283b4d 100644
--- a/app/src/main/res/values-pt-rBR/strings.xml
+++ b/app/src/main/res/values-pt-rBR/strings.xml
@@ -178,7 +178,7 @@
Nome do personagem
HP: %1$d, BP: %2$d, AP: %3$d
Stg: %1$s, Atr: %2$s
- \?\?\?\?\?\?\?\?\?\?\?\?\?\?\?\?
+ ________________
Stg: -, Atr: -
HP: -, BP: -, AP: -
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 3235e11..90d71c0 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -68,14 +68,17 @@
Transfer complete. You can remove the phone now.
Transfer failed. Reposition devices and try again.
Transfer cancelled.
+ Detected target: Bandai Vital Bracelet (NFC-A)
+ Detected target: VitalWear (HCE)
Settings
NFC Communication
- DiM/BEm management
+ VBH-VW card management
About and credits
Data management
+ VitalWear companion tools
Import APK
@@ -84,7 +87,12 @@
Import card
- Import DiM/BEm
+ Import DIM/BEM cards into VBHelper\'s Dex.
+
+
+ Scale DIM cards to BEM stats
+
+ Automatically upscale DIM card stats to BEM level when importing
Credits
@@ -103,6 +111,27 @@
Import application database
+ Import card to VitalWear
+ Import Dim/Bem to Vitalwear
+ Import firmware to VitalWear
+ Import firmware to Watch (10b for now)
+ Send watch logs
+ Request logs from a connected VitalWear watch and share them
+ Send phone logs
+ Share the latest VBHelper phone log file
+ WARNING: Logs have personal health information such as heart rate measurements and steps per day.
+ No log files found
+ Failed to send log request to watch
+ Error receiving log file
+ No connected watch found
+ Firmware sent successfully
+ Imported Successfully
+ Connect to VB
+ Insert Card in VB and Reconnect
+ Card Validated Successfully
+ Select firmware file
+ Character transfer is disabled in companion. Use watch Transfer instead.
+
Credits
Reverse engineering
Application development
@@ -187,7 +216,7 @@
Character name
HP: %1$d, BP: %2$d, AP: %3$d
Stg: %1$s, Atr: %2$s
- \?\?\?\?\?\?\?\?\?\?\?\?\?\?\?\?
+ ________________
Stg: -, Atr: -
HP: -, BP: -, AP: -
diff --git a/app/src/main/res/xml/provider_paths.xml b/app/src/main/res/xml/provider_paths.xml
index 8a3c842..b4b063a 100644
--- a/app/src/main/res/xml/provider_paths.xml
+++ b/app/src/main/res/xml/provider_paths.xml
@@ -1,5 +1,8 @@
+
diff --git a/app/src/test/java/com/github/nacabaro/vbhelper/screens/scanScreen/converters/ToNfcConverterProfileSelectionTest.kt b/app/src/test/java/com/github/nacabaro/vbhelper/screens/scanScreen/converters/ToNfcConverterProfileSelectionTest.kt
new file mode 100644
index 0000000..ff4fef7
--- /dev/null
+++ b/app/src/test/java/com/github/nacabaro/vbhelper/screens/scanScreen/converters/ToNfcConverterProfileSelectionTest.kt
@@ -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))
+ }
+}
+
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 97d43aa..5f078f3 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -17,6 +17,12 @@ protobufJavalite = "4.33.4"
roomRuntime = "2.8.4"
vbNfcReader = "0.2.0-SNAPSHOT"
dimReader = "2.0.0"
+playServicesWearable = "19.0.0"
+kotlinxCoroutinesPlayServices = "1.4.3-native-mt"
+timber = "5.0.1"
+tinyLog = "2.4.1"
+slf4jTimber = "1.0.0"
+slf4jApi = "2.0.16"
[libraries]
androidx-compose-material = { module = "androidx.compose.material:material" }
@@ -46,6 +52,13 @@ protobuf-gradle-plugin = { module = "com.google.protobuf:protobuf-gradle-plugin"
protobuf-javalite = { module = "com.google.protobuf:protobuf-javalite", version.ref = "protobufJavalite" }
vb-nfc-reader = { module = "com.github.cfogrady:vb-nfc-reader", version.ref = "vbNfcReader" }
dim-reader = { module = "com.github.cfogrady:vb-dim-reader", version.ref = "dimReader" }
+play-services-wearable = { module = "com.google.android.gms:play-services-wearable", version.ref = "playServicesWearable" }
+kotlinx-coroutines-play-services = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-play-services", version.ref = "kotlinxCoroutinesPlayServices" }
+timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
+tiny-log = { module = "org.tinylog:tinylog-api", version.ref = "tinyLog" }
+tiny-log-impl = { module = "org.tinylog:tinylog-impl", version.ref = "tinyLog" }
+slf4j-timber = { module = "at.favre.lib:slf4j-timber", version.ref = "slf4jTimber" }
+slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4jApi" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 50f6d5b..5e8316b 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -17,17 +17,13 @@ dependencyResolutionManagement {
mavenLocal()
google()
mavenCentral()
+ maven { url = uri("https://jitpack.io") }
}
}
rootProject.name = "VBHelper"
include(":app")
-includeBuild("../VB-DIM-Reader-2.0.0") {
- dependencySubstitution {
- substitute(module("com.github.cfogrady:vb-dim-reader")).using(project(":"))
- }
-}
includeBuild("../vb-nfc-reader") {
dependencySubstitution {