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 {