From 6c257b2080227d960dceb729681dd2a9528fd150 Mon Sep 17 00:00:00 2001 From: Taveon Nelson <74945303+457R0@users.noreply.github.com> Date: Tue, 26 May 2026 03:36:45 -0500 Subject: [PATCH] HCE Savepoint This is HCE before companion merge --- app/build.gradle.kts | 18 +- app/src/main/AndroidManifest.xml | 40 +- .../github/nacabaro/vbhelper/MainActivity.kt | 81 +- .../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 +- .../nacabaro/vbhelper/daos/AdventureDao.kt | 2 - .../github/nacabaro/vbhelper/daos/CardDao.kt | 3 - .../nacabaro/vbhelper/daos/CardFusionsDao.kt | 3 +- .../nacabaro/vbhelper/daos/CharacterDao.kt | 6 - .../nacabaro/vbhelper/daos/SpriteDao.kt | 3 - .../vbhelper/daos/UserCharacterDao.kt | 17 - .../nacabaro/vbhelper/database/AppDatabase.kt | 98 +-- .../nacabaro/vbhelper/di/AppContainer.kt | 4 - .../vbhelper/di/DefaultAppContainer.kt | 16 +- .../github/nacabaro/vbhelper/di/VBHelper.kt | 25 - .../vbhelper/domain/card/Background.kt | 2 - .../vbhelper/domain/card/CardAdventure.kt | 5 - .../vbhelper/domain/card/CardCharacter.kt | 10 +- .../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/UserCharacter.kt | 2 - .../domain/device_data/VitalsHistory.kt | 2 - .../vbhelper/navigation/AppNavigation.kt | 17 - .../navigation/BottomNavigationBar.kt | 18 +- .../vbhelper/navigation/NavigationItems.kt | 4 +- .../vbhelper/screens/BattlesScreen.kt | 504 +++++------- .../screens/homeScreens/HomeScreen.kt | 63 +- .../screens/itemsScreen/ItemElement.kt | 56 -- .../screens/itemsScreen/ItemsScreen.kt | 4 +- .../itemsScreen/ItemsScreenControllerImpl.kt | 21 +- .../scanScreen/ChooseConnectionScreen.kt | 2 +- .../vbhelper/screens/scanScreen/ScanScreen.kt | 10 +- .../scanScreen/ScanScreenController.kt | 17 +- .../scanScreen/ScanScreenControllerImpl.kt | 743 +++--------------- .../scanScreen/converters/FromNfcConverter.kt | 61 +- .../scanScreen/converters/ToNfcConverter.kt | 91 +-- .../scanScreen/screens/ActionScreen.kt | 77 +- .../scanScreen/screens/ReadingScreen.kt | 102 +-- .../scanScreen/screens/WritingScreen.kt | 79 +- .../screens/settingsScreen/SettingsScreen.kt | 72 -- .../SettingsScreenController.kt | 4 - .../SettingsScreenControllerImpl.kt | 40 +- .../controllers/CardImportController.kt | 53 +- .../DatabaseManagementController.kt | 141 ---- .../vbhelper/source/AuthRepository.kt | 6 - .../source/VitalWearCharacterExporter.kt | 265 +++---- .../source/VitalWearCharacterImporter.kt | 564 ++----------- .../nacabaro/vbhelper/utils/BitmapData.kt | 4 +- .../nacabhelper/screens/BattlesScreen.kt | 1 + .../cfogrady/vitalwear/protos/Character.proto | 68 -- app/src/main/res/values-pt-rBR/strings.xml | 10 +- app/src/main/res/values/strings.xml | 43 +- app/src/main/res/xml/provider_paths.xml | 3 - gradle/libs.versions.toml | 15 +- settings.gradle.kts | 8 - 66 files changed, 1328 insertions(+), 2995 deletions(-) create mode 100644 app/src/main/java/com/github/nacabhelper/screens/BattlesScreen.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a2f1042..99c898f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -73,12 +73,8 @@ protobuf { dependencies { implementation(libs.androidx.room.runtime) implementation(libs.vb.nfc.reader) - // Temporarily commented out due to Lombok compilation issues in VB-DIM-Reader - // implementation(libs.dim.reader) - implementation(files("../../VB-DIM-Reader-2.0.0/build/libs/VB-DIM-Reader.jar")) - //VB-DIM-Reader runtime dependency required by the local JAR - implementation("at.favre.lib:bytes:1.5.0") - + implementation(libs.dim.reader) + implementation(libs.androidx.core.ktx) implementation(libs.androidx.room.ktx) implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.navigation.compose) @@ -94,13 +90,6 @@ dependencies { implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) implementation(libs.androidx.compose.material.icons.extended) - implementation(libs.play.services.wearable) - implementation(libs.kotlinx.coroutines.play.services) - implementation(libs.timber) - implementation(libs.tiny.log) - implementation(libs.tiny.log.impl) - implementation(libs.slf4j.api) - runtimeOnly(libs.slf4j.timber) debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.test.manifest) @@ -129,7 +118,4 @@ dependencies { // HTTP request logging implementation("com.squareup.okhttp3:logging-interceptor:4.11.0") - - // In-app browser for OAuth login - implementation("androidx.browser:browser:1.8.0") } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 538abe9..39fa631 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,16 +2,8 @@ - - - - @@ -39,25 +31,6 @@ android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/provider_paths" /> - - - - - - - - - - - - + + + - + 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 c883fbc..9d178a1 100644 --- a/app/src/main/java/com/github/nacabaro/vbhelper/MainActivity.kt +++ b/app/src/main/java/com/github/nacabaro/vbhelper/MainActivity.kt @@ -1,19 +1,19 @@ package com.github.nacabaro.vbhelper import android.content.Intent +import android.net.Uri import android.os.Bundle import android.util.Log +import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue +import androidx.lifecycle.lifecycleScope +import com.github.cfogrady.vitalwear.protos.Character import com.github.nacabaro.vbhelper.navigation.AppNavigation import com.github.nacabaro.vbhelper.di.VBHelper import com.github.nacabaro.vbhelper.navigation.AppNavigationHandlers -import com.github.nacabaro.vbhelper.navigation.NavigationItems import com.github.nacabaro.vbhelper.screens.homeScreens.HomeScreenControllerImpl import com.github.nacabaro.vbhelper.screens.itemsScreen.ItemsScreenControllerImpl import com.github.nacabaro.vbhelper.screens.scanScreen.ScanScreenControllerImpl @@ -22,12 +22,15 @@ import com.github.nacabaro.vbhelper.screens.adventureScreen.AdventureScreenContr import com.github.nacabaro.vbhelper.screens.cardScreen.CardScreenControllerImpl import com.github.nacabaro.vbhelper.screens.spriteViewer.SpriteViewerControllerImpl import com.github.nacabaro.vbhelper.screens.storageScreen.StorageScreenControllerImpl +import com.github.nacabaro.vbhelper.source.VitalWearCharacterImporter import com.github.nacabaro.vbhelper.ui.theme.VBHelperTheme +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch class MainActivity : ComponentActivity() { private val onActivityLifecycleListeners = HashMap() - private var initialRoute: String? by mutableStateOf(null) + private var initialRoute: String? = null private fun registerActivityLifecycleListener(key: String, activityLifecycleListener: ActivityLifecycleListener) { if( onActivityLifecycleListeners[key] != null) { @@ -79,6 +82,7 @@ class MainActivity : ComponentActivity() { } Log.i("MainActivity", "Activity onCreated") + handleImportIntent(intent) } override fun onPause() { @@ -101,25 +105,62 @@ class MainActivity : ComponentActivity() { super.onNewIntent(intent) setIntent(intent) initialRoute = getInitialRouteFromIntent(intent) + // Optionally, you may want to trigger navigation here if needed + handleImportIntent(intent) + } + + private fun handleImportIntent(intent: Intent?) { + val importUri = extractVitalWearImportUri(intent) ?: return + val application = applicationContext as VBHelper + + lifecycleScope.launch(Dispatchers.IO) { + val result = runCatching { + contentResolver.openInputStream(importUri)?.use { inputStream -> + val character = Character.parseFrom(inputStream) + VitalWearCharacterImporter(application.container.db).importCharacter(character) + } ?: VitalWearCharacterImporter.ImportResult( + success = false, + message = "VitalWear import file could not be opened." + ) + }.getOrElse { + VitalWearCharacterImporter.ImportResult( + success = false, + message = "VitalWear import failed: ${it.message ?: "Unknown error"}" + ) + } + + runOnUiThread { + Toast.makeText(this@MainActivity, result.message, Toast.LENGTH_LONG).show() + } + } + } + + private fun extractVitalWearImportUri(intent: Intent?): Uri? { + if (intent == null) { + return null + } + + val isVitalWearImport = intent.type == VITALWEAR_CHARACTER_MIME + if (!isVitalWearImport) { + return null + } + + return when (intent.action) { + Intent.ACTION_SEND -> { + @Suppress("DEPRECATION") + intent.getParcelableExtra(Intent.EXTRA_STREAM) + } + Intent.ACTION_VIEW -> intent.data + else -> null + } } private fun getInitialRouteFromIntent(intent: Intent?): String? { if (intent == null) return null val data = intent.data if (intent.action == Intent.ACTION_VIEW && data != null) { - val isAppAuthCallback = data.scheme == "vbhelper" && data.host == "auth" - val isLocalhostAuthCallback = - (data.scheme == "http" || data.scheme == "https") && - (data.host == "localhost" || data.host == "127.0.0.1") && - data.path?.startsWith("/authenticate") == true - val token = data.getQueryParameter("c") ?: data.getQueryParameter("token") - val hasAuthToken = !token.isNullOrEmpty() - - if (isAppAuthCallback || isLocalhostAuthCallback || hasAuthToken) { - // BattlesScreen consumes the callback intent and exchanges the single-use token - // for a durable session token. Writing partial auth state here can clear an - // already-valid session token and cause the browser login loop to restart. - return NavigationItems.Battles.route + if (data.scheme == "vbhelper" && data.host == "auth") { + return "Battle" } } return null @@ -151,4 +192,8 @@ class MainActivity : ComponentActivity() { initialRoute = initialRoute ) } + + companion object { + private const val VITALWEAR_CHARACTER_MIME = "application/x-vitalwear-character" + } } 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 7d6f6cc..f75b935 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,7 +5,9 @@ import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import kotlinx.coroutines.launch +import kotlinx.coroutines.delay @Composable fun AnimatedSpriteImage( @@ -13,10 +15,12 @@ fun AnimatedSpriteImage( animationType: DigimonAnimationType = DigimonAnimationType.IDLE, modifier: Modifier = Modifier, contentScale: ContentScale = ContentScale.Fit, + reloadMappings: Boolean = false, animationOffset: Long = 0L // New parameter for offsetting animation timing ) { - val spriteManager = remember { IndividualSpriteManager() } - + val context = LocalContext.current + val spriteManager = remember { IndividualSpriteManager(context) } + // Calculate frame offset based on animation offset // 750ms is the idle animation duration, so we calculate how many frames to offset val frameOffset = if (animationOffset > 0L) { @@ -26,11 +30,18 @@ fun AnimatedSpriteImage( 0 } - val animationStateMachine = remember { DigimonAnimationStateMachine(characterId, frameOffset, animationOffset) } + val animationStateMachine = remember { DigimonAnimationStateMachine(characterId, context, frameOffset, animationOffset) } val coroutineScope = rememberCoroutineScope() var bitmap by remember { mutableStateOf(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 5a6beaf..cac56be 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,11 +1,18 @@ 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 { - // Attack phases: 0=Idle, 1=Player attack on player screen, 2=Player attack on opponent screen, + companion object { + private const val TAG = "ArenaBattleSystem" + } + + // Attack phases: 0=Idle, 1=Player attack on player screen, 2=Player attack on opponent screen, // 3=Opponent attack on opponent screen, 4=Opponent attack on player screen private var _attackPhase by mutableStateOf(0) val attackPhase: Int get() = _attackPhase @@ -37,6 +44,19 @@ class ArenaBattleSystem { private var _critBarProgress by mutableStateOf(0) val critBarProgress: Int get() = _critBarProgress + // Dodge animation states + private var _isDodging by mutableStateOf(false) + val isDodging: Boolean get() = _isDodging + + private var _dodgeProgress by mutableStateOf(0f) + val dodgeProgress: Float get() = _dodgeProgress + + private var _dodgeDirection by mutableStateOf(1f) // 1f = up, -1f = down + val dodgeDirection: Float get() = _dodgeDirection + + private var _isHit by mutableStateOf(false) + val isHit: Boolean get() = _isHit + private var _hitProgress by mutableStateOf(0f) val hitProgress: Float get() = _hitProgress @@ -160,6 +180,10 @@ class ArenaBattleSystem { _isPlayerAttacking = false _attackIsHit = false _currentView = 0 + _isDodging = false + _dodgeProgress = 0f + _dodgeDirection = 1f + _isHit = false _hitProgress = 0f _isPlayerDodging = false _isOpponentDodging = false @@ -191,10 +215,41 @@ class ArenaBattleSystem { //Log.d(TAG, "Updated crit bar progress: $progress") } + // Dodge animation methods + fun startDodge() { + _isDodging = true + _dodgeProgress = 0f + _dodgeDirection = 1f // Start moving up + } + + fun setDodgeProgress(progress: Float) { + _dodgeProgress = progress + } + + fun setDodgeDirection(direction: Float) { + _dodgeDirection = direction + } + + fun endDodge() { + _isDodging = false + _dodgeProgress = 0f + } + + // Hit animation methods + fun startHit() { + _isHit = true + _hitProgress = 0f + } + fun setHitProgress(progress: Float) { _hitProgress = progress } + fun endHit() { + _isHit = false + _hitProgress = 0f + } + // Player-specific dodge methods fun startPlayerDodge() { _isPlayerDodging = true @@ -290,6 +345,17 @@ class ArenaBattleSystem { _isOpponentShakeDelayed = false } + // Combined method to handle attack result + fun handleAttackResult(isHit: Boolean) { + _attackIsHit = isHit + if (isHit) { + // Player attack hit - opponent gets hit + startOpponentHit() + } else { + // Player attack missed - opponent dodges + startOpponentDodge() + } + } // Method to handle opponent attack result fun handleOpponentAttackResult(isHit: Boolean) { 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 6488586..df03762 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,7 +20,6 @@ class AuthInterceptor(private val token: String) : Interceptor { // Use X-Session-Token header (preferred) or Authorization: Bearer val authenticatedRequest = originalRequest.newBuilder() .header("X-Session-Token", token) - .header("Authorization", "Bearer $token") .build() // Debug: Log which header is being used (first few chars of token for security) 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 5b1f1b0..cb1f4fd 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,46 +1,21 @@ package com.github.nacabaro.vbhelper.battle -import com.google.gson.annotations.SerializedName - data class AdditionalInfo( - @SerializedName(value = "avatar", alternate = ["avatar_url"]) val avatar: String? = null, - @SerializedName(value = "id", alternate = ["user_id"]) val id: Long? = null, - @SerializedName(value = "name", alternate = ["username", "display_name"]) val name: String? = null, val status: String? = null ) data class UserInfo( - @SerializedName(value = "userId", alternate = ["user_id", "id"]) val userId: String? = null, - @SerializedName(value = "additionalInfo", alternate = ["additional_info"]) val additionalInfo: AdditionalInfo? = null ) data class AuthenticateResponse( - @SerializedName(value = "success", alternate = ["ok"]) val success: Boolean, val message: String? = null, - @SerializedName(value = "userInfo", alternate = ["user_info", "user"]) val userInfo: UserInfo? = null, - @SerializedName(value = "sessionToken", alternate = ["session_token", "token", "session"]) - val sessionToken: String? = null, - @SerializedName(value = "userId", alternate = ["user_id"]) - val topLevelUserId: String? = null, - @SerializedName(value = "id", alternate = ["uid"]) - val topLevelId: Long? = null, + val sessionToken: String? = null ) -fun AuthenticateResponse.extractSessionToken(): String? { - return sessionToken?.takeIf { it.isNotBlank() } -} - -fun AuthenticateResponse.extractUserId(): Long? { - return userInfo?.userId?.toLongOrNull() - ?: topLevelUserId?.toLongOrNull() - ?: userInfo?.additionalInfo?.id - ?: topLevelId -} - 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 40c4b00..c2ee41e 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,13 +4,22 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import kotlinx.coroutines.delay +import android.content.Context +import java.io.File enum class DigimonAnimationType { IDLE, IDLE2, WALK, + WALK2, + RUN, + RUN2, + WORKOUT, + WORKOUT2, + HAPPY, SLEEP, - ATTACK + ATTACK, + FLEE } data class AnimationState( @@ -22,6 +31,7 @@ data class AnimationState( class DigimonAnimationStateMachine( private val characterId: String, + private val context: Context, private val initialFrameOffset: Int = 0, // New parameter for offsetting the starting frame private val timingOffset: Long = 0L // New parameter for offsetting the timing ) { @@ -34,13 +44,21 @@ class DigimonAnimationStateMachine( var isPlaying by mutableStateOf(false) private set - // Direct mapping of frame numbers used by current battle animations. + // Direct mapping of frame numbers (1-12) to animation types + // This is based on the standard Digimon sprite frame order private val frameToAnimationType = mapOf( 1 to DigimonAnimationType.IDLE, 2 to DigimonAnimationType.IDLE2, 3 to DigimonAnimationType.WALK, + 4 to DigimonAnimationType.WALK2, + 5 to DigimonAnimationType.RUN, + 6 to DigimonAnimationType.RUN2, + 7 to DigimonAnimationType.WORKOUT, + 8 to DigimonAnimationType.WORKOUT2, + 9 to DigimonAnimationType.HAPPY, 10 to DigimonAnimationType.SLEEP, - 11 to DigimonAnimationType.ATTACK + 11 to DigimonAnimationType.ATTACK, + 12 to DigimonAnimationType.FLEE ) // Reverse mapping for getting frame numbers for each animation type @@ -51,8 +69,15 @@ class DigimonAnimationStateMachine( DigimonAnimationType.IDLE to 750L, DigimonAnimationType.IDLE2 to 750L, DigimonAnimationType.WALK to 200L, + DigimonAnimationType.WALK2 to 200L, + DigimonAnimationType.RUN to 150L, + DigimonAnimationType.RUN2 to 150L, + DigimonAnimationType.WORKOUT to 300L, + DigimonAnimationType.WORKOUT2 to 300L, + DigimonAnimationType.HAPPY to 400L, DigimonAnimationType.SLEEP to 1500L, - DigimonAnimationType.ATTACK to 650L + DigimonAnimationType.ATTACK to 650L, + DigimonAnimationType.FLEE to 150L ) /* @@ -130,4 +155,13 @@ class DigimonAnimationStateMachine( return currentFrameNumber } -} \ No newline at end of file + 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 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 800a0f3..e4a3383 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,6 +10,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.unit.dp import kotlinx.coroutines.delay @@ -24,9 +25,10 @@ fun HitEffectOverlay( ) { if (!isVisible) return + val context = LocalContext.current val configuration = LocalConfiguration.current val isLandscapeMode = configuration.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE - val hitEffectManager = remember { HitEffectSpriteManager() } + val hitEffectManager = remember { HitEffectSpriteManager(context) } val coroutineScope = rememberCoroutineScope() var currentFrame by remember { mutableStateOf(0) } 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 e6fae95..fa116be 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,10 +1,13 @@ package com.github.nacabaro.vbhelper.battle +import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory +import android.graphics.Rect +import android.os.Environment import java.io.File -class HitEffectSpriteManager { +class HitEffectSpriteManager(private val context: Context) { private val spriteCache = mutableMapOf() // Get the external storage directory for hit effect sprites @@ -53,4 +56,117 @@ class HitEffectSpriteManager { } } + /** + * Load a damage effect sprite from spritesheet + * @param spritesheetName The spritesheet name (e.g., "dmg_ef1", "dmg_ef2") + * @param frameIndex The frame index (0-3 for dmg_ef1 and dmg_ef2, 0 for dmg_ef3) + * @return Bitmap of the damage effect frame, or null if not found + */ + fun loadDamageEffectSprite(spritesheetName: String, frameIndex: Int = 0): Bitmap? { + val cacheKey = "dmg_${spritesheetName}_frame_${frameIndex}" + + // Check cache first + if (spriteCache.containsKey(cacheKey)) { + return spriteCache[cacheKey] + } + + try { + val spritesheetFile = File(getHitSpritesDir(), "$spritesheetName.png") + + if (!spritesheetFile.exists()) { + println("Damage effect spritesheet not found: ${spritesheetFile.absolutePath}") + return null + } + + val spritesheet = BitmapFactory.decodeFile(spritesheetFile.absolutePath) + if (spritesheet == null) { + println("Failed to decode damage effect spritesheet: ${spritesheetFile.absolutePath}") + return null + } + + // Extract frame from spritesheet + val frameBitmap = when (spritesheetName) { + "dmg_ef1", "dmg_ef2" -> { + // These are 2x2 spritesheets (4 frames) + val frameWidth = spritesheet.width / 2 + val frameHeight = spritesheet.height / 2 + val row = frameIndex / 2 + val col = frameIndex % 2 + val x = col * frameWidth + val y = row * frameHeight + Bitmap.createBitmap(spritesheet, x, y, frameWidth, frameHeight) + } + "dmg_ef3" -> { + // This is a single sprite + spritesheet + } + else -> { + println("Unknown spritesheet name: $spritesheetName") + return null + } + } + + println("Successfully loaded damage effect frame: $spritesheetName frame $frameIndex (${frameBitmap.width}x${frameBitmap.height})") + + // Cache the result + spriteCache[cacheKey] = frameBitmap + + return frameBitmap + + } catch (e: Exception) { + println("Error loading damage effect sprite: ${e.message}") + e.printStackTrace() + return null + } + } + + /** + * Get all available hit sprites + * @return List of hit sprite names (without .png extension) + */ + fun getAvailableHitSprites(): List { + 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 b6bb2bc..84c68a7 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,10 +1,12 @@ package com.github.nacabaro.vbhelper.battle +import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory +import android.os.Environment import java.io.File -class IndividualSpriteManager { +class IndividualSpriteManager(private val context: Context) { private val spriteCache = mutableMapOf() // Get the external storage directory for sprite files @@ -63,4 +65,68 @@ class IndividualSpriteManager { } } -} \ No newline at end of file + /** + * 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 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 fc0ce24..b465ddc 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,14 +12,10 @@ 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. @@ -32,22 +28,9 @@ class RetrofitHelper { return OkHttpClient.Builder() .addInterceptor(AuthInterceptor(token)) .addInterceptor(loggingInterceptor) - .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .writeTimeout(30, TimeUnit.SECONDS) - .callTimeout(45, TimeUnit.SECONDS) - .retryOnConnectionFailure(true) .build() } - - private fun classifyNetworkError(t: Throwable): String { - return when (t) { - is SocketTimeoutException -> "Authentication server timed out" - is InterruptedIOException -> "Request timed out" - else -> t.message ?: "Network error" - } - } - + /** * Gets the session token from AuthRepository for API calls. * Falls back to nacatech token if session token is not available (backward compatibility). @@ -55,8 +38,7 @@ class RetrofitHelper { private fun getAuthToken(context: Context): String? { return try { val authContainer = BattleAuthContainer(context) - runBlocking(Dispatchers.IO) { - withTimeoutOrNull(5000L) { + runBlocking { // Prefer session token, fall back to nacatech token for backward compatibility val sessionToken = authContainer.authRepository.sessionToken.first() if (!sessionToken.isNullOrEmpty()) { @@ -70,10 +52,6 @@ class RetrofitHelper { } nacatechToken } - } ?: run { - println("RetrofitHelper: Timed out while reading auth token from DataStore") - null - } } } catch (e: Exception) { println("RetrofitHelper: Error getting auth token: ${e.message}") @@ -98,30 +76,7 @@ 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. @@ -166,15 +121,16 @@ class RetrofitHelper { } } - fun getOpponents(context: Context, stage: String, onComplete: (() -> Unit)? = null, callback: (OpponentsDataModel) -> Unit) { + fun getOpponents(context: Context, stage: String, callback: (OpponentsDataModel) -> Unit) { //println("RetrofitHelper: Starting API call for stage: $stage") try { - // Opponents endpoint requires auth on the current backend. - // Prefer authenticated Retrofit; fall back to public client only if no token is available. - val retrofit = createAuthenticatedRetrofit(context) ?: run { - println("RetrofitHelper: No auth token for opponents request, falling back to public Retrofit") - createPublicRetrofit() + // Create an authenticated Retrofit instance + val retrofit = createAuthenticatedRetrofit(context) + if (retrofit == null) { + println("RetrofitHelper: Cannot create authenticated Retrofit - no token available") + Toast.makeText(context, "Authentication required. Please log in.", Toast.LENGTH_SHORT).show() + return } // Create an ApiService instance from the Retrofit instance. @@ -190,11 +146,9 @@ class RetrofitHelper { // make an asynchronous API request. call.enqueue(object : Callback { override fun onFailure(call: Call, t: Throwable) { - val classified = classifyNetworkError(t) - println("RetrofitHelper: API call failed (${t::class.java.simpleName}): $classified") + println("RetrofitHelper: API call failed: ${t.message}") t.printStackTrace() - Toast.makeText(context, classified, Toast.LENGTH_SHORT).show() - onComplete?.invoke() + Toast.makeText(context, "Request Fail", Toast.LENGTH_SHORT).show() } override fun onResponse(call: Call, response: Response) { @@ -202,19 +156,13 @@ class RetrofitHelper { println("RetrofitHelper: Response body: ${response.body()}") if(response.isSuccessful){ - 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() + //println("RetrofitHelper: Response successful, calling callback") + val opponentsList: OpponentsDataModel = response.body() as OpponentsDataModel + callback(opponentsList) } else { val errorBody = response.errorBody()?.string() - println("RetrofitHelper: Opponents response not successful - Code: ${response.code()}, Error: $errorBody") - handleErrorResponse(context, response, errorBody ?: "Unable to load opponents right now.") - onComplete?.invoke() + println("RetrofitHelper: Response not successful - Error: $errorBody") + handleErrorResponse(context, response, errorBody ?: "Unknown error") } } }) @@ -222,7 +170,6 @@ class RetrofitHelper { println("RetrofitHelper: Exception in getOpponents: ${e.message}") e.printStackTrace() Toast.makeText(context, "Request failed: ${e.message}", Toast.LENGTH_SHORT).show() - onComplete?.invoke() } } @@ -337,10 +284,9 @@ class RetrofitHelper { override fun onFailure(call: Call, t: Throwable) { // This method is called when the API request fails. - val classified = classifyNetworkError(t) - println("RetrofitHelper: PVP API call failed (${t::class.java.simpleName}): $classified") + println("RetrofitHelper: PVP API call failed: ${t.message}") t.printStackTrace() - Toast.makeText(context, classified, Toast.LENGTH_SHORT).show() + Toast.makeText(context, "Request Fail", Toast.LENGTH_SHORT).show() } override fun onResponse(call: Call, response: Response) { @@ -348,13 +294,13 @@ class RetrofitHelper { println("RetrofitHelper: PVP API response received - Code: ${response.code()}") if(response.isSuccessful){ - val apiResults = response.body() - if (apiResults != null) { - callback(apiResults) - } else { - println("RetrofitHelper: Successful PVP response had empty body") - Toast.makeText(context, "Empty response from server", Toast.LENGTH_SHORT).show() - } + // If the response is successful, parse the + // response body to a DataModel object. + val apiResults: PVPDataModel = response.body() as PVPDataModel + + // Call the callback function with the DataModel + // object as a parameter. + callback(apiResults) } else { val errorBody = response.errorBody()?.string() println("RetrofitHelper: PVP API response not successful - Code: ${response.code()}, Error: $errorBody") @@ -369,20 +315,12 @@ class RetrofitHelper { } } - fun authenticate( - context: Context, - token: String, - showUserFacingErrors: Boolean = true, - callback: (AuthenticateResponse) -> Unit, - ) { + fun authenticate(context: Context, token: String, callback: (AuthenticateResponse) -> Unit) { //println("RetrofitHelper: Starting validate API call with token: $token") if (token.isEmpty()) { println("RetrofitHelper: ERROR - Token is empty!") - if (showUserFacingErrors) { - Toast.makeText(context, "Authentication failed: Token is empty", Toast.LENGTH_SHORT).show() - } - callback(AuthenticateResponse(success = false, message = "Token is empty")) + Toast.makeText(context, "Authentication failed: Token is empty", Toast.LENGTH_SHORT).show() return } @@ -393,10 +331,6 @@ class RetrofitHelper { } val okHttpClient = OkHttpClient.Builder() .addInterceptor(loggingInterceptor) - .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .writeTimeout(30, TimeUnit.SECONDS) - .callTimeout(45, TimeUnit.SECONDS) .build() val retrofit: Retrofit = Retrofit.Builder() @@ -414,11 +348,7 @@ class RetrofitHelper { override fun onFailure(call: Call, t: Throwable) { println("RetrofitHelper: Validate API call failed: ${t.message}") t.printStackTrace() - val message = classifyNetworkError(t) - if (showUserFacingErrors) { - Toast.makeText(context, "Authentication failed: $message", Toast.LENGTH_SHORT).show() - } - callback(AuthenticateResponse(success = false, message = message)) + Toast.makeText(context, "Authentication failed: ${t.message}", Toast.LENGTH_SHORT).show() } override fun onResponse(call: Call, response: Response) { @@ -430,25 +360,11 @@ class RetrofitHelper { } else { println("RetrofitHelper: Validation failed: Invalid response body") Toast.makeText(context, "Authentication failed: Invalid response", Toast.LENGTH_SHORT).show() - callback(AuthenticateResponse(success = false, message = "Invalid response body")) } } else { val errorBody = response.errorBody()?.string() println("RetrofitHelper: Validate response not successful - Code: ${response.code()}, Error: $errorBody") - val userMessage = when { - response.code() == 522 -> "Authentication server timed out. Please try again in a moment." - response.code() >= 500 -> "Authentication server is unavailable. Please try again later." - else -> "Authentication failed: ${response.code()}" - } - if (showUserFacingErrors) { - Toast.makeText(context, userMessage, Toast.LENGTH_SHORT).show() - } - callback( - AuthenticateResponse( - success = false, - message = "HTTP ${response.code()}: ${errorBody ?: "Authentication server error"}" - ) - ) + Toast.makeText(context, "Authentication failed: ${response.code()}", Toast.LENGTH_SHORT).show() } } }) @@ -456,7 +372,6 @@ class RetrofitHelper { println("RetrofitHelper: Exception in validate: ${e.message}") e.printStackTrace() Toast.makeText(context, "Authentication failed: ${e.message}", Toast.LENGTH_SHORT).show() - callback(AuthenticateResponse(success = false, message = e.message ?: "Authentication error")) } } } \ 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 e2812c3..65238e4 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,13 +1,275 @@ package com.github.nacabaro.vbhelper.battle +import android.content.Context import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.io.FileInputStream -class SpriteFileManager { - private fun getExternalSpriteBaseDir(): File { +class SpriteFileManager(private val context: Context) { + + // Get the external storage directory where files are already located + fun getExternalSpriteBaseDir(): File { val externalDir = android.os.Environment.getExternalStorageDirectory() return File(externalDir, "VBHelper/battle_sprites") } - + + // Get the internal storage directory for sprite files + private fun getInternalSpriteBaseDir(): File { + return File(context.filesDir, "battle_sprites") + } + + fun copySpriteFilesToInternalStorage() { + try { + println("Starting sprite file copy process from external storage to internal storage...") + + val externalDir = getExternalSpriteBaseDir() + val internalDir = getInternalSpriteBaseDir() + + // Check if external directory exists + if (!externalDir.exists()) { + println("External sprite directory does not exist: ${externalDir.absolutePath}") + return + } + + println("External sprite directory exists: ${externalDir.absolutePath}") + println("Copying to internal storage: ${internalDir.absolutePath}") + + // Create internal directory if it doesn't exist + if (!internalDir.exists()) { + val created = internalDir.mkdirs() + println("Created internal sprite directory: $created") + } + + // Copy all subdirectories from external to internal storage + val externalFiles = externalDir.listFiles() + if (externalFiles != null) { + println("Found ${externalFiles.size} items in external directory") + externalFiles.forEach { item -> + val targetItem = File(internalDir, item.name) + if (item.isDirectory) { + println("Copying directory: ${item.name}") + copyDirectory(item, targetItem) + } else { + println("Copying file: ${item.name}") + copyFile(item, targetItem) + } + } + } + + println("Sprite files copied successfully to internal storage: ${internalDir.absolutePath}") + + } catch (e: Exception) { + println("Error copying sprite files to internal storage: ${e.message}") + e.printStackTrace() + } + } + + fun copySpriteFilesToExternalStorage() { + try { + println("Starting sprite file copy process to external storage...") + + // Debug: List what's in the assets directory + val assetManager = context.assets + val battleSpritesFiles = assetManager.list("battle_sprites") + println("battle_sprites directory in assets contains: ${battleSpritesFiles?.joinToString(", ")}") + + val extractedAssetsFiles = assetManager.list("battle_sprites/extracted_assets") + println("battle_sprites/extracted_assets directory in assets contains: ${extractedAssetsFiles?.joinToString(", ")}") + + // Check specifically for extracted_atksprites in assets (now directly under battle_sprites) + val atkspritesInAssets = assetManager.list("battle_sprites/extracted_atksprites") + println("extracted_atksprites in assets contains: ${atkspritesInAssets?.size ?: 0} files") + if (atkspritesInAssets != null && atkspritesInAssets.isNotEmpty()) { + println("First few attack files in assets: ${atkspritesInAssets.take(5).joinToString(", ")}") + } + + // Check for extracted_battlebgs in assets (now directly under battle_sprites) + val battlebgsInAssets = assetManager.list("battle_sprites/extracted_battlebgs") + println("extracted_battlebgs in assets contains: ${battlebgsInAssets?.size ?: 0} files") + if (battlebgsInAssets != null && battlebgsInAssets.isNotEmpty()) { + println("First few battle background files in assets: ${battlebgsInAssets.take(5).joinToString(", ")}") + } + + // Try to list all possible subdirectories in battle_sprites + println("Checking all possible subdirectories in battle_sprites...") + battleSpritesFiles?.forEach { subdir -> + try { + val subdirFiles = assetManager.list("battle_sprites/$subdir") + println(" $subdir contains: ${subdirFiles?.size ?: 0} files") + if (subdirFiles != null && subdirFiles.isNotEmpty()) { + println(" First few files: ${subdirFiles.take(3).joinToString(", ")}") + } + } catch (e: Exception) { + println(" Error listing $subdir: ${e.message}") + } + } + + // Create the base directory for battle_sprites in external storage + val battleSpritesDir = getExternalSpriteBaseDir() + if (!battleSpritesDir.exists()) { + battleSpritesDir.mkdirs() + println("Created battle_sprites directory in external storage: ${battleSpritesDir.absolutePath}") + } else { + println("battle_sprites directory already exists in external storage: ${battleSpritesDir.absolutePath}") + } + + // Copy all subdirectories from battle_sprites assets to external storage + println("Copying all battle_sprites subdirectories to external storage...") + battleSpritesFiles?.forEach { subdir -> + val sourcePath = "battle_sprites/$subdir" + val targetDir = File(battleSpritesDir, subdir) + println("Copying $sourcePath to ${targetDir.absolutePath}") + copyAssetDirectory(sourcePath, targetDir) + } + + println("Sprite files copied successfully to external storage: ${battleSpritesDir.absolutePath}") + + // Verify that attack sprites were copied + val atkspritesDir = File(battleSpritesDir, "extracted_atksprites") + if (atkspritesDir.exists()) { + val attackFiles = atkspritesDir.listFiles() + println("Attack sprites directory exists with ${attackFiles?.size ?: 0} files") + if (attackFiles != null && attackFiles.isNotEmpty()) { + println("First few attack files: ${attackFiles.take(5).map { it.name }}") + } + } else { + println("WARNING: extracted_atksprites directory does not exist!") + // List what's actually in the battle_sprites directory + val battleSpritesContents = battleSpritesDir.listFiles() + println("battle_sprites directory contains: ${battleSpritesContents?.map { it.name }?.joinToString(", ")}") + } + + // Verify that battle backgrounds were copied + val battlebgsDir = File(battleSpritesDir, "extracted_battlebgs") + if (battlebgsDir.exists()) { + val bgFiles = battlebgsDir.listFiles() + println("Battle backgrounds directory exists with ${bgFiles?.size ?: 0} files") + if (bgFiles != null && bgFiles.isNotEmpty()) { + println("First few battle background files: ${bgFiles.take(5).map { it.name }}") + } + } else { + println("WARNING: extracted_battlebgs directory does not exist!") + } + + } catch (e: Exception) { + println("Error copying sprite files to external storage: ${e.message}") + e.printStackTrace() + } + } + + private fun copyAssetDirectory(assetPath: String, targetDir: File) { + try { + val assetManager = context.assets + val files = assetManager.list(assetPath) ?: return + + println("Copying asset directory: $assetPath (${files.size} items)") + println("Files found: ${files.joinToString(", ")}") + + for (file in files) { + val assetFilePath = if (assetPath.isEmpty()) file else "$assetPath/$file" + val targetFile = File(targetDir, file) + + // Create subdirectories if needed + if (targetFile.parentFile != null && !targetFile.parentFile!!.exists()) { + targetFile.parentFile!!.mkdirs() + } + + // Check if it's a directory by trying to list its contents + try { + val subFiles = assetManager.list(assetFilePath) + if (subFiles != null && subFiles.isNotEmpty()) { + // It's a directory, create it and copy contents + println("Copying subdirectory: $assetFilePath (${subFiles.size} files)") + if (!targetFile.exists()) { + targetFile.mkdirs() + } + copyAssetDirectory(assetFilePath, targetFile) + } else { + // It's a file, copy it + copyAssetFile(assetFilePath, targetFile) + } + } catch (e: Exception) { + // If we can't list contents, it's probably a file + println("Treating $assetFilePath as file (could not list contents)") + copyAssetFile(assetFilePath, targetFile) + } + } + + // Special handling for extracted_atksprites - try to copy it directly if it wasn't found + if (assetPath == "battle_sprites/extracted_assets") { + println("Special handling: Checking for extracted_atksprites directory...") + try { + val atkspritesFiles = assetManager.list("battle_sprites/extracted_assets/extracted_atksprites") + if (atkspritesFiles != null && atkspritesFiles.isNotEmpty()) { + println("Found extracted_atksprites with ${atkspritesFiles.size} files") + val atkspritesDir = File(targetDir, "extracted_atksprites") + if (!atkspritesDir.exists()) { + atkspritesDir.mkdirs() + } + copyAssetDirectory("battle_sprites/extracted_assets/extracted_atksprites", atkspritesDir) + } else { + println("extracted_atksprites directory not found in assets") + } + } catch (e: Exception) { + println("Error checking extracted_atksprites: ${e.message}") + } + } + + } catch (e: Exception) { + println("Error copying asset directory $assetPath: ${e.message}") + e.printStackTrace() + } + } + + private fun copyDirectory(sourceDir: File, targetDir: File) { + if (!targetDir.exists()) { + targetDir.mkdirs() + } + + val files = sourceDir.listFiles() + if (files != null) { + files.forEach { file -> + val targetFile = File(targetDir, file.name) + if (file.isDirectory) { + copyDirectory(file, targetFile) + } else { + copyFile(file, targetFile) + } + } + } + } + + private fun copyFile(sourceFile: File, targetFile: File) { + try { + val inputStream = FileInputStream(sourceFile) + val outputStream = FileOutputStream(targetFile) + + inputStream.copyTo(outputStream) + inputStream.close() + outputStream.close() + + println("Copied: ${sourceFile.name} -> ${targetFile.absolutePath}") + } catch (e: IOException) { + println("Error copying file ${sourceFile.name}: ${e.message}") + } + } + + private fun copyAssetFile(assetPath: String, targetFile: File) { + try { + val inputStream = context.assets.open(assetPath) + val outputStream = FileOutputStream(targetFile) + + inputStream.copyTo(outputStream) + inputStream.close() + outputStream.close() + + println("Copied: $assetPath -> ${targetFile.absolutePath}") + } catch (e: IOException) { + println("Error copying asset file $assetPath: ${e.message}") + } + } + fun checkSpriteFilesExist(): Boolean { val battleSpritesDir = getExternalSpriteBaseDir() val extractedAssetsDir = File(battleSpritesDir, "extracted_assets") @@ -32,4 +294,35 @@ class SpriteFileManager { return battleSpritesExist && assetsExist && statsExist && atkspritesExist && battlebgsExist } -} \ No newline at end of file + + 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 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 61cbee1..5e1c141 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,6 +5,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext @Composable fun SpriteImage( @@ -13,8 +14,9 @@ fun SpriteImage( modifier: Modifier = Modifier, contentScale: ContentScale = ContentScale.Fit ) { - val spriteManager = remember { IndividualSpriteManager() } - + val context = LocalContext.current + val spriteManager = remember { IndividualSpriteManager(context) } + var bitmap by remember { mutableStateOf(null) } LaunchedEffect(characterId, frameNumber) { 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 8114a8b..7167b97 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,7 +2,6 @@ package com.github.nacabaro.vbhelper.daos import androidx.room.Dao import androidx.room.Query -import androidx.room.RewriteQueriesToDropUnusedColumns import com.github.nacabaro.vbhelper.dtos.CharacterDtos import kotlinx.coroutines.flow.Flow @@ -20,7 +19,6 @@ interface AdventureDao { """) fun getAdventureCount(): Int - @RewriteQueriesToDropUnusedColumns @Query( """ SELECT diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/daos/CardDao.kt b/app/src/main/java/com/github/nacabaro/vbhelper/daos/CardDao.kt index 3fbca2a..b2d61e0 100644 --- a/app/src/main/java/com/github/nacabaro/vbhelper/daos/CardDao.kt +++ b/app/src/main/java/com/github/nacabaro/vbhelper/daos/CardDao.kt @@ -13,9 +13,6 @@ interface CardDao { @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insertNewCard(card: Card): Long - @Query("SELECT * FROM Card") - fun getAllCards(): List - @Query("SELECT * FROM Card WHERE cardId = :id") fun getCardByCardId(id: Int): List 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 4382448..86d6a9a 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,7 +2,6 @@ package com.github.nacabaro.vbhelper.daos import androidx.room.Dao import androidx.room.Query -import androidx.room.RewriteQueriesToDropUnusedColumns import com.github.cfogrady.vbnfc.data.NfcCharacter import com.github.nacabaro.vbhelper.dtos.CharacterDtos import kotlinx.coroutines.flow.Flow @@ -28,12 +27,12 @@ interface CardFusionsDao { toCharaId: Int ) - @RewriteQueriesToDropUnusedColumns @Query(""" SELECT cf.toCharaId as charaId, cf.fromCharaId as fromCharaId, s.spriteIdle1 as spriteIdle, + cc.attribute as attribute, s.width as spriteWidth, s.height as spriteHeight, d.discoveredOn as discoveredOn, 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 e8f0de4..378e92f 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,15 +13,9 @@ interface CharacterDao { @Insert suspend fun insertCharacter(vararg characterData: CardCharacter) - @Query("SELECT * FROM CardCharacter WHERE id = :id LIMIT 1") - fun getCharacterById(id: Long): CardCharacter? - @Query("SELECT * FROM CardCharacter WHERE charaIndex = :monIndex AND cardId = :dimId LIMIT 1") fun getCharacterByMonIndex(monIndex: Int, dimId: Long): CardCharacter - @Query("SELECT * FROM CardCharacter WHERE charaIndex = :monIndex AND cardId = :dimId LIMIT 1") - fun getCharacterByMonIndexOrNull(monIndex: Int, dimId: Long): CardCharacter? - @Insert suspend fun insertSprite(vararg sprite: Sprite) 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 62943a5..4d8d6e6 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,9 +11,6 @@ interface SpriteDao { @Insert(onConflict = OnConflictStrategy.REPLACE) fun insertSprite(sprite: Sprite): Long - @Query("SELECT * FROM Sprite WHERE id = :id LIMIT 1") - fun getSpriteById(id: Long): Sprite? - @Query("SELECT * FROM Sprite") suspend fun getAllSprites(): List } \ 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 b8bd138..284bca9 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,7 +4,6 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query -import androidx.room.RewriteQueriesToDropUnusedColumns import androidx.room.Upsert import com.github.nacabaro.vbhelper.domain.card.CardCharacter import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter @@ -39,7 +38,6 @@ interface UserCharacterDao { @Upsert fun insertSpecialMissions(vararg specialMissions: SpecialMissions) - @RewriteQueriesToDropUnusedColumns @Query( """ SELECT @@ -57,7 +55,6 @@ interface UserCharacterDao { ) fun getTransformationHistory(monId: Long): Flow> - @RewriteQueriesToDropUnusedColumns @Query( """ SELECT @@ -74,7 +71,6 @@ interface UserCharacterDao { ) suspend fun getTransformationHistoryForExport(monId: Long): List - @RewriteQueriesToDropUnusedColumns @Query( """ SELECT @@ -100,7 +96,6 @@ interface UserCharacterDao { ) fun getAllCharacters(): Flow> - @RewriteQueriesToDropUnusedColumns @Query( """ SELECT @@ -133,22 +128,12 @@ interface UserCharacterDao { @Query("SELECT * FROM BECharacterData WHERE id = :id") fun getBeData(id: Long): Flow - @Query("SELECT * FROM BECharacterData WHERE id = :id") - suspend fun getBeDataOrNull(id: Long): BECharacterData? - @Query("SELECT * FROM VBCharacterData WHERE id = :id") fun getVbData(id: Long): Flow - @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 @@ -214,7 +199,6 @@ interface UserCharacterDao { @Query("""SELECT * FROM VitalsHistory WHERE charId = :charId ORDER BY id ASC""") suspend fun getVitalsHistory(charId: Long): List - @RewriteQueriesToDropUnusedColumns @Query( """ SELECT @@ -241,7 +225,6 @@ 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 566f828..7aa784c 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 @@ -2,8 +2,6 @@ package com.github.nacabaro.vbhelper.database import androidx.room.Database import androidx.room.RoomDatabase -import androidx.room.migration.Migration -import androidx.sqlite.db.SupportSQLiteDatabase import com.github.nacabaro.vbhelper.daos.AdventureDao import com.github.nacabaro.vbhelper.daos.CardAdventureDao import com.github.nacabaro.vbhelper.daos.CharacterDao @@ -15,7 +13,6 @@ import com.github.nacabaro.vbhelper.daos.ItemDao import com.github.nacabaro.vbhelper.daos.SpecialMissionDao import com.github.nacabaro.vbhelper.daos.SpriteDao import com.github.nacabaro.vbhelper.daos.UserCharacterDao -import com.github.nacabaro.vbhelper.daos.VitalWearSettingsDao import com.github.nacabaro.vbhelper.domain.card.Background import com.github.nacabaro.vbhelper.domain.card.CardCharacter import com.github.nacabaro.vbhelper.domain.card.Card @@ -32,12 +29,10 @@ import com.github.nacabaro.vbhelper.domain.device_data.TransformationHistory import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData import com.github.nacabaro.vbhelper.domain.device_data.VitalsHistory -import com.github.nacabaro.vbhelper.domain.device_data.VitalWearCharacterSettings import com.github.nacabaro.vbhelper.domain.items.Items @Database( - version = 6, - exportSchema = false, + version = 1, entities = [ Card::class, CardProgress::class, @@ -55,8 +50,7 @@ import com.github.nacabaro.vbhelper.domain.items.Items Items::class, Adventure::class, Background::class, - PossibleTransformations::class, - VitalWearCharacterSettings::class, + PossibleTransformations::class ] ) abstract class AppDatabase : RoomDatabase() { @@ -71,92 +65,4 @@ abstract class AppDatabase : RoomDatabase() { abstract fun specialMissionDao(): SpecialMissionDao abstract fun cardAdventureDao(): CardAdventureDao abstract fun cardFusionsDao(): CardFusionsDao - abstract fun vitalWearSettingsDao(): VitalWearSettingsDao - - companion object { - val MIGRATION_1_2 = object : Migration(1, 2) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL( - """ - CREATE TABLE IF NOT EXISTS `VitalWearCharacterSettings` ( - `characterId` INTEGER NOT NULL, - `trainingInBackground` INTEGER NOT NULL, - `allowedBattles` INTEGER NOT NULL, - `accumulatedDailyInjuries` INTEGER NOT NULL, - PRIMARY KEY(`characterId`) - ) - """.trimIndent() - ) - - // Clean existing duplicates before adding the unique index. - db.execSQL( - """ - DELETE FROM `CardCharacter` - WHERE `id` NOT IN ( - SELECT MIN(`id`) FROM `CardCharacter` GROUP BY `cardId`, `charaIndex` - ) - """.trimIndent() - ) - db.execSQL( - """ - CREATE UNIQUE INDEX IF NOT EXISTS `index_CardCharacter_cardId_charaIndex` - ON `CardCharacter` (`cardId`, `charaIndex`) - """.trimIndent() - ) - } - } - - val MIGRATION_2_3 = object : Migration(2, 3) { - override fun migrate(db: SupportSQLiteDatabase) { - val backfillTimestamp = System.currentTimeMillis() - db.execSQL( - """ - INSERT INTO `TransformationHistory`(`monId`, `stageId`, `transformationDate`) - SELECT uc.`id`, uc.`charId`, $backfillTimestamp - FROM `UserCharacter` uc - WHERE NOT EXISTS ( - SELECT 1 - FROM `TransformationHistory` th - WHERE th.`monId` = uc.`id` - ) - """.trimIndent() - ) - } - } - - val MIGRATION_3_5 = object : Migration(3, 5) { - override fun migrate(db: SupportSQLiteDatabase) { - // TransferSeen moved to shared_transfer.db - db.execSQL("DROP TABLE IF EXISTS `TransferSeen`") - db.execSQL("DROP INDEX IF EXISTS `index_TransferSeen_cardLookupKey_slotId`") - } - } - - val MIGRATION_4_5 = object : Migration(4, 5) { - override fun migrate(db: SupportSQLiteDatabase) { - // TransferSeen moved to shared_transfer.db - db.execSQL("DROP TABLE IF EXISTS `TransferSeen`") - db.execSQL("DROP INDEX IF EXISTS `index_TransferSeen_cardLookupKey_slotId`") - } - } - - val MIGRATION_5_6 = object : Migration(5, 6) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("CREATE INDEX IF NOT EXISTS `index_CardCharacter_spriteId` ON `CardCharacter` (`spriteId`)") - db.execSQL("CREATE INDEX IF NOT EXISTS `index_CardAdventure_cardId` ON `CardAdventure` (`cardId`)") - db.execSQL("CREATE INDEX IF NOT EXISTS `index_CardAdventure_characterId` ON `CardAdventure` (`characterId`)") - db.execSQL("CREATE INDEX IF NOT EXISTS `index_CardFusions_fromCharaId` ON `CardFusions` (`fromCharaId`)") - db.execSQL("CREATE INDEX IF NOT EXISTS `index_CardFusions_toCharaId` ON `CardFusions` (`toCharaId`)") - db.execSQL("CREATE INDEX IF NOT EXISTS `index_Background_cardId` ON `Background` (`cardId`)") - db.execSQL("CREATE INDEX IF NOT EXISTS `index_PossibleTransformations_charaId` ON `PossibleTransformations` (`charaId`)") - db.execSQL("CREATE INDEX IF NOT EXISTS `index_PossibleTransformations_toCharaId` ON `PossibleTransformations` (`toCharaId`)") - db.execSQL("CREATE INDEX IF NOT EXISTS `index_UserCharacter_charId` ON `UserCharacter` (`charId`)") - db.execSQL("CREATE INDEX IF NOT EXISTS `index_SpecialMissions_characterId` ON `SpecialMissions` (`characterId`)") - db.execSQL("CREATE INDEX IF NOT EXISTS `index_TransformationHistory_monId` ON `TransformationHistory` (`monId`)") - db.execSQL("CREATE INDEX IF NOT EXISTS `index_TransformationHistory_stageId` ON `TransformationHistory` (`stageId`)") - db.execSQL("CREATE INDEX IF NOT EXISTS `index_VitalsHistory_charId` ON `VitalsHistory` (`charId`)") - } - } - - } } \ No newline at end of file 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 ff6d8fc..2d82b95 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 @@ -1,15 +1,11 @@ package com.github.nacabaro.vbhelper.di -import com.github.cfogrady.vitalwear.common.data.SharedTransferSeenDao import com.github.nacabaro.vbhelper.database.AppDatabase -import com.github.nacabaro.vbhelper.source.CardSettingsRepository import com.github.nacabaro.vbhelper.source.CurrencyRepository import com.github.nacabaro.vbhelper.source.DataStoreSecretsRepository interface AppContainer { val db: AppDatabase - val transferSeenDao: SharedTransferSeenDao val dataStoreSecretsRepository: DataStoreSecretsRepository val currencyRepository: CurrencyRepository - val cardSettingsRepository: CardSettingsRepository } \ 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 c949f86..70305b2 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 @@ -4,11 +4,8 @@ import androidx.datastore.dataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.preferencesDataStore import androidx.room.Room -import com.github.cfogrady.vitalwear.common.data.SharedDatabaseFactory -import com.github.cfogrady.vitalwear.common.data.SharedTransferSeenDao -import com.github.nacabaro.vbhelper.di.AppContainer import com.github.nacabaro.vbhelper.database.AppDatabase -import com.github.nacabaro.vbhelper.source.CardSettingsRepository +import com.github.nacabaro.vbhelper.di.AppContainer import com.github.nacabaro.vbhelper.source.CurrencyRepository import com.github.nacabaro.vbhelper.source.DataStoreSecretsRepository import com.github.nacabaro.vbhelper.source.SecretsSerializer @@ -34,22 +31,11 @@ class DefaultAppContainer(private val context: Context) : AppContainer { "internalDb" ) .createFromAsset("items.db") - .addMigrations(AppDatabase.MIGRATION_1_2) - .addMigrations(AppDatabase.MIGRATION_2_3) - .addMigrations(AppDatabase.MIGRATION_3_5) - .addMigrations(AppDatabase.MIGRATION_4_5) - .addMigrations(AppDatabase.MIGRATION_5_6) .build() } - override val transferSeenDao: SharedTransferSeenDao by lazy { - SharedDatabaseFactory.getDatabase(context).transferSeenDao() - } - override val dataStoreSecretsRepository = DataStoreSecretsRepository(context.secretsStore) override val currencyRepository = CurrencyRepository(context.currencyStore) - - override val cardSettingsRepository = CardSettingsRepository(context.currencyStore) } 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 b6e6c07..0c66c99 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,37 +2,12 @@ package com.github.nacabaro.vbhelper.di import DefaultAppContainer import android.app.Application -import androidx.room.Room -import com.github.nacabaro.vbhelper.companion.logging.TinyLogTree -import com.github.nacabaro.vbhelper.companion.logs.CompanionLogService -import com.github.nacabaro.vbhelper.companion.validation.CompanionValidatedDatabase -import com.github.nacabaro.vbhelper.companion.validation.ValidatedCardManager -import timber.log.Timber class VBHelper : Application() { lateinit var container: DefaultAppContainer - lateinit var validatedCardDatabase: CompanionValidatedDatabase - lateinit var validatedCardManager: ValidatedCardManager - val companionLogService = CompanionLogService() override fun onCreate() { super.onCreate() container = DefaultAppContainer(applicationContext) - - Timber.plant(Timber.DebugTree()) - Timber.plant(TinyLogTree(this)) - val originalExceptionHandler = Thread.getDefaultUncaughtExceptionHandler() - Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> - Timber.e(throwable, "VBHelper crashed on thread ${thread.name}") - TinyLogTree.shutdown() - originalExceptionHandler?.uncaughtException(thread, throwable) - } - - validatedCardDatabase = Room.databaseBuilder( - applicationContext, - CompanionValidatedDatabase::class.java, - "vbhelper_companion_tools" - ).build() - validatedCardManager = ValidatedCardManager(validatedCardDatabase.validatedCardDao()) } } \ 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 d49c49a..23766d4 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,11 +2,9 @@ package com.github.nacabaro.vbhelper.domain.card import androidx.room.Entity import androidx.room.ForeignKey -import androidx.room.Index import androidx.room.PrimaryKey @Entity( - indices = [Index(value = ["cardId"])], foreignKeys = [ ForeignKey( entity = Card::class, 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 5fe0e55..b8f4f56 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,14 +2,9 @@ package com.github.nacabaro.vbhelper.domain.card import androidx.room.Entity import androidx.room.ForeignKey -import androidx.room.Index import androidx.room.PrimaryKey @Entity( - indices = [ - Index(value = ["cardId"]), - Index(value = ["characterId"]) - ], foreignKeys = [ ForeignKey( entity = CardCharacter::class, 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 bfed547..050cc9a 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 @@ -2,16 +2,11 @@ package com.github.nacabaro.vbhelper.domain.card import androidx.room.Entity import androidx.room.ForeignKey -import androidx.room.Index import androidx.room.PrimaryKey import com.github.cfogrady.vbnfc.data.NfcCharacter import com.github.nacabaro.vbhelper.domain.characters.Sprite @Entity( - indices = [ - Index(value = ["cardId", "charaIndex"], unique = true), - Index(value = ["spriteId"]) - ], foreignKeys = [ ForeignKey( entity = Card::class, @@ -29,8 +24,9 @@ import com.github.nacabaro.vbhelper.domain.characters.Sprite ) /* - * Character represents a character on a specific card slot. - * Uniqueness is enforced per cardId + charaIndex. + * Character represents a character on a card. There should only be one of these per dimId + * and monIndex. + * TODO: Customs will mean this should be unique per cardName and monIndex */ data class CardCharacter ( @PrimaryKey(autoGenerate = true) val id: Long = 0, 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 ecc4e41..1769eda 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,15 +2,10 @@ package com.github.nacabaro.vbhelper.domain.card import androidx.room.Entity import androidx.room.ForeignKey -import androidx.room.Index import androidx.room.PrimaryKey import com.github.cfogrady.vbnfc.data.NfcCharacter @Entity( - indices = [ - Index(value = ["fromCharaId"]), - Index(value = ["toCharaId"]) - ], foreignKeys = [ ForeignKey( entity = CardCharacter::class, 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 5312070..2dc2e8b 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,14 +2,9 @@ package com.github.nacabaro.vbhelper.domain.card import androidx.room.Entity import androidx.room.ForeignKey -import androidx.room.Index import androidx.room.PrimaryKey @Entity( - indices = [ - Index(value = ["charaId"]), - Index(value = ["toCharaId"]) - ], foreignKeys = [ ForeignKey( entity = CardCharacter::class, 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 ef2e3dd..13b092d 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,12 +2,10 @@ package com.github.nacabaro.vbhelper.domain.device_data import androidx.room.Entity import androidx.room.ForeignKey -import androidx.room.Index import androidx.room.PrimaryKey import com.github.cfogrady.vbnfc.vb.SpecialMission @Entity( - indices = [Index(value = ["characterId"])], foreignKeys = [ ForeignKey( entity = UserCharacter::class, 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 436af2b..0a560cc 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,15 +2,10 @@ package com.github.nacabaro.vbhelper.domain.device_data import androidx.room.Entity import androidx.room.ForeignKey -import androidx.room.Index import androidx.room.PrimaryKey import com.github.nacabaro.vbhelper.domain.card.CardCharacter @Entity( - indices = [ - Index(value = ["monId"]), - Index(value = ["stageId"]) - ], foreignKeys = [ ForeignKey( entity = UserCharacter::class, 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 3a68aa4..e34b57d 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,14 +2,12 @@ package com.github.nacabaro.vbhelper.domain.device_data import androidx.room.Entity import androidx.room.ForeignKey -import androidx.room.Index import androidx.room.PrimaryKey import com.github.cfogrady.vbnfc.data.NfcCharacter import com.github.nacabaro.vbhelper.utils.DeviceType import com.github.nacabaro.vbhelper.domain.card.CardCharacter @Entity( - indices = [Index(value = ["charId"])], foreignKeys = [ ForeignKey( entity = CardCharacter::class, 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 163b44e..313e7c1 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,11 +2,9 @@ package com.github.nacabaro.vbhelper.domain.device_data import androidx.room.Entity import androidx.room.ForeignKey -import androidx.room.Index import androidx.room.PrimaryKey @Entity( - indices = [Index(value = ["charId"])], foreignKeys = [ ForeignKey( entity = UserCharacter::class, 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 22f43be..86b474d 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 @@ -6,7 +6,6 @@ import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -15,7 +14,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.navigation.compose.NavHost -import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import com.github.nacabaro.vbhelper.di.VBHelper @@ -60,21 +58,6 @@ fun AppNavigation( ) { val navController = rememberNavController() - LaunchedEffect(initialRoute) { - val route = initialRoute ?: return@LaunchedEffect - if (navController.currentBackStackEntry?.destination?.route == route) { - return@LaunchedEffect - } - navController.navigate(route) { - popUpTo(navController.graph.findStartDestination().id) { - inclusive = false - saveState = false - } - launchSingleTop = true - restoreState = false - } - } - Scaffold( bottomBar = { BottomNavigationBar(navController = navController) 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 0118833..16e6728 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,7 +7,6 @@ import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.ui.res.painterResource import androidx.navigation.NavController -import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.compose.currentBackStackEntryAsState import androidx.compose.ui.res.stringResource @@ -31,14 +30,17 @@ fun BottomNavigationBar(navController: NavController) { label = { Text(text = stringResource(item.label)) }, selected = currentRoute == item.route, onClick = { - navController.navigate(item.route) { - // Always route tab clicks to each tab's root screen. - popUpTo(navController.graph.findStartDestination().id) { - inclusive = false - saveState = false + if (item == NavigationItems.Home) { + navController.navigate(NavigationItems.Home.route) { + popUpTo(0) { inclusive = false } + launchSingleTop = true + } + } else { + navController.navigate(item.route) { + popUpTo(navController.graph.startDestinationId) { saveState = true } + launchSingleTop = true + restoreState = true } - launchSingleTop = true - restoreState = false } } ) 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 27af488..77b9b91 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, - @param:DrawableRes val icon: Int, - @param:StringRes val label: Int + @DrawableRes val icon: Int, + @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 179776b..dad71ac 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,7 +51,6 @@ import android.content.Intent import android.net.Uri import android.media.MediaPlayer import android.os.Environment -import android.widget.Toast //import androidx.compose.animation.core.animateFloatAsState //import androidx.compose.animation.core.tween import kotlinx.coroutines.delay @@ -65,8 +64,6 @@ import com.github.nacabaro.vbhelper.battle.APIBattleCharacter import android.util.Log import com.github.nacabaro.vbhelper.components.TopBanner import com.github.nacabaro.vbhelper.battle.RetrofitHelper -import com.github.nacabaro.vbhelper.battle.extractSessionToken -import com.github.nacabaro.vbhelper.battle.extractUserId import com.github.nacabaro.vbhelper.battle.AttackSpriteImage import com.github.nacabaro.vbhelper.battle.SpriteFileManager import com.github.nacabaro.vbhelper.battle.ArenaBattleSystem @@ -74,7 +71,6 @@ import com.github.nacabaro.vbhelper.battle.DigimonAnimationType import com.github.nacabaro.vbhelper.battle.AnimatedSpriteImage import com.github.nacabaro.vbhelper.battle.HitEffectOverlay import com.github.nacabaro.vbhelper.battle.BattleAuthContainer -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.collect import androidx.compose.foundation.lazy.LazyColumn @@ -903,6 +899,7 @@ fun MiddleBattleView( y = enemyVerticalOffset + 40.dp ), contentScale = ContentScale.Fit, + reloadMappings = false, animationOffset = 375L // Offset enemy animation by half the idle duration ) @@ -989,6 +986,7 @@ fun MiddleBattleView( y = playerVerticalOffset - 40.dp ), contentScale = ContentScale.Fit, + reloadMappings = false, animationOffset = 0L // Player animation starts immediately ) @@ -1074,30 +1072,19 @@ fun MiddleBattleView( // Capture userId for use in this lambda val playerUserId = userId - if (playerUserId == null) { - context?.let { - Toast.makeText(it, "Session missing user id. Please re-authenticate.", Toast.LENGTH_SHORT).show() - } - battleSystem.resetAttackState() - battleSystem.enableAttackButton() - return@Button - } - + // Get crit bar progress as float (0.0f to 100.0f) val critBarProgressFloat = battleSystem.critBarProgress.toFloat() // Determine player and opponent stages - // activeCharacter.stage is DB stage (2=rookie, 3=champion, 4=ultimate, 5=mega) - // API expects stage 0-3 val playerStage = when (activeCharacter?.stage) { - 2 -> 0 // rookie - 3 -> 1 // champion - 4 -> 2 // ultimate - 5 -> 3 // mega + 0 -> 0 // rookie + 1 -> 1 // champion + 2 -> 2 // ultimate + 3 -> 3 // mega else -> 0 } - // opponentCharacter.stage comes from the API and is already 0-3 val opponentStage = when (opponentCharacter?.stage) { 0 -> 0 // rookie 1 -> 1 // champion @@ -1114,11 +1101,11 @@ fun MiddleBattleView( RetrofitHelper().getPVPWinner( ctx, 1, - playerUserId, - activeCharacter?.charaId ?: "dim011_mon01", - playerStage, - battleSystem.critBarProgress, - opponentCharacter?.charaId ?: "dim011_mon01", + playerUserId ?: 2L, + activeCharacter?.name ?: "Player", + playerStage, + opponentStage, + opponentCharacter?.name ?: "Opponent", opponentStage ) { apiResult -> // Handle API response here @@ -1322,6 +1309,7 @@ fun PlayerBattleView( y = verticalOffset ), contentScale = ContentScale.Fit, + reloadMappings = false, animationOffset = 0L // Player animation starts immediately ) @@ -1503,6 +1491,7 @@ fun EnemyBattleView( y = verticalOffset ), contentScale = ContentScale.Fit, + reloadMappings = false, animationOffset = 375L // Offset enemy animation by half the idle duration ) @@ -1652,63 +1641,8 @@ fun BattlesScreen() { // Track processed tokens to prevent duplicate API calls // Use rememberSaveable to persist across configuration changes (screen rotation) var processedTokens by rememberSaveable { mutableStateOf>(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) } @@ -1790,86 +1724,40 @@ fun BattlesScreen() { // Determine if player can battle based on stage (derived from activeUserCharacter) val canBattle = activeUserCharacter?.stage?.let { it >= 2 } ?: false - val loadOpponentsIfReady: () -> Unit = loadOpponents@{ - val currentCharacter = activeUserCharacter - val readyToken = authReadyToken - + // Load opponents automatically based on player's stage + // Only load if authenticated and character is ready + LaunchedEffect(activeUserCharacter, isAuthenticated) { + // Wait for authentication to complete before loading opponents if (!isAuthenticated) { - if (opponentsList.isNotEmpty()) { - opponentsList = ArrayList() - } - return@loadOpponents + return@LaunchedEffect } - - if (readyToken.isNullOrBlank()) { - println("BATTLESCREEN: Skipping opponent load - auth token not persisted yet") - return@loadOpponents - } - - if (isLoadingOpponents) { - println("BATTLESCREEN: Opponents load already in flight, skipping duplicate request") - return@loadOpponents - } - - if (currentCharacter != null && currentCharacter.stage >= 2) { - val battleType = when (currentCharacter.stage) { - 2 -> "rookie" - 3 -> "champion" - 4 -> "ultimate" - 5 -> "mega" - else -> null - } - if (battleType == null) { - println("BATTLESCREEN: Stage ${currentCharacter.stage} has no battle type, skipping") - return@loadOpponents - } - 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() + 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}") } } - lifecycleOwner?.lifecycle?.addObserver(observer) - onDispose { - lifecycleOwner?.lifecycle?.removeObserver(observer) - } } // Helper lambda to extract token from URI and authenticate @@ -1887,19 +1775,17 @@ fun BattlesScreen() { if (!processedTokens.contains(token)) { // Mark token as being processed IMMEDIATELY to prevent duplicate API calls processedTokens = processedTokens + token - isProcessingAuthCallback = true - authBrowserLaunchInFlight = false - + // Exchange token with battle server RetrofitHelper().authenticate(context, token) { response -> if (response.success) { // Extract userId and sessionToken from response - val extractedUserId = response.extractUserId() - val sessionToken = response.extractSessionToken() - + val extractedUserId = response.userInfo?.userId?.toLongOrNull() + val sessionToken = response.sessionToken + println("BATTLESCREEN: Authentication successful, userId: $extractedUserId, sessionToken: ${if (sessionToken != null) "present" else "missing"}") - // Persist auth first so follow-up API calls can immediately use the token. + // Store both nacatech token (for re-auth) and sessionToken (for API calls) kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch { battleAuthContainer.authRepository.setAuthenticated( isAuthenticated = true, @@ -1907,16 +1793,14 @@ fun BattlesScreen() { sessionToken = sessionToken, userId = extractedUserId ) - - 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() - } + } + // 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() } } else { println("BATTLESCREEN: Authentication failed: ${response.message}") @@ -1931,16 +1815,23 @@ fun BattlesScreen() { kotlinx.coroutines.CoroutineScope(Dispatchers.Main).launch { isAuthenticated = false isCheckingAuth = false - isProcessingAuthCallback = false - authBrowserLaunchInFlight = false - println("BATTLESCREEN: Auth callback token was already used; avoiding automatic browser relaunch loop") - } + // Small delay to ensure state is updated + kotlinx.coroutines.delay(100) + // Open auth URL to get a fresh token + val authUrl = "http://auth.nacatech.es/begin?app=443654920&redirect_uri=vbhelper://auth?token=" + val authIntent = Intent(Intent.ACTION_VIEW, Uri.parse(authUrl)) + try { + context.startActivity(authIntent) + println("BATTLESCREEN: Opened auth URL after token expiration: $authUrl") + } catch (e: Exception) { + println("BATTLESCREEN: Failed to open auth URL: ${e.message}") + e.printStackTrace() + } + } } else { // For other errors, remove from processed set to allow retry with a new token println("BATTLESCREEN: Authentication failed, removing token from processed set to allow retry") processedTokens = processedTokens - token - isProcessingAuthCallback = false - authBrowserLaunchInFlight = false } // Show toast on main thread kotlinx.coroutines.CoroutineScope(Dispatchers.Main).launch { @@ -1948,47 +1839,12 @@ fun BattlesScreen() { } } } - } else { - println("BATTLESCREEN: Ignoring already processed auth token") } } else { println("BATTLESCREEN: No token found in URI: $uri (checked 'c' and 'token' parameters)") } } - - val consumeAuthCallbackIntent: () -> Boolean = consume@{ - val currentActivity = context as? ComponentActivity ?: return@consume false - val incomingIntent = currentActivity.intent ?: return@consume false - if (incomingIntent.action != Intent.ACTION_VIEW) return@consume false - - val uri = incomingIntent.data ?: return@consume false - val isAppAuthCallback = uri.scheme == "vbhelper" && uri.host == "auth" - val isLocalhostAuthCallback = - (uri.scheme == "http" || uri.scheme == "https") && - (uri.host == "localhost" || uri.host == "127.0.0.1") && - uri.path?.startsWith("/authenticate") == true - val hasAuthToken = !uri.getQueryParameter("c").isNullOrEmpty() || - !uri.getQueryParameter("token").isNullOrEmpty() - - if (!hasAuthToken) { - return@consume false - } - - if (!isAppAuthCallback && !isLocalhostAuthCallback) { - return@consume false - } - - handleTokenFromUri(uri) - - // Clear callback intent so reopening Battles does not replay stale deep links. - currentActivity.setIntent(Intent(incomingIntent).apply { - action = Intent.ACTION_MAIN - data = null - }) - - true - } - + // Check authentication status on load LaunchedEffect(Unit) { try { @@ -1996,8 +1852,7 @@ 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 @@ -2012,36 +1867,41 @@ fun BattlesScreen() { userId = storedUserId } - if (consumeAuthCallbackIntent()) { - return@LaunchedEffect + // Only check for token in intent if it's a fresh deep link (ACTION_VIEW intent) + // This prevents processing stale tokens from previous sessions + val activity = context as? ComponentActivity + val intent = activity?.intent + if (intent?.action == Intent.ACTION_VIEW) { + intent.data?.let { uri -> + if (uri.getQueryParameter("c") != null || uri.getQueryParameter("token") != null) { + handleTokenFromUri(uri) + return@LaunchedEffect // Don't open auth URL if we're processing a token + } + } } // If we have a stored token and userId, assume session is still active // The single-use token can't be re-validated, but the server maintains a session after initial validation // We'll only re-authenticate if API calls fail with authentication errors - if (localAuthState && storedToken != null && storedToken.isNotEmpty() && (storedUserId != null || !storedSessionToken.isNullOrEmpty())) { + if (localAuthState && storedToken != null && storedToken.isNotEmpty() && storedUserId != null) { // Session appears to be active - user is already authenticated // No need to re-validate the single-use token, just restore the session - println("BATTLESCREEN: Restoring active session (userId: $storedUserId, sessionTokenPresent=${!storedSessionToken.isNullOrEmpty()})") + println("BATTLESCREEN: Restoring active session (userId: $storedUserId)") isAuthenticated = true isCheckingAuth = false userId = storedUserId // Session is restored, no need to validate token again - } else if (localAuthState && storedToken != null && storedToken.isNotEmpty() && storedSessionToken.isNullOrEmpty()) { + } else if (localAuthState && storedToken != null && storedToken.isNotEmpty()) { // We have a token but no userId - try to validate once to get userId // This should only happen on first login or if userId was lost println("BATTLESCREEN: Have token but no userId, validating once to get userId...") - RetrofitHelper().authenticate( - context = context, - token = storedToken, - showUserFacingErrors = false, - ) { response -> + RetrofitHelper().authenticate(context, storedToken) { response -> // Update UI on main thread kotlinx.coroutines.CoroutineScope(Dispatchers.Main).launch { if (response.success) { - val extractedUserId = response.extractUserId() - val sessionToken = response.extractSessionToken() - + val extractedUserId = response.userInfo?.userId?.toLongOrNull() + val sessionToken = response.sessionToken + // Update stored userId and sessionToken if (extractedUserId != null) { kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch { @@ -2064,22 +1924,8 @@ fun BattlesScreen() { response.message?.contains("nonce") == true || response.message?.contains("invalid") == true || response.message?.contains("expired") == true - val latestSessionToken = withContext(Dispatchers.IO) { - authRepository.sessionToken.first() - } - val latestUserId = withContext(Dispatchers.IO) { - authRepository.userId.first() - } - + if (isCriticalError) { - // A parallel callback auth can complete before this fallback check returns. - if (!latestSessionToken.isNullOrEmpty() || latestUserId != null) { - println("BATTLESCREEN: Ignoring nonce validation failure because a valid session already exists") - isAuthenticated = true - isCheckingAuth = false - userId = latestUserId ?: userId - return@launch - } // Critical error - token is invalid, need to re-authenticate println("BATTLESCREEN: Critical authentication error, clearing state and redirecting") kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch { @@ -2087,7 +1933,11 @@ fun BattlesScreen() { } isAuthenticated = false isCheckingAuth = false - openBattleAuthPage() + // Open auth URL + val authUrl = "http://auth.nacatech.es/begin?app=443654920&redirect_uri=vbhelper://auth?token=" + val authIntent = Intent(Intent.ACTION_VIEW, Uri.parse(authUrl)) + context.startActivity(authIntent) + println("BATTLESCREEN: Opened auth URL after critical validation failure: $authUrl") } else { // Non-critical error (e.g., network issue) - keep authenticated state println("BATTLESCREEN: Non-critical validation error, keeping authenticated state") @@ -2102,7 +1952,10 @@ fun BattlesScreen() { isAuthenticated = false isCheckingAuth = false // If not authenticated and no fresh token in intent, open auth URL - openBattleAuthPage() + val authUrl = "http://auth.nacatech.es/begin?app=443654920&redirect_uri=vbhelper://auth?token=" + val authIntent = Intent(Intent.ACTION_VIEW, Uri.parse(authUrl)) + context.startActivity(authIntent) + println("BATTLESCREEN: Opened auth URL: $authUrl") } } catch (e: Exception) { println("BATTLESCREEN: Error checking authentication status: ${e.message}") @@ -2111,11 +1964,42 @@ fun BattlesScreen() { } } - // Handle deep link callback once on initial load and consume it. + // Handle deep link callback to get token + // Check intent data on initial load - handle both vbhelper:// and http://localhost:8080/authenticate?c= + // Only process if it's a fresh ACTION_VIEW intent (deep link) LaunchedEffect(Unit) { // Small delay to ensure activity is fully initialized kotlinx.coroutines.delay(100) - consumeAuthCallbackIntent() + + val activity = context as? ComponentActivity + val intent = activity?.intent + + // Only process if this is a fresh deep link (ACTION_VIEW) + if (intent?.action == Intent.ACTION_VIEW) { + val uri = intent.data + if (uri != null) { + + // Handle vbhelper://auth?token= or vbhelper://auth?c= deep link + if (uri.scheme == "vbhelper" && uri.host == "auth") { + handleTokenFromUri(uri) + } + // Handle http://localhost:8080/authenticate?c= redirect + else if ((uri.scheme == "http" || uri.scheme == "https") && + (uri.host == "localhost" || uri.host == "127.0.0.1" || uri.host?.contains("8080") == true)) { + handleTokenFromUri(uri) + } + // Also check if there's a 'c' or 'token' parameter in any URL + else if (uri.getQueryParameter("c") != null || uri.getQueryParameter("token") != null) { + handleTokenFromUri(uri) + } else { + println("BATTLESCREEN: URI found but no token parameter detected") + } + } else { + println("BATTLESCREEN: ACTION_VIEW intent but no URI found") + } + } else { + println("BATTLESCREEN: Not an ACTION_VIEW intent, skipping deep link processing") + } } // Check intent when screen becomes visible or when authentication state changes @@ -2125,10 +2009,15 @@ fun BattlesScreen() { val lifecycleOwner = activity as? LifecycleOwner val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - authBrowserLaunchInFlight = false - if (!isAuthenticated) { - consumeAuthCallbackIntent() + if (event == Lifecycle.Event.ON_RESUME && !isAuthenticated) { + // Check intent data when activity resumes - only if it's a fresh ACTION_VIEW intent + val intent = activity?.intent + if (intent?.action == Intent.ACTION_VIEW) { + intent.data?.let { uri -> + if (uri.getQueryParameter("c") != null || uri.getQueryParameter("token") != null) { + handleTokenFromUri(uri) + } + } } } } @@ -2149,16 +2038,45 @@ fun BattlesScreen() { isAuthenticated = false isCheckingAuth = false // Open auth URL to get a fresh token - openBattleAuthPage() + val authUrl = "http://auth.nacatech.es/begin?app=443654920&redirect_uri=vbhelper://auth?token=" + val authIntent = Intent(Intent.ACTION_VIEW, Uri.parse(authUrl)) + try { + context.startActivity(authIntent) + println("BATTLESCREEN: Opened auth URL after token expiration: $authUrl") + } catch (e: Exception) { + println("BATTLESCREEN: Failed to open auth URL: ${e.message}") + e.printStackTrace() + } } } } - // Also check for callback when auth state changes. + // Also check intent when authentication state changes + // Only process if it's a fresh ACTION_VIEW intent (deep link) LaunchedEffect(isAuthenticated) { if (!isAuthenticated) { kotlinx.coroutines.delay(200) // Small delay to ensure intent is available - consumeAuthCallbackIntent() + val activity = context as? ComponentActivity + val intent = activity?.intent + // Only process if this is a fresh deep link (ACTION_VIEW) + if (intent?.action == Intent.ACTION_VIEW) { + intent.data?.let { uri -> + println("BATTLESCREEN: Re-checking ACTION_VIEW intent data - URI: $uri, scheme: ${uri.scheme}, host: ${uri.host}") + // Handle vbhelper://auth?token= or vbhelper://auth?c= deep link + if (uri.scheme == "vbhelper" && uri.host == "auth") { + handleTokenFromUri(uri) + } + // Handle http://localhost:8080/authenticate?c= redirect + else if ((uri.scheme == "http" || uri.scheme == "https") && + (uri.host == "localhost" || uri.host == "127.0.0.1" || uri.host?.contains("8080") == true)) { + handleTokenFromUri(uri) + } + // Also check if there's a 'c' or 'token' parameter in any URL + else if (uri.getQueryParameter("c") != null || uri.getQueryParameter("token") != null) { + handleTokenFromUri(uri) + } + } + } } } @@ -2166,8 +2084,9 @@ fun BattlesScreen() { // Only check if permission is granted LaunchedEffect(hasStoragePermission) { if (hasStoragePermission) { - val spriteFileManager = SpriteFileManager() - if (!spriteFileManager.checkSpriteFilesExist()) { + val spriteFileManager = SpriteFileManager(context) + if (spriteFileManager.checkSpriteFilesExist()) { + } else { println("BATTLESCREEN: Sprite files not found in external storage") } } else { @@ -2314,10 +2233,6 @@ fun BattlesScreen() { color = Color.Gray, textAlign = TextAlign.Center ) - Spacer(modifier = Modifier.height(16.dp)) - Button(onClick = { openBattleAuthPage() }) { - Text("Open Login") - } } } } @@ -2352,12 +2267,6 @@ fun BattlesScreen() { items(opponentsList) { opponent -> Button( onClick = { - val currentUserId = userId - if (currentUserId == null) { - Toast.makeText(context, "Session missing user id. Please re-authenticate.", Toast.LENGTH_SHORT).show() - return@Button - } - activeCardId?.let { cardId -> selectedOpponent = opponent // Randomly select background set (0, 1, or 2) @@ -2372,7 +2281,7 @@ fun BattlesScreen() { else -> 0 } - RetrofitHelper().getPVPWinner(context, 0, currentUserId, cardId, apiStage, 0, opponent.charaId, apiStage) { apiResult -> + RetrofitHelper().getPVPWinner(context, 0, userId ?: 2L, cardId, apiStage, 0, opponent.charaId, apiStage) { apiResult -> // Check if there's an existing match when { apiResult.status.contains("Existing match found", ignoreCase = true) -> { @@ -2473,17 +2382,14 @@ fun BattlesScreen() { LaunchedEffect(Unit) { // Determine player and opponent stages - // activeCharacter.stage is DB stage (2=rookie, 3=champion, 4=ultimate, 5=mega) - // API expects stage 0-3 val playerStage = when (activeCharacter?.stage) { - 2 -> 0 // rookie - 3 -> 1 // champion - 4 -> 2 // ultimate - 5 -> 3 // mega + 0 -> 0 // rookie + 1 -> 1 // champion + 2 -> 2 // ultimate + 3 -> 3 // mega else -> 0 } - // selectedOpponent.stage comes from the API and is already 0-3 val opponentStage = when (selectedOpponent?.stage) { 0 -> 0 // rookie 1 -> 1 // champion @@ -2496,16 +2402,11 @@ fun BattlesScreen() { RetrofitHelper().getPVPWinner( context, 1, - userId ?: run { - Toast.makeText(context, "Session missing user id. Please re-authenticate.", Toast.LENGTH_SHORT).show() - isWinnerLoaded = true - winnerName = "Unknown" - return@LaunchedEffect - }, - activeCharacter?.charaId ?: "dim011_mon01", - playerStage, - 0, - selectedOpponent?.charaId ?: "dim011_mon01", + userId ?: 2L, + activeCharacter?.name ?: "Player", + playerStage, + opponentStage, + selectedOpponent?.name ?: "Opponent", opponentStage ) { apiResult -> // Winner might be empty in first call, but we can check HP values @@ -2514,7 +2415,7 @@ fun BattlesScreen() { // Also check winner field if it's not empty val playerWonFromWinner = activeCardId?.let { cardId -> - val winner = apiResult.winner + val winner = apiResult.winner ?: "" if (winner.isNotEmpty()) { if (winner.contains("|")) { // Pipe-separated format: first part is the winner @@ -2541,21 +2442,18 @@ fun BattlesScreen() { println("BATTLESCREEN: Battle result (first call) - winner: '${apiResult.winner}', playerHP: ${apiResult.playerHP}, opponentHP: ${apiResult.opponentHP}, playerWonFromHP: $playerWonFromHP, playerWonFromWinner: $playerWonFromWinner, final playerWon: $playerWon") // Store winner name for display (will be updated in cleanup call if available) - winnerName = if (apiResult.winner.isNotEmpty()) apiResult.winner else if (playerWon) "You" else "Opponent" + winnerName = apiResult.winner ?: if (playerWon) "You" else "Opponent" isWinnerLoaded = true // Then send the cleanup call - this will have the actual winner name RetrofitHelper().getPVPWinner( context, 2, - userId ?: run { - Toast.makeText(context, "Session missing user id. Please re-authenticate.", Toast.LENGTH_SHORT).show() - return@getPVPWinner - }, - activeCharacter?.charaId ?: "dim011_mon01", + userId ?: 2L, + activeCharacter?.name ?: "Player", playerStage, - 0, - selectedOpponent?.charaId ?: "dim011_mon01", + opponentStage, + selectedOpponent?.name ?: "Opponent", opponentStage ) { cleanupResult -> // Update winner name from cleanup call if available @@ -2567,7 +2465,7 @@ fun BattlesScreen() { // Primary method: Check HP values (opponentHP <= 0 means opponent lost = player won) // Secondary method: Check winner name (if winner doesn't match opponent, player won) val opponentName = selectedOpponent?.name ?: "" - val winner = cleanupResult.winner + val winner = cleanupResult.winner ?: "" // Primary: HP-based determination (most reliable) // If opponentHP <= 0, opponent is dead = player won @@ -2704,13 +2602,8 @@ fun BattlesScreen() { RetrofitHelper().getPVPWinner( context, 0, - userId ?: run { - Toast.makeText(context, "Session missing user id. Please re-authenticate.", Toast.LENGTH_SHORT).show() - showResumeDialog = false - existingMatchState = null - return@let - }, - cardId, + userId ?: 2L, + cardId, apiStage, 0, clickedOpponent.charaId, @@ -2834,13 +2727,8 @@ fun BattlesScreen() { RetrofitHelper().getPVPWinner( context, 0, - userId ?: run { - Toast.makeText(context, "Session missing user id. Please re-authenticate.", Toast.LENGTH_SHORT).show() - showResumeDialog = false - existingMatchState = null - return@let - }, - cardId, + userId ?: 2L, + 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 a970896..37551e8 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,12 +33,16 @@ import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData import com.github.nacabaro.vbhelper.dtos.ItemDtos import com.github.nacabaro.vbhelper.navigation.NavigationItems +import com.github.nacabaro.vbhelper.screens.homeScreens.screens.BEBEmHomeScreen +import com.github.nacabaro.vbhelper.screens.homeScreens.screens.BEDiMHomeScreen import com.github.nacabaro.vbhelper.screens.homeScreens.screens.VBDiMHomeScreen import com.github.nacabaro.vbhelper.screens.itemsScreen.ObtainedItemDialog import com.github.nacabaro.vbhelper.source.StorageRepository import com.github.nacabaro.vbhelper.R import com.github.nacabaro.vbhelper.dtos.CardDtos import com.github.nacabaro.vbhelper.source.CardRepository +import com.github.nacabaro.vbhelper.source.VitalWearCharacterExporter +import android.widget.Toast import com.github.nacabaro.vbhelper.utils.BitmapData import kotlinx.coroutines.flow.flowOf import kotlin.collections.emptyList @@ -72,8 +76,9 @@ fun HomeScreen( ?: flowOf(emptyList()) ).collectAsState(initial = emptyList()) - val specialMissions by ( + val vbSpecialMissions by ( activeMon + ?.takeIf { it.characterType == DeviceType.VBDevice } ?.let { chara -> storageRepository.getSpecialMissions(chara.id) } @@ -141,20 +146,29 @@ fun HomeScreen( height = cardIconData!!.cardIconHeight ) - val activeCharacter = activeMon!! - val displaySpecialMissions = specialMissions - - if (activeCharacter.characterType == DeviceType.BEDevice || vbData != null) { - VBDiMHomeScreen( - activeMon = activeCharacter, - vbData = vbData ?: VBCharacterData( - id = activeCharacter.id, - generation = 0, - totalTrophies = activeCharacter.trophies, - ), + if (activeMon!!.isBemCard && beData != null) { + BEBEmHomeScreen( + activeMon = activeMon!!, + beData = beData!!, transformationHistory = transformationHistory, contentPadding = PaddingValues(0.dp), - specialMissions = displaySpecialMissions, + cardIcon = cardIcon + ) + } else if (!activeMon!!.isBemCard && activeMon!!.characterType == DeviceType.BEDevice && beData != null) { + BEDiMHomeScreen( + activeMon = activeMon!!, + beData = beData!!, + transformationHistory = transformationHistory, + contentPadding = PaddingValues(0.dp), + cardIcon = cardIcon + ) + } else if (vbData != null) { + VBDiMHomeScreen( + activeMon = activeMon!!, + vbData = vbData!!, + transformationHistory = transformationHistory, + contentPadding = PaddingValues(0.dp), + specialMissions = vbSpecialMissions, homeScreenController = homeScreenController, onClickCollect = { item, currency -> collectedItem = item @@ -164,6 +178,27 @@ fun HomeScreen( ) } + Button( + onClick = { + try { + val intent = VitalWearCharacterExporter(application, application.container.db) + .buildShareIntent(activeMon!!.id) + .addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) + application.startActivity(intent) + } catch (e: Exception) { + Toast.makeText( + application, + "Could not send character to VitalWear: ${e.message}", + Toast.LENGTH_LONG + ).show() + } + }, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text(text = "Send to VitalWear") + } } } } @@ -213,5 +248,3 @@ fun HomeScreen( } } } - - 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 1aafa84..e08effa 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,17 +8,12 @@ import androidx.compose.foundation.layout.size import androidx.compose.material3.Card import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.dp import com.github.nacabaro.vbhelper.dtos.ItemDtos -import com.github.nacabaro.vbhelper.domain.items.ItemType import com.github.nacabaro.vbhelper.R @Composable @@ -33,12 +28,6 @@ fun ItemElement( .aspectRatio(1f) ) { Box(modifier = Modifier.fillMaxSize()) { - ItemCompatibilityBadge( - itemType = item.itemType, - modifier = Modifier - .align(Alignment.TopEnd) - .padding(8.dp) - ) Icon( painter = painterResource(id = getIconResource(item.itemIcon)), contentDescription = null, @@ -59,48 +48,3 @@ fun ItemElement( } } } - -@Composable -private fun ItemCompatibilityBadge( - itemType: ItemType, - modifier: Modifier = Modifier, -) { - val (label, containerColor, contentColor) = when (itemType) { - ItemType.VBITEM -> Triple( - "VB", - MaterialTheme.colorScheme.primaryContainer, - MaterialTheme.colorScheme.onPrimaryContainer, - ) - ItemType.BEITEM -> Triple( - "BE", - MaterialTheme.colorScheme.tertiaryContainer, - MaterialTheme.colorScheme.onTertiaryContainer, - ) - ItemType.UNIVERSAL -> Triple( - "VB+BE", - MaterialTheme.colorScheme.secondaryContainer, - MaterialTheme.colorScheme.onSecondaryContainer, - ) - ItemType.SPECIALMISSION -> Triple( - "MISSION", - MaterialTheme.colorScheme.errorContainer, - MaterialTheme.colorScheme.onErrorContainer, - ) - } - - Surface( - modifier = modifier, - shape = RoundedCornerShape(12.dp), - color = containerColor, - tonalElevation = 2.dp, - shadowElevation = 1.dp, - ) { - Text( - text = label, - color = contentColor, - fontSize = 10.sp, - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), - ) - } -} - 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 c233134..63ca4c5 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.PrimaryTabRow +import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -34,7 +34,7 @@ fun ItemsScreen( topBar = { Column { TopBanner(text = stringResource(R.string.items_title)) - PrimaryTabRow( + TabRow( selectedTabIndex = selectedTabItem, modifier = Modifier ) { 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 45847f3..eb3f477 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,6 +7,7 @@ import com.github.nacabaro.vbhelper.domain.device_data.SpecialMissions import com.github.nacabaro.vbhelper.database.AppDatabase import com.github.nacabaro.vbhelper.di.VBHelper import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData +import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData import com.github.nacabaro.vbhelper.dtos.ItemDtos import com.github.nacabaro.vbhelper.utils.DeviceType import kotlinx.coroutines.Dispatchers @@ -50,12 +51,19 @@ class ItemsScreenControllerImpl ( val item = getItem(itemId) val characterData = database.userCharacterDao().getCharacter(characterId) var beCharacterData: BECharacterData? = null + var vbCharacterData: VBCharacterData? = null if (characterData.characterType == DeviceType.BEDevice) { beCharacterData = database .userCharacterDao() .getBeData(characterId) .firstOrNull() + + } else if (characterData.characterType == DeviceType.VBDevice) { + vbCharacterData = database + .userCharacterDao() + .getVbData(characterId) + .firstOrNull() } if ( @@ -114,7 +122,8 @@ class ItemsScreenControllerImpl ( .updateCharacter(characterData) } else if (item.itemIcon in ItemTypes.Step8k.id .. ItemTypes.Win4.id && - (characterData.characterType == DeviceType.VBDevice || characterData.characterType == DeviceType.BEDevice) + characterData.characterType == DeviceType.VBDevice && + vbCharacterData != null ) { applySpecialMission(item.itemIcon, item.itemLength, characterId) } @@ -171,12 +180,12 @@ class ItemsScreenControllerImpl ( .getSpecialMissions(characterId) .first() - val existingMission = availableSpecialMissions.firstOrNull { it.watchId == specialMissionSlot } - val newSpecialMission = SpecialMissions( - id = existingMission?.id ?: 0, - characterId = characterId, + var newSpecialMission = availableSpecialMissions[specialMissionSlot] + newSpecialMission = SpecialMissions( + id = newSpecialMission.id, + characterId = newSpecialMission.characterId, goal = specialMissionGoal, - watchId = specialMissionSlot, + watchId = newSpecialMission.watchId, progress = 0, status = SpecialMission.Status.AVAILABLE, timeElapsedInMinutes = 0, 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 03de44f..d5fe878 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 = "VitalWear/VBH", + text = "Bandai Toys = Vital Bracelet, Vital Hero, BE Bracelet", 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 7ec6b52..72fc520 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 @@ -24,7 +24,6 @@ import com.github.nacabaro.vbhelper.source.isMissingSecrets import com.github.nacabaro.vbhelper.source.proto.Secrets import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.withContext import com.github.nacabaro.vbhelper.R @@ -132,8 +131,6 @@ fun ScanScreenPreview() { navController = rememberNavController(), scanScreenController = object: ScanScreenController { override val secretsFlow = MutableStateFlow(Secrets.getDefaultInstance()) - override val detectedTransportFlow: StateFlow = MutableStateFlow(DetectedTransport.UNKNOWN) - override val transferStatusFlow: StateFlow = MutableStateFlow(null) override fun unregisterActivityLifecycleListener(key: String) { } override fun registerActivityLifecycleListener( key: String, @@ -144,12 +141,7 @@ fun ScanScreenPreview() { override fun flushCharacter(cardId: Long) {} override fun onClickRead(secrets: Secrets, onComplete: ()->Unit, onMultipleCards: (List) -> Unit) {} override fun onClickCheckCard(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: () -> Unit) {} - override fun onClickWrite( - secrets: Secrets, - nfcCharacter: NfcCharacter, - characterId: Long?, - onComplete: (ScanScreenController.WriteResult) -> Unit - ) {} + override fun onClickWrite(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: () -> Unit) {} override fun cancelRead() {} override fun characterFromNfc(nfcCharacter: NfcCharacter, onMultipleCards: (List, 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 23d97e1..485fab8 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 @@ -5,27 +5,12 @@ import com.github.nacabaro.vbhelper.ActivityLifecycleListener import com.github.nacabaro.vbhelper.domain.card.Card import com.github.nacabaro.vbhelper.source.proto.Secrets import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.StateFlow interface ScanScreenController { val secretsFlow: Flow - val detectedTransportFlow: StateFlow - val transferStatusFlow: StateFlow - - enum class WriteResult { - MOVE_CONFIRMED, - COPIED, - BLOCKED_DEVICE_FULL, - } - fun onClickRead(secrets: Secrets, onComplete: ()->Unit, onMultipleCards: (List) -> Unit) fun onClickCheckCard(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: () -> Unit) - fun onClickWrite( - secrets: Secrets, - nfcCharacter: NfcCharacter, - characterId: Long? = null, - onComplete: (WriteResult) -> Unit - ) + fun onClickWrite(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: () -> Unit) fun cancelRead() 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 5f1de65..eb52382 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 @@ -3,7 +3,6 @@ package com.github.nacabaro.vbhelper.screens.scanScreen import android.content.Intent import android.nfc.NfcAdapter import android.nfc.Tag -import android.nfc.tech.IsoDep import android.nfc.tech.NfcA import android.os.Bundle import android.provider.Settings @@ -12,34 +11,21 @@ import android.widget.Toast import androidx.activity.ComponentActivity import androidx.lifecycle.lifecycleScope import com.github.cfogrady.vbnfc.TagCommunicator -import com.github.cfogrady.vitalwear.protos.Character import com.github.cfogrady.vbnfc.be.BENfcCharacter import com.github.cfogrady.vbnfc.data.NfcCharacter -import com.github.cfogrady.vbnfc.data.DeviceType as NfcDeviceTypeId import com.github.cfogrady.vbnfc.vb.VBNfcCharacter import com.github.nacabaro.vbhelper.ActivityLifecycleListener import com.github.nacabaro.vbhelper.domain.card.Card -import com.github.nacabaro.vbhelper.di.VBHelper import com.github.nacabaro.vbhelper.screens.scanScreen.converters.FromNfcConverter import com.github.nacabaro.vbhelper.screens.scanScreen.converters.ToNfcConverter -import com.github.nacabaro.vbhelper.source.VitalWearCharacterExporter -import com.github.nacabaro.vbhelper.source.VitalWearCharacterImporter import com.github.nacabaro.vbhelper.source.getCryptographicTransformerMap import com.github.nacabaro.vbhelper.source.isMissingSecrets import com.github.nacabaro.vbhelper.source.proto.Secrets -import com.github.nacabaro.vbhelper.transfer.hce.VitalWearHceReaderClient -import com.github.nacabaro.vbhelper.utils.DeviceType -import com.github.nacabaro.vbhelper.R import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import java.util.Arrays -import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.AtomicLong +import com.github.nacabaro.vbhelper.R class ScanScreenControllerImpl( override val secretsFlow: Flow, @@ -47,316 +33,49 @@ class ScanScreenControllerImpl( private val registerActivityLifecycleListener: (String, ActivityLifecycleListener)->Unit, private val unregisterActivityLifecycleListener: (String)->Unit, ): ScanScreenController { - - override val detectedTransportFlow: StateFlow - get() = _detectedTransportFlow - override val transferStatusFlow: StateFlow - get() = _transferStatusFlow - - private val _detectedTransportFlow = MutableStateFlow(DetectedTransport.UNKNOWN) - 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 - private var lastTagHandledAtMs: Long = 0L - private val tagStateLock = Any() - private val readerSessionCounter = AtomicLong(0L) - @Volatile private var activeReaderSessionId: Long = 0L - - companion object { - private const val TAG_DEBOUNCE_MS = 1500L - private const val TAG_IGNORE_AFTER_HANDLED_MS = 2000 - private val VITALWEAR_AID = byteArrayOf( - 0xF0.toByte(), 0x56, 0x49, 0x54, 0x41, 0x4C, 0x57, 0x45, 0x41, 0x52 - ) - private const val SW_OK = 0x9000 - // Lifecycle key for the always-on NFC suppressor that prevents Android from launching - // com.android.apps.tag/.TagViewer whenever the watch's HCE comes into range. - private const val LIFECYCLE_KEY_NFC_SUPPRESSOR = "nfc_suppressor" - } init { val maybeNfcAdapter = NfcAdapter.getDefaultAdapter(componentActivity) if (maybeNfcAdapter == null) { - Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_no_nfc_on_device), Toast.LENGTH_SHORT).show() + Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_no_nfc_on_device), Toast.LENGTH_SHORT).show() } nfcAdapter = maybeNfcAdapter checkSecrets() - registerNfcSuppressor() } - /** - * Registers a lifecycle listener that keeps reader mode active (with a no-op callback) - * whenever this Activity is in the foreground. This prevents Android's default NFC dispatch - * system from launching com.android.apps.tag/.TagViewer when the watch's HCE service is - * detected while the user hasn't explicitly pressed a transfer button yet. - * - * When the user presses Read/Write/CheckCard, [handleTag] replaces this suppressor with - * the real transfer callback via [NfcAdapter.enableReaderMode]. After the transfer - * completes, [enableNfcSuppressor] is called again to restore the passive suppressor. - */ - private fun registerNfcSuppressor() { - registerActivityLifecycleListener( - LIFECYCLE_KEY_NFC_SUPPRESSOR, - object : ActivityLifecycleListener { - override fun onResume() { - enableNfcSuppressor() + override fun onClickRead(secrets: Secrets, onComplete: ()->Unit, onMultipleCards: (List) -> Unit) { + handleTag(secrets) { tagCommunicator -> + try { + val character = tagCommunicator.receiveCharacter() + val resultMessage = characterFromNfc(character) { cards, nfcCharacter -> + lastScannedCharacter = nfcCharacter + onMultipleCards(cards) } - override fun onPause() { - disableReaderModeSafely() + onComplete.invoke() + resultMessage + } catch (e: Exception) { + Log.e("NFC_READ", "Error reading character from NFC", e) + componentActivity.runOnUiThread { + Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_error_generic) + ": " + (e.message ?: e.javaClass.simpleName), Toast.LENGTH_LONG).show() } + onComplete.invoke() + componentActivity.getString(R.string.scan_error_generic) } - ) - } - - /** - * Enables reader mode with a silent no-op callback. Calling [NfcAdapter.enableReaderMode] - * suppresses Android's default tag-dispatch system (and therefore the "new tag scanned" - * TagViewer screen) for as long as this Activity is in the foreground. The actual transfer - * logic is wired up separately via [handleTag] when the user presses a button. - */ - private fun enableNfcSuppressor() { - if (!nfcAdapter.isEnabled) return - val options = Bundle() - options.putInt(NfcAdapter.EXTRA_READER_PRESENCE_CHECK_DELAY, 250) - val flags = NfcAdapter.FLAG_READER_NFC_A or - NfcAdapter.FLAG_READER_NO_PLATFORM_SOUNDS or - NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK - runCatching { - nfcAdapter.enableReaderMode( - componentActivity, - { /* suppress default dispatch — user must tap a transfer button */ }, - flags, - options - ) - }.onFailure { - Log.w("NFC", "Failed to enable NFC suppressor reader mode", it) } } - // ---- Read (phone receives character FROM watch or bracelet) -------------------- - - override fun onClickRead(secrets: Secrets, onComplete: () -> Unit, onMultipleCards: (List) -> Unit) { - _detectedTransportFlow.value = DetectedTransport.UNKNOWN - setTransferStatus(R.string.scan_transfer_waiting_tap) - handleTag( - secrets, - nfcAHandler = { tagCommunicator -> - try { - val character = tagCommunicator.receiveCharacter() - val resultMessage = characterFromNfc(character) { cards, nfcCharacter -> - lastScannedCharacter = nfcCharacter - onMultipleCards(cards) - } - onComplete.invoke() - resultMessage - } catch (e: Exception) { - Log.e("NFC_READ", "Error reading character from NFC", e) - componentActivity.runOnUiThread { - Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_error_generic) + ": " + (e.message ?: e.javaClass.simpleName), Toast.LENGTH_LONG).show() - } - onComplete.invoke() - componentActivity.getString(R.string.scan_error_generic) - } - }, - isoDepHandler = { isoDep -> - val application = componentActivity.applicationContext as VBHelper - val importer = VitalWearCharacterImporter(application.container.db, application.container.transferSeenDao) - try { - // ISO-DEP/HCE route: VitalWear characters are always imported as BE device type. - var importResult: VitalWearCharacterImporter.ImportResult? = null - val moved = VitalWearHceReaderClient(isoDep).moveCharacterFromWatch { character -> - // Force imported device type to BE since source is VitalWear HCE (BE only). - val result = importer.importCharacter(character, forcedDeviceType = DeviceType.BEDevice) - importResult = result - result.success - } - onComplete.invoke() - if (moved) { - importResult?.message ?: componentActivity.getString(R.string.scan_sent_character_success) - } else { - importResult?.message - ?: "VitalWear import was rejected. Source character remains on the watch." - } - } catch (readError: Exception) { - Log.e("NFC_READ", "HCE read failed; watch may be armed as destination", readError) - onComplete.invoke() - "No source character detected on watch. If the watch is waiting to receive, use VBH to Watch." - } - } - ) - } - - // ---- Write (phone sends character TO watch or bracelet) ------------------------ - - override fun onClickWrite( - secrets: Secrets, - nfcCharacter: NfcCharacter, - characterId: Long?, - onComplete: (ScanScreenController.WriteResult) -> Unit - ) { - _detectedTransportFlow.value = DetectedTransport.UNKNOWN - setTransferStatus(R.string.scan_transfer_waiting_tap) - handleTag( - secrets, - nfcAHandler = { tagCommunicator -> - try { - val targetHeader = runCatching { tagCommunicator.readTargetHeader() }.getOrNull() - val normalizedDeviceType = when (targetHeader?.deviceTypeId) { - 512u.toUShort() -> NfcDeviceTypeId.VitalSeriesDeviceType - 768u.toUShort() -> NfcDeviceTypeId.VitalCharactersDeviceType - 1024u.toUShort() -> NfcDeviceTypeId.VitalBraceletBEDeviceType - else -> targetHeader?.deviceTypeId - } - val forcedProfile = when (normalizedDeviceType) { - NfcDeviceTypeId.VitalBraceletBEDeviceType -> DeviceType.BEDevice - else -> DeviceType.VBDevice - } - Log.i( - "NFC_WRITE_A", - "Target NFC-A header rawDeviceType=${targetHeader?.deviceTypeId}, normalizedDeviceType=$normalizedDeviceType, dimId=${targetHeader?.getDimId()}, forcedProfile=$forcedProfile" - ) - - // NFC-A route: choose export profile by target bracelet type. - // - Vital Series / Vital Characters => VB profile - // - Vital Bracelet BE => BE profile - val nfcACharacter = if (characterId != null) { - runBlocking { - ToNfcConverter(componentActivity = componentActivity) - .characterToNfc(characterId, forcedProfile) - } - } else { - nfcCharacter - } - - val initialSlotState = readNfcASlotState(tagCommunicator) - if (initialSlotState.isFull()) { - onComplete.invoke(ScanScreenController.WriteResult.BLOCKED_DEVICE_FULL) - return@handleTag componentActivity.getString(R.string.scan_target_device_full) - } - - val migrationCheck = verifyActiveToBackupMigration(tagCommunicator, initialSlotState) - if (!migrationCheck.canProceed) { - onComplete.invoke(ScanScreenController.WriteResult.COPIED) - return@handleTag migrationCheck.message - } - - // Send with VB profile (which was forced above). Real bracelets only accept VBNfcCharacter. - return@handleTag try { - when (nfcACharacter) { - is VBNfcCharacter -> tagCommunicator.sendCharacter(nfcACharacter) - is BENfcCharacter -> { - // This should not happen since we forced VBDevice above, but fail if it does. - Log.e("NFC_WRITE_A", "BENfcCharacter sent to NFC-A (real bracelet) — protocol error") - throw IllegalStateException("NFC-A forced VBDevice but received BENfcCharacter") - } - } - onComplete.invoke(ScanScreenController.WriteResult.MOVE_CONFIRMED) - componentActivity.getString(R.string.scan_sent_character_success) - } catch (deviceTypeMismatch: Exception) { - // Device type mismatch on NFC-A is terminal for this tap. - if (deviceTypeMismatch.message?.contains("Character doesn't match device type") == true) { - Log.e("NFC_WRITE_A", "Device type mismatch on NFC-A with selected transfer profile", deviceTypeMismatch) - onComplete.invoke(ScanScreenController.WriteResult.COPIED) - "Transfer failed: bracelet rejected character profile for this tap. Retry and keep bracelet steady." - } else { - Log.e("NFC_WRITE_A", "NFC-A write failed", deviceTypeMismatch) - onComplete.invoke(ScanScreenController.WriteResult.COPIED) - throw deviceTypeMismatch - } - } - } catch (writeError: Throwable) { - Log.e("NFC_WRITE_A", "NFC-A setup failed", writeError) - onComplete.invoke(ScanScreenController.WriteResult.COPIED) - throw writeError - } - }, - isoDepHandler = { isoDep -> - val characterId = lastRequestedCharacterId - ?: throw IllegalStateException("No character id available for VitalWear HCE write") - val application = componentActivity.applicationContext as VBHelper - val hceClient = VitalWearHceReaderClient(isoDep) - try { - // ISO-DEP/HCE route: VitalWear always uses BE profile (enforced). - // Create protobuf with forced BE device type for correct serialization. - val proto = runBlocking { - VitalWearCharacterExporter(application.container.db) - .buildCharacterProto(characterId, forcedTransferProfile = DeviceType.BEDevice) - } - if (proto.characterStats.deviceType != Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_BE) { - throw IllegalStateException("VitalWear HCE export must use BE transfer profile") - } - // Send with status confirmation: only mark MOVE_CONFIRMED after watch confirms import. - val confirmedMove = hceClient.sendCharacterToWatchAndConfirm(proto) - if (confirmedMove) { - onComplete.invoke(ScanScreenController.WriteResult.MOVE_CONFIRMED) - componentActivity.getString(R.string.scan_sent_character_success) - } else { - // Transfer acknowledging but watch import failed or did not confirm. - onComplete.invoke(ScanScreenController.WriteResult.COPIED) - "Transfer sent but watch did not confirm import. Source was kept in VBH." - } - } catch (writeError: Throwable) { - Log.e("NFC_WRITE_HCE", "HCE write failed", writeError) - onComplete.invoke(ScanScreenController.WriteResult.COPIED) - throw writeError - } - } - ) - } - - - - // ---- Check card (NFC-A only; ISO-DEP watches don't need a DIM prep) ----------- - - override fun onClickCheckCard(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: () -> Unit) { - _detectedTransportFlow.value = DetectedTransport.UNKNOWN - setTransferStatus(R.string.scan_transfer_waiting_tap) - handleTag( - secrets, - nfcAHandler = { tagCommunicator -> - tagCommunicator.prepareDIMForCharacter(nfcCharacter.dimId) - onComplete.invoke() - componentActivity.getString(R.string.scan_sent_dim_success) - }, - // HCE equivalent of the prep step: verify watch is armed for phone->watch transfer. - isoDepHandler = { isoDep -> - runCatching { - VitalWearHceReaderClient(isoDep).verifyWatchReadyToReceive() - }.fold( - onSuccess = { - onComplete.invoke() - "Watch ready. Tap again to send character." - }, - onFailure = { error -> - Log.e("NFC_HCE", "Watch is not ready to receive character", error) - "Watch is not ready. On watch: Transfer > Receive from VBH, then tap again." - } - ) - } - ) - } - - // ---- Cancel / lifecycle ------------------------------------------------------- - override fun cancelRead() { - _detectedTransportFlow.value = DetectedTransport.UNKNOWN - setTransferStatus(R.string.scan_transfer_cancelled) - activeReaderSessionId = readerSessionCounter.incrementAndGet() - isHandlingTag.set(false) - synchronized(tagStateLock) { - lastTagId = null - lastTagHandledAtMs = 0L + if(nfcAdapter.isEnabled) { + nfcAdapter.disableReaderMode(componentActivity) } - // Re-arm the suppressor so the TagViewer doesn't appear while the user is still - // on the scan screen but hasn't pressed a transfer button yet. - enableNfcSuppressor() } - override fun registerActivityLifecycleListener(key: String, activityLifecycleListener: ActivityLifecycleListener) { + override fun registerActivityLifecycleListener( + key: String, + activityLifecycleListener: ActivityLifecycleListener + ) { registerActivityLifecycleListener.invoke(key, activityLifecycleListener) } @@ -364,232 +83,43 @@ class ScanScreenControllerImpl( unregisterActivityLifecycleListener.invoke(key) } - // ---- NFC adapter wiring ------------------------------------------------------- - - /** - * Arms the NFC reader for both NFC-A (bracelet) and ISO-DEP (VitalWear HCE). - * [isoDepHandler] is optional; when null the reader only accepts NFC-A. - */ - private fun handleTag( - secrets: Secrets, - nfcAHandler: (TagCommunicator) -> String, - isoDepHandler: ((IsoDep) -> String)? = null - ) { + // EXTRACTED DIRECTLY FROM EXAMPLE APP + private fun handleTag(secrets: Secrets, handlerFunc: (TagCommunicator)->String) { if (!nfcAdapter.isEnabled) { showWirelessSettings() - return + } else { + val options = Bundle() + // Work around for some broken Nfc firmware implementations that poll the card too fast + options.putInt(NfcAdapter.EXTRA_READER_PRESENCE_CHECK_DELAY, 250) + nfcAdapter.enableReaderMode(componentActivity, buildOnReadTag(secrets, handlerFunc), NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK, + options + ) } - val options = Bundle() - // Work around for some broken Nfc firmware implementations that poll the card too fast - options.putInt(NfcAdapter.EXTRA_READER_PRESENCE_CHECK_DELAY, 250) - val flags = NfcAdapter.FLAG_READER_NFC_A or - NfcAdapter.FLAG_READER_NO_PLATFORM_SOUNDS or - NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK - val sessionId = readerSessionCounter.incrementAndGet() - activeReaderSessionId = sessionId - isHandlingTag.set(false) - synchronized(tagStateLock) { - lastTagId = null - lastTagHandledAtMs = 0L - } - disableReaderModeSafely() - nfcAdapter.enableReaderMode( - componentActivity, - buildOnReadTag(secrets, nfcAHandler, isoDepHandler, sessionId), - flags, - options - ) } - private fun buildOnReadTag( - secrets: Secrets, - nfcAHandler: (TagCommunicator) -> String, - isoDepHandler: ((IsoDep) -> String)?, - sessionId: Long, - ): (Tag) -> Unit { - return { tag -> - if (activeReaderSessionId == sessionId) { - val now = System.currentTimeMillis() - val shouldHandleTag = synchronized(tagStateLock) { - val sameRecentTag = lastTagId != null && tag.id != null && Arrays.equals(lastTagId, tag.id) && - (now - lastTagHandledAtMs) < TAG_DEBOUNCE_MS - if (sameRecentTag || !isHandlingTag.compareAndSet(false, true)) { - false - } else { - lastTagId = tag.id?.clone() - lastTagHandledAtMs = now - true - } - } - if (shouldHandleTag) { - setTransferStatus(R.string.scan_transfer_detected_keep_tap) - - // Detect transport once per tap and lock to that route for this transfer. - // Real Bandai bracelets route through NFC-A; VitalWear routes through ISO-DEP/HCE. - val isoDep = IsoDep.get(tag) - val hasNfcARoute = NfcA.get(tag) != null - val hasIsoDepHandler = isoDep != null && isoDepHandler != null - val confirmedVitalWear = if (hasIsoDepHandler) { - runCatching { isVitalWearHceTarget(isoDep) }.getOrDefault(false) - } else { - false - } - try { - if (hasIsoDepHandler && confirmedVitalWear) { - val isoDepTarget = isoDep - val isoDepAction = isoDepHandler - try { - _detectedTransportFlow.value = DetectedTransport.ISO_DEP - isoDepTarget.connect() - isoDepTarget.use { - setTransferStatus(R.string.scan_transfer_in_progress) - val successText = isoDepAction.invoke(isoDepTarget) - componentActivity.runOnUiThread { - Toast.makeText(componentActivity, successText, Toast.LENGTH_SHORT).show() - setTransferStatus(R.string.scan_transfer_complete_remove) - } - } - } catch (e: Throwable) { - Log.e("NFC_HCE", "IsoDep transfer failed", e) - componentActivity.runOnUiThread { - Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_error_generic), Toast.LENGTH_SHORT).show() - setTransferStatus(R.string.scan_transfer_failed_try_again) - } - } - } else if (hasIsoDepHandler && !confirmedVitalWear && !hasNfcARoute) { - // Hard-fail only when IsoDep is present without any NFC-A fallback. - // This keeps HCE safety while allowing real bracelets to route via NFC-A. - Log.w("NFC_ROUTE", "IsoDep present but VitalWear AID not confirmed and no NFC-A route; cancelling transfer") - _detectedTransportFlow.value = DetectedTransport.UNKNOWN - componentActivity.runOnUiThread { - Toast.makeText(componentActivity, "VitalWear HCE not detected. Transfer cancelled.", Toast.LENGTH_SHORT).show() - setTransferStatus(R.string.scan_transfer_failed_try_again) - } - } else if (hasNfcARoute) { - if (hasIsoDepHandler && !confirmedVitalWear) { - Log.i("NFC_ROUTE", "IsoDep present but VitalWear AID not confirmed; routing to NFC-A fallback") - } - if (!handleNfcATag(tag, secrets, nfcAHandler)) { - componentActivity.runOnUiThread { - Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_tag_not_vb), Toast.LENGTH_SHORT).show() - setTransferStatus(R.string.scan_transfer_failed_try_again) - } - } - } else { - if (hasIsoDepHandler && !confirmedVitalWear) { - Log.w("NFC_ROUTE", "IsoDep tag does not expose VitalWear HCE AID and has no NFC-A fallback") - } - _detectedTransportFlow.value = DetectedTransport.UNKNOWN - componentActivity.runOnUiThread { - Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_tag_not_vb), Toast.LENGTH_SHORT).show() - setTransferStatus(R.string.scan_transfer_failed_try_again) - } - } - } finally { - finishHandledTagSession(tag, sessionId) - } + // EXTRACTED DIRECTLY FROM EXAMPLE APP + private fun buildOnReadTag(secrets: Secrets, handlerFunc: (TagCommunicator)->String): (Tag)->Unit { + return { tag-> + val nfcData = NfcA.get(tag) + if (nfcData == null) { + componentActivity.runOnUiThread { + Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_tag_not_vb), Toast.LENGTH_SHORT).show() } } - } - } - - private fun finishHandledTagSession(tag: Tag, sessionId: Long) { - if (activeReaderSessionId != sessionId) return - - // Keep Android from redispatching this same tag to other NFC apps while still in range. - runCatching { - nfcAdapter.ignore( - tag, - TAG_IGNORE_AFTER_HANDLED_MS, - NfcAdapter.OnTagRemovedListener { }, - null - ) - }.onFailure { - Log.w("NFC", "Failed to ignore handled tag", it) - } - - // Restore the passive suppressor so devices can remain close without Android - // launching the TagViewer between transfers or while waiting for the next button press. - enableNfcSuppressor() - isHandlingTag.set(false) - } - - private fun handleNfcATag( - tag: Tag, - secrets: Secrets, - nfcAHandler: (TagCommunicator) -> String, - ): Boolean { - val nfcData = NfcA.get(tag) ?: return false - _detectedTransportFlow.value = DetectedTransport.NFC_A - return try { nfcData.connect() nfcData.use { val tagCommunicator = TagCommunicator.getInstance(nfcData, secrets.getCryptographicTransformerMap()) - setTransferStatus(R.string.scan_transfer_in_progress) - val successText = nfcAHandler(tagCommunicator) + val successText = handlerFunc(tagCommunicator) componentActivity.runOnUiThread { Toast.makeText(componentActivity, successText, Toast.LENGTH_SHORT).show() - setTransferStatus(R.string.scan_transfer_complete_remove) } } - true - } catch (e: Throwable) { - val staleTagSession = e is SecurityException && (e.message?.contains("out of date", ignoreCase = true) == true) - if (staleTagSession) { - Log.w("NFC_A", "Ignoring stale NFC-A tag session; waiting for a fresh tap", e) - componentActivity.runOnUiThread { - Toast.makeText(componentActivity, "Tag session expired. Please tap again.", Toast.LENGTH_SHORT).show() - setTransferStatus(R.string.scan_transfer_waiting_tap) - } - return false - } - Log.e("NFC_A", "NfcA transfer failed", e) - componentActivity.runOnUiThread { - Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_error_generic), Toast.LENGTH_SHORT).show() - setTransferStatus(R.string.scan_transfer_failed_try_again) - } - true } } - private fun setTransferStatus(messageRes: Int) { - _transferStatusFlow.value = componentActivity.getString(messageRes) - } - - private fun disableReaderModeSafely() { - if (!nfcAdapter.isEnabled) return - runCatching { nfcAdapter.disableReaderMode(componentActivity) } - } - - private fun isVitalWearHceTarget(isoDep: IsoDep): Boolean { - return runCatching { - val originalTimeout = isoDep.timeout - try { - isoDep.timeout = 500 - if (!isoDep.isConnected) { - isoDep.connect() - } - val selectApdu = byteArrayOf(0x00, 0xA4.toByte(), 0x04, 0x00, VITALWEAR_AID.size.toByte()) + VITALWEAR_AID - val response = isoDep.transceive(selectApdu) - statusWord(response) == SW_OK - } finally { - isoDep.timeout = originalTimeout - runCatching { if (isoDep.isConnected) isoDep.close() } - } - }.getOrDefault(false) - } - - private fun statusWord(response: ByteArray): Int { - if (response.size < 2) return -1 - return ((response[response.size - 2].toInt() and 0xFF) shl 8) or - (response[response.size - 1].toInt() and 0xFF) - } - - // ---- Misc --------------------------------------------------------------------- - private fun checkSecrets() { componentActivity.lifecycleScope.launch(Dispatchers.IO) { - if (secretsFlow.stateIn(componentActivity.lifecycleScope).value.isMissingSecrets()) { + if(secretsFlow.stateIn(componentActivity.lifecycleScope).value.isMissingSecrets()) { componentActivity.runOnUiThread { Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_missing_secrets), Toast.LENGTH_SHORT).show() } @@ -597,153 +127,74 @@ class ScanScreenControllerImpl( } } + override fun onClickWrite( + secrets: Secrets, + nfcCharacter: NfcCharacter, + onComplete: () -> Unit + ) { + handleTag(secrets) { tagCommunicator -> + try { + if (nfcCharacter is VBNfcCharacter) { + Log.d("SendCharacter", "VBNfcCharacter") + val castNfcCharacter: VBNfcCharacter = nfcCharacter + tagCommunicator.sendCharacter(castNfcCharacter) + } else if (nfcCharacter is BENfcCharacter) { + Log.d("SendCharacter", "BENfcCharacter") + val castNfcCharacter: BENfcCharacter = nfcCharacter + tagCommunicator.sendCharacter(castNfcCharacter) + } + onComplete.invoke() + componentActivity.getString(R.string.scan_sent_character_success) + } catch (e: Throwable) { + Log.e("TAG", e.stackTraceToString()) + componentActivity.getString(R.string.scan_error_generic) + } + } + } + + override fun onClickCheckCard( + secrets: Secrets, + nfcCharacter: NfcCharacter, + onComplete: () -> Unit + ) { + handleTag(secrets) { tagCommunicator -> + tagCommunicator.prepareDIMForCharacter(nfcCharacter.dimId) + onComplete.invoke() + componentActivity.getString(R.string.scan_sent_dim_success) + } + } + + // EXTRACTED DIRECTLY FROM EXAMPLE APP private fun showWirelessSettings() { - Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_nfc_must_be_enabled), Toast.LENGTH_SHORT).show() + Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_nfc_must_be_enabled), Toast.LENGTH_SHORT).show() componentActivity.startActivity(Intent(Settings.ACTION_WIRELESS_SETTINGS)) } - /** - * Best-effort, non-destructive slot-capacity check for NFC-A devices. - */ - private fun readNfcASlotState(tagCommunicator: TagCommunicator): NfcASlotState { - val communicatorClass = tagCommunicator.javaClass - - val countMethod = communicatorClass.methods.firstOrNull { - it.parameterCount == 0 && - Number::class.java.isAssignableFrom(it.returnType) && - it.name.contains("count", ignoreCase = true) && - it.name.contains("character", ignoreCase = true) - } - val count = if (countMethod != null) { - runCatching { (countMethod.invoke(tagCommunicator) as Number).toInt() }.getOrNull() - } else null - - val activeMethod = communicatorClass.methods.firstOrNull { - it.parameterCount == 0 && it.returnType == java.lang.Boolean.TYPE && - (it.name.contains("active", ignoreCase = true) || it.name.contains("current", ignoreCase = true)) && - it.name.contains("character", ignoreCase = true) - } - val backupMethod = communicatorClass.methods.firstOrNull { - it.parameterCount == 0 && it.returnType == java.lang.Boolean.TYPE && - it.name.contains("backup", ignoreCase = true) && - it.name.contains("character", ignoreCase = true) - } - - val activePresent = if (activeMethod != null) { - runCatching { activeMethod.invoke(tagCommunicator) as Boolean }.getOrNull() - } else null - val backupPresent = if (backupMethod != null) { - runCatching { backupMethod.invoke(tagCommunicator) as Boolean }.getOrNull() - } else null - - if (count == null && activePresent == null && backupPresent == null) { - Log.w("NFC_A", "Unable to introspect NFC-A slot occupancy; defaulting to non-blocking write path") - } - - return NfcASlotState(count = count, activePresent = activePresent, backupPresent = backupPresent) - } - - /** - * Attempts to mirror official toy behavior by moving the active character to backup. - * If the library already does this internally, this is a harmless no-op. - */ - private fun verifyActiveToBackupMigration( - tagCommunicator: TagCommunicator, - beforeState: NfcASlotState, - ): SlotMigrationCheck { - val migrationResult = moveActiveToBackupIfSupported(tagCommunicator) - if (migrationResult.attempted && !migrationResult.success) { - return SlotMigrationCheck( - canProceed = false, - message = "Transfer blocked. Could not move active character to backup safely." - ) - } - - // If we could not introspect occupancy at all, keep legacy behavior and proceed. - if (!beforeState.hasOccupancySignal()) { - return SlotMigrationCheck(canProceed = true, message = "") - } - - val afterState = readNfcASlotState(tagCommunicator) - val migrationVerified = afterState.backupPresent == true || - (beforeState.count != null && afterState.count != null && afterState.count >= beforeState.count) - if (!migrationVerified) { - // Some real bracelets do not expose stable occupancy signals through the current - // reflection-based probing. In that case, prefer compatibility over false blocks. - Log.w("NFC_A", "Could not verify active->backup migration from slot signals; proceeding with write") - return SlotMigrationCheck(canProceed = true, message = "") - } - - return SlotMigrationCheck(canProceed = true, message = "") - } - - private fun moveActiveToBackupIfSupported(tagCommunicator: TagCommunicator): MigrationInvokeResult { - val communicatorClass = tagCommunicator.javaClass - val candidate = communicatorClass.methods.firstOrNull { - it.parameterCount == 0 && - ( - (it.name.contains("move", ignoreCase = true) && it.name.contains("backup", ignoreCase = true)) || - (it.name.contains("shift", ignoreCase = true) && it.name.contains("backup", ignoreCase = true)) || - (it.name.contains("promote", ignoreCase = true) && it.name.contains("backup", ignoreCase = true)) - ) - } - if (candidate == null) { - return MigrationInvokeResult(attempted = false, success = true) - } - - return runCatching { candidate.invoke(tagCommunicator) } - .fold( - onSuccess = { MigrationInvokeResult(attempted = true, success = true) }, - onFailure = { - Log.w("NFC_A", "Failed to move active character to backup", it) - MigrationInvokeResult(attempted = true, success = false) - } - ) - } - - private data class NfcASlotState( - val count: Int?, - val activePresent: Boolean?, - val backupPresent: Boolean?, - ) { - fun hasOccupancySignal(): Boolean { - return count != null || activePresent != null || backupPresent != null - } - - fun isFull(): Boolean { - if (count != null) { - return count >= 2 - } - if (activePresent != null && backupPresent != null) { - return activePresent && backupPresent - } - return false - } - } - - private data class SlotMigrationCheck( - val canProceed: Boolean, - val message: String, - ) - - private data class MigrationInvokeResult( - val attempted: Boolean, - val success: Boolean, - ) - - override fun characterFromNfc(nfcCharacter: NfcCharacter, onMultipleCards: (List, NfcCharacter) -> Unit): String { - return FromNfcConverter(componentActivity = componentActivity).addCharacter(nfcCharacter, onMultipleCards) + override fun characterFromNfc( + nfcCharacter: NfcCharacter, + onMultipleCards: (List, NfcCharacter) -> Unit + ): String { + val nfcConverter = FromNfcConverter( + componentActivity = componentActivity + ) + return nfcConverter.addCharacter(nfcCharacter, onMultipleCards) } override suspend fun characterToNfc(characterId: Long): NfcCharacter { - lastRequestedCharacterId = characterId - val character = ToNfcConverter(componentActivity = componentActivity).characterToNfc(characterId) + val nfcGenerator = ToNfcConverter( + componentActivity = componentActivity + ) + + val character = nfcGenerator.characterToNfc(characterId) Log.d("CharacterType", character.toString()) return character } override fun flushCharacter(cardId: Long) { - val nfcConverter = FromNfcConverter(componentActivity = componentActivity) + val nfcConverter = FromNfcConverter( + componentActivity = componentActivity + ) + componentActivity.lifecycleScope.launch(Dispatchers.IO) { if (lastScannedCharacter != null) { nfcConverter.addCharacterUsingCard(lastScannedCharacter!!, cardId) 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 d23f6be..dcc85a3 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 @@ -3,7 +3,6 @@ package com.github.nacabaro.vbhelper.screens.scanScreen.converters import androidx.activity.ComponentActivity import com.github.cfogrady.vbnfc.be.BENfcCharacter import com.github.cfogrady.vbnfc.data.NfcCharacter -import com.github.cfogrady.vbnfc.vb.SpecialMission import com.github.cfogrady.vbnfc.vb.VBNfcCharacter import com.github.nacabaro.vbhelper.di.VBHelper import com.github.nacabaro.vbhelper.domain.card.Card @@ -20,8 +19,7 @@ class FromNfcConverter ( ) { private val application = componentActivity.applicationContext as VBHelper private val database = application.container.db - private val transferSeenDao = application.container.transferSeenDao - + fun addCharacterUsingCard( nfcCharacter: NfcCharacter, @@ -120,16 +118,11 @@ class FromNfcConverter ( .userCharacterDao() .insertCharacterData(characterData) - val seenTimestamp = System.currentTimeMillis() - markSeen(cardData, nfcCharacter.charIndex.toInt(), seenTimestamp) - if (nfcCharacter is BENfcCharacter) { addBeCharacterToDatabase( characterId = characterId, nfcCharacter = nfcCharacter ) - // Keep a VB profile alongside BE stats for later VB-target exports. - addVbCharacterProfileFromBe(characterId, nfcCharacter) } else if (nfcCharacter is VBNfcCharacter) { addVbCharacterToDatabase( characterId = characterId, @@ -236,8 +229,8 @@ class FromNfcConverter ( itemType = nfcCharacter.itemType.toInt(), itemMultiplier = nfcCharacter.itemMultiplier.toInt(), itemRemainingTime = nfcCharacter.itemRemainingTime.toInt(), - otp0 = nfcCharacter.getOtp0().joinToString("") { "%02x".format(it) }, - otp1 = nfcCharacter.getOtp1().joinToString("") { "%02x".format(it) }, + otp0 = "", //nfcCharacter.value!!.otp0.toString(), + otp1 = "", //nfcCharacter.value!!.otp1.toString(), minorVersion = nfcCharacter.characterCreationFirmwareVersion.minorVersion.toInt(), majorVersion = nfcCharacter.characterCreationFirmwareVersion.majorVersion.toInt(), ) @@ -247,42 +240,6 @@ class FromNfcConverter ( .insertBECharacterData(extraCharacterData) } - private fun addVbCharacterProfileFromBe( - characterId: Long, - nfcCharacter: BENfcCharacter, - ) { - val historyCount = nfcCharacter.transformationHistory.count { it.toCharIndex.toInt() != 255 } - val vbData = VBCharacterData( - id = characterId, - generation = (historyCount - 1).coerceAtLeast(0), - totalTrophies = nfcCharacter.trophies.toInt(), - ) - database.userCharacterDao().insertVBCharacterData(vbData) - - // Populate empty mission slots for BE characters for UI consistency - addEmptyMissionSlotsForBe(characterId) - } - - private fun addEmptyMissionSlotsForBe(characterId: Long) { - // Create 4 empty mission slots (one for each watch position 0-3) - val emptyMissions = (0..3).map { watchId -> - SpecialMissions( - characterId = characterId, - watchId = watchId, - missionType = SpecialMission.Type.NONE, - status = SpecialMission.Status.UNAVAILABLE, - goal = 0, - progress = 0, - timeElapsedInMinutes = 0, - timeLimitInMinutes = 0, - ) - } - - database - .userCharacterDao() - .insertSpecialMissions(*emptyMissions.toTypedArray()) - } - private fun addVitalsHistoryToDatabase( @@ -335,14 +292,12 @@ class FromNfcConverter ( database .dexDao() - .insertCharacter(item.toCharIndex.toInt(), dimData.id, date) - transferSeenDao.markSeen(dimData.name, item.toCharIndex.toInt(), date) + .insertCharacter( + item.toCharIndex.toInt(), + dimData.id, + date + ) } } } - - private fun markSeen(cardData: Card, slotId: Int, timestamp: Long) { - database.dexDao().insertCharacter(slotId, cardData.id, timestamp) - transferSeenDao.markSeen(cardData.name, slotId, timestamp) - } } \ No newline at end of file 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 bb105dd..143fef8 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 @@ -11,42 +11,22 @@ import com.github.cfogrady.vbnfc.vb.SpecialMission import com.github.cfogrady.vbnfc.vb.VBNfcCharacter import com.github.nacabaro.vbhelper.database.AppDatabase import com.github.nacabaro.vbhelper.di.VBHelper -import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter -import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData import com.github.nacabaro.vbhelper.dtos.CharacterDtos import com.github.nacabaro.vbhelper.utils.DeviceType import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.firstOrNull import java.util.Date class ToNfcConverter( private val componentActivity: ComponentActivity ) { - companion object { - private const val MIN_NFC_TRANSFORMATION_YEAR = 2021 - private const val MAX_NFC_TRANSFORMATION_YEAR = 2035 - - internal fun shouldEncodeAsBem( - forcedProfile: DeviceType?, - storedDeviceType: DeviceType, - ): Boolean { - return when (forcedProfile) { - DeviceType.BEDevice -> true - DeviceType.VBDevice -> false - else -> storedDeviceType == DeviceType.BEDevice - } - } - } - private val application: VBHelper = componentActivity.applicationContext as VBHelper private val database: AppDatabase = application.container.db suspend fun characterToNfc( - characterId: Long, - forcedProfile: DeviceType? = null, + characterId: Long ): NfcCharacter { val app = componentActivity.applicationContext as VBHelper val database = app.container.db @@ -59,13 +39,7 @@ class ToNfcConverter( .characterDao() .getCharacterInfo(userCharacter.charId) - // Forced profile overrides stored device type for transfer semantics: - // - NFC-A route forces VBDevice (real bracelets only understand VB) - // - HCE route forces BEDevice (VitalWear only sends/accepts BE) - // Otherwise, use the character's own stored type. - val shouldEncodeAsBem = shouldEncodeAsBem(forcedProfile, userCharacter.characterType) - - return if (shouldEncodeAsBem) + return if (userCharacter.characterType == DeviceType.BEDevice) nfcToBENfc(characterId, characterInfo, userCharacter) else nfcToVBNfc(characterId, characterInfo, userCharacter) @@ -81,12 +55,7 @@ class ToNfcConverter( val vbData = database .userCharacterDao() .getVbData(characterId) - .firstOrNull() - ?: VBCharacterData( - id = characterId, - generation = 0, - totalTrophies = userCharacter.trophies - ) + .first() val paddedTransformationArray = generateTransformationHistory(characterId, 9) @@ -207,47 +176,11 @@ class ToNfcConverter( ): BENfcCharacter { val beData = database .userCharacterDao() - .getBeDataOrNull(characterId) - ?: BECharacterData( - id = characterId, - trainingHp = 0, - trainingAp = 0, - trainingBp = 0, - remainingTrainingTimeInMinutes = 0, - itemEffectMentalStateValue = 0, - itemEffectMentalStateMinutesRemaining = 0, - itemEffectActivityLevelValue = 0, - itemEffectActivityLevelMinutesRemaining = 0, - itemEffectVitalPointsChangeValue = 0, - itemEffectVitalPointsChangeMinutesRemaining = 0, - abilityRarity = NfcCharacter.AbilityRarity.None, - abilityType = 0, - abilityBranch = 0, - abilityReset = 0, - rank = 0, - itemType = 0, - itemMultiplier = 0, - itemRemainingTime = 0, - otp0 = "", - otp1 = "", - minorVersion = 0, - majorVersion = 0, - ) + .getBeData(characterId) + .first() val paddedTransformationArray = generateTransformationHistory(characterId) - // Convert hex-encoded OTP strings back to ByteArrays; use empty arrays if not stored - val otp0ByteArray = if (beData.otp0.isNotEmpty()) { - beData.otp0.chunked(2).map { it.toInt(16).toByte() }.toByteArray() - } else { - ByteArray(8) // Default to 8-byte zero array if not available - } - val otp1ByteArray = if (beData.otp1.isNotEmpty()) { - beData.otp1.chunked(2).map { it.toInt(16).toByte() }.toByteArray() - } else { - ByteArray(8) // Default to 8-byte zero array if not available - } - val nfcData = BENfcCharacter( dimId = characterInfo.cardId.toUShort(), charIndex = characterInfo.charId.toUShort(), @@ -288,8 +221,8 @@ class ToNfcConverter( itemType = beData.itemType.toByte(), itemMultiplier = beData.itemMultiplier.toByte(), itemRemainingTime = beData.itemRemainingTime.toByte(), - otp0 = otp0ByteArray, - otp1 = otp1ByteArray, + otp0 = byteArrayOf(8), + otp1 = byteArrayOf(8), characterCreationFirmwareVersion = FirmwareVersion( minorVersion = beData.minorVersion.toByte(), majorVersion = beData.majorVersion.toByte() @@ -321,15 +254,11 @@ class ToNfcConverter( "Day: ${calendar.get(Calendar.DAY_OF_MONTH)}" ) - val rawYear = calendar.get(Calendar.YEAR) - val normalizedYear = rawYear.coerceIn(MIN_NFC_TRANSFORMATION_YEAR, MAX_NFC_TRANSFORMATION_YEAR) - if (normalizedYear != rawYear) { - Log.w("TransformationHistory", "Normalizing out-of-range transformation year $rawYear to $normalizedYear") - } - NfcCharacter.Transformation( toCharIndex = it.monIndex.toUByte(), - year = normalizedYear.toUShort(), + year = calendar + .get(Calendar.YEAR) + .toUShort(), month = (calendar .get(Calendar.MONTH) + 1) .toUByte(), 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 8f2a1f5..5aaa7e7 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,47 @@ package com.github.nacabaro.vbhelper.screens.scanScreen.screens +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.platform.LocalContext -import com.github.nacabaro.vbhelper.screens.scanScreen.TransferHaptics -import com.github.nacabaro.vbhelper.utils.ImageBitmapData +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.github.nacabaro.vbhelper.components.TopBanner +import com.github.nacabaro.vbhelper.R @Composable fun ActionScreen( topBannerText: String, - detectedTransportMessage: String?, - transferStatusMessage: String?, - characterPreview: ImageBitmapData? = null, onClickCancel: () -> Unit, - isTransferring: Boolean = true, ) { - val context = LocalContext.current - val transferHaptics = remember(context) { TransferHaptics(context) } - - LaunchedEffect(transferStatusMessage) { - if (transferStatusMessage != null && transferStatusMessage.contains("progress", ignoreCase = true)) { - transferHaptics.onTransferProgress() + Scaffold ( + topBar = { + TopBanner( + text = topBannerText, + onBackClick = onClickCancel + ) + } + ) { innerPadding -> + Column ( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier + .padding(innerPadding) + .fillMaxSize() + ) { + Text(stringResource(R.string.action_place_near_reader)) + Button( + onClick = onClickCancel, + modifier = Modifier.padding(16.dp) + ) { + Text(stringResource(R.string.action_cancel)) + } } } - - 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 3b08d90..633be72 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 @@ -1,26 +1,20 @@ package com.github.nacabaro.vbhelper.screens.scanScreen.screens -import android.util.Log import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import com.github.nacabaro.vbhelper.ActivityLifecycleListener import com.github.nacabaro.vbhelper.domain.card.Card import com.github.nacabaro.vbhelper.screens.cardScreen.ChooseCard -import com.github.nacabaro.vbhelper.screens.scanScreen.DetectedTransport import com.github.nacabaro.vbhelper.screens.scanScreen.SCAN_SCREEN_ACTIVITY_LIFECYCLE_LISTENER import com.github.nacabaro.vbhelper.screens.scanScreen.ScanScreenController -import com.github.nacabaro.vbhelper.screens.scanScreen.TransferHaptics import com.github.nacabaro.vbhelper.R -private const val TAG = "ReadingScreen" @Composable fun ReadingScreen( @@ -28,17 +22,7 @@ fun ReadingScreen( onCancel: () -> Unit, onComplete: () -> Unit ) { - val context = LocalContext.current - val transferHaptics = remember(context) { TransferHaptics(context) } val secrets by scanScreenController.secretsFlow.collectAsState(null) - val transferStatus by scanScreenController.transferStatusFlow.collectAsState(null) - val detectedTransport by scanScreenController.detectedTransportFlow.collectAsState() - - val transportMessage = when (detectedTransport) { - DetectedTransport.NFC_A -> stringResource(R.string.scan_transport_bandai_bracelet) - DetectedTransport.ISO_DEP -> stringResource(R.string.scan_transport_vitalwear_hce) - DetectedTransport.UNKNOWN -> null - } var cardsRead by remember { mutableStateOf?>(null) } @@ -46,80 +30,55 @@ 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) { - Log.d(TAG, "startReadIfNeeded: skipped (no secrets)") - return - } - if (!readingScreen || isDoneReadingCharacter || cardSelectScreen) { - Log.d( - TAG, - "startReadIfNeeded: skipped (readingScreen=$readingScreen, done=$isDoneReadingCharacter, cardSelect=$cardSelectScreen)" - ) - return - } - - Log.d(TAG, "startReadIfNeeded: arming onClickRead") - scanScreenController.onClickRead( - 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 - isDoneReadingCharacter = true - } - ) - } - DisposableEffect(readingScreen) { if(readingScreen) { - Log.d(TAG, "DisposableEffect: readingScreen=true, registering lifecycle listener") scanScreenController.registerActivityLifecycleListener( SCAN_SCREEN_ACTIVITY_LIFECYCLE_LISTENER, object: ActivityLifecycleListener { override fun onPause() { - Log.d(TAG, "lifecycle.onPause: cancelRead") scanScreenController.cancelRead() } override fun onResume() { - Log.d(TAG, "lifecycle.onResume: attempting startReadIfNeeded") - startReadIfNeeded() + scanScreenController.onClickRead( + secrets = secrets!!, + onComplete = { + isDoneReadingCharacter = true + }, + onMultipleCards = { cards -> + cardsRead = cards + readingScreen = false + cardSelectScreen = true + isDoneReadingCharacter = true + } + ) } } ) - startReadIfNeeded() + scanScreenController.onClickRead( + secrets = secrets!!, + onComplete = { + isDoneReadingCharacter = true + }, + onMultipleCards = { cards -> + cardsRead = cards + readingScreen = false + cardSelectScreen = true + isDoneReadingCharacter = true + } + ) } onDispose { if(readingScreen) { - Log.d(TAG, "DisposableEffect.onDispose: unregister listener + cancelRead") scanScreenController.unregisterActivityLifecycleListener( SCAN_SCREEN_ACTIVITY_LIFECYCLE_LISTENER ) scanScreenController.cancelRead() - } else { - Log.d(TAG, "DisposableEffect.onDispose: readingScreen=false, nothing to cancel") } } } if (isDoneReadingCharacter && !cardSelectScreen) { - Log.d(TAG, "state gate: done read without cardSelect, calling onComplete") readingScreen = false onComplete() } @@ -127,33 +86,24 @@ fun ReadingScreen( if (!readingScreen) { ReadCharacterScreen( onClickConfirm = { - Log.d(TAG, "ReadCharacterScreen.onClickConfirm: entering reading screen") readingScreen = true }, onClickCancel = { - Log.d(TAG, "ReadCharacterScreen.onClickCancel: user cancelled") onCancel() } ) } if (readingScreen) { - ActionScreen( - topBannerText = stringResource(R.string.reading_character_title), - detectedTransportMessage = transportMessage, - transferStatusMessage = transferStatus, - onClickCancel = { - Log.d(TAG, "ActionScreen.onCancel: cancelRead + onCancel") + ActionScreen(topBannerText = stringResource(R.string.reading_character_title),) { readingScreen = false scanScreenController.cancelRead() onCancel() - } - ) + } } else if (cardSelectScreen) { ChooseCard( cards = cardsRead!!, onCardSelected = { card -> - Log.d(TAG, "ChooseCard.onCardSelected: selected cardId=${card.id}") cardSelectScreen = false scanScreenController.flushCharacter(card.id) } 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 f4a8141..8542bd0 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,21 +7,14 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.produceState import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import com.github.cfogrady.vbnfc.data.NfcCharacter import com.github.nacabaro.vbhelper.ActivityLifecycleListener import com.github.nacabaro.vbhelper.di.VBHelper -import com.github.nacabaro.vbhelper.screens.scanScreen.DetectedTransport import com.github.nacabaro.vbhelper.screens.scanScreen.SCAN_SCREEN_ACTIVITY_LIFECYCLE_LISTENER import com.github.nacabaro.vbhelper.screens.scanScreen.ScanScreenController -import com.github.nacabaro.vbhelper.screens.scanScreen.ScanScreenController.WriteResult -import com.github.nacabaro.vbhelper.screens.scanScreen.TransferHaptics import com.github.nacabaro.vbhelper.source.StorageRepository -import com.github.nacabaro.vbhelper.utils.BitmapData -import com.github.nacabaro.vbhelper.utils.ImageBitmapData -import com.github.nacabaro.vbhelper.utils.getImageBitmap import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import androidx.compose.ui.res.stringResource @@ -36,53 +29,18 @@ fun WritingScreen( onCancel: () -> Unit, ) { val secrets by scanScreenController.secretsFlow.collectAsState(null) - val transferStatus by scanScreenController.transferStatusFlow.collectAsState(null) - val detectedTransport by scanScreenController.detectedTransportFlow.collectAsState() - - val transportMessage = when (detectedTransport) { - DetectedTransport.NFC_A -> stringResource(R.string.scan_transport_bandai_bracelet) - DetectedTransport.ISO_DEP -> stringResource(R.string.scan_transport_vitalwear_hce) - DetectedTransport.UNKNOWN -> null - } val application = LocalContext.current.applicationContext as VBHelper val storageRepository = StorageRepository(application.container.db) - val context = LocalContext.current - - val characterPreview by produceState(initialValue = null, key1 = characterId) { - value = withContext(Dispatchers.IO) { - runCatching { - storageRepository.getSingleCharacter(characterId) - }.getOrNull()?.let { character -> - if (character.spriteWidth > 0 && character.spriteHeight > 0 && character.spriteIdle.isNotEmpty()) { - BitmapData( - bitmap = character.spriteIdle, - width = character.spriteWidth, - height = character.spriteHeight - ).getImageBitmap( - context = context, - multiplier = 4, - obscure = false - ) - } else { - null - } - } - } - } var writing by remember { mutableStateOf(false) } var writingScreen by remember { mutableStateOf(false) } var writingConfirmScreen by remember { mutableStateOf(false) } var isDoneSendingCard by remember { mutableStateOf(false) } var isDoneWritingCharacter by remember { mutableStateOf(false) } - var writeResult by remember { mutableStateOf(null) } DisposableEffect(writing) { if (writing) { - val transferHaptics = TransferHaptics(application) - transferHaptics.onTransferStart() - scanScreenController.registerActivityLifecycleListener( SCAN_SCREEN_ACTIVITY_LIFECYCLE_LISTENER, object : ActivityLifecycleListener { @@ -94,13 +52,10 @@ fun WritingScreen( if (!isDoneSendingCard) { scanScreenController.onClickCheckCard(secrets!!, nfcCharacter) { isDoneSendingCard = true - transferHaptics.onTransferProgress() } } else if (!isDoneWritingCharacter) { - scanScreenController.onClickWrite(secrets!!, nfcCharacter, characterId) { result -> - writeResult = result + scanScreenController.onClickWrite(secrets!!, nfcCharacter) { isDoneWritingCharacter = true - transferHaptics.onTransferProgress() } } } @@ -111,13 +66,10 @@ fun WritingScreen( if (!isDoneSendingCard) { scanScreenController.onClickCheckCard(secrets!!, nfcCharacter) { isDoneSendingCard = true - transferHaptics.onTransferProgress() } } else if (!isDoneWritingCharacter) { - scanScreenController.onClickWrite(secrets!!, nfcCharacter, characterId) { result -> - writeResult = result + scanScreenController.onClickWrite(secrets!!, nfcCharacter) { isDoneWritingCharacter = true - transferHaptics.onTransferProgress() } } } @@ -147,16 +99,10 @@ fun WritingScreen( ) } else if (!isDoneSendingCard) { writing = true - ActionScreen( - topBannerText = stringResource(R.string.sending_card_title), - detectedTransportMessage = transportMessage, - transferStatusMessage = transferStatus, - characterPreview = characterPreview, - onClickCancel = { + ActionScreen( topBannerText = stringResource(R.string.sending_card_title)) { scanScreenController.cancelRead() onCancel() - } - ) + } } else if (!writingConfirmScreen) { writing = false WriteCharacterScreen ( @@ -171,17 +117,11 @@ fun WritingScreen( ) } else if (!isDoneWritingCharacter) { writing = true - ActionScreen( - topBannerText = stringResource(R.string.writing_character_action_title), - detectedTransportMessage = transportMessage, - transferStatusMessage = transferStatus, - characterPreview = characterPreview, - onClickCancel = { + ActionScreen(topBannerText = stringResource(R.string.writing_character_action_title)) { isDoneSendingCard = false scanScreenController.cancelRead() onCancel() - } - ) + } } var completedWriting by remember { mutableStateOf(false) } @@ -189,11 +129,8 @@ fun WritingScreen( LaunchedEffect(isDoneSendingCard, isDoneWritingCharacter) { withContext(Dispatchers.IO) { if (isDoneSendingCard && isDoneWritingCharacter) { - // Only delete source character if transfer was confirmed as move. - // COPIED means transfer was sent but not confirmed, so keep source. - if (writeResult == WriteResult.MOVE_CONFIRMED) { - storageRepository.deleteCharacter(characterId) - } + storageRepository + .deleteCharacter(characterId) completedWriting = true } } 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 0b4bfc9..6a71765 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,7 +5,6 @@ import android.net.Uri import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -13,12 +12,8 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -72,7 +67,6 @@ fun SettingsScreen( settingsScreenController.onClickImportCard() } - SettingsSection(title = stringResource(R.string.settings_section_about)) SettingsEntry( title = stringResource(R.string.settings_credits_title), @@ -104,38 +98,6 @@ fun SettingsScreen( ) { settingsScreenController.onClickImportDatabase() } - - SettingsSection(title = stringResource(R.string.settings_section_companion_tools)) - SettingsEntry( - title = stringResource(R.string.settings_companion_import_card_image_title), - description = stringResource(R.string.settings_companion_import_card_image_desc) - ) { - settingsScreenController.onClickCompanionImportCardImage() - } - SettingsEntry( - title = stringResource(R.string.settings_companion_import_firmware_title), - description = stringResource(R.string.settings_companion_import_firmware_desc) - ) { - settingsScreenController.onClickCompanionImportFirmware() - } - SettingsEntry( - title = stringResource(R.string.settings_companion_send_watch_logs_title), - description = stringResource(R.string.settings_companion_send_watch_logs_desc) - ) { - settingsScreenController.onClickCompanionSendWatchLogs() - } - SettingsEntry( - title = stringResource(R.string.settings_companion_send_phone_logs_title), - description = stringResource(R.string.settings_companion_send_phone_logs_desc) - ) { - settingsScreenController.onClickCompanionSendPhoneLogs() - } - SettingsSwitchEntry( - title = stringResource(R.string.settings_enable_dim_to_bem_title), - description = stringResource(R.string.settings_enable_dim_to_bem_desc), - isEnabled = settingsScreenController, - onToggle = { settingsScreenController.onToggleDimToBemConversion(it) } - ) } } } @@ -161,40 +123,6 @@ fun SettingsEntry( } } -@Composable -fun SettingsSwitchEntry( - title: String, - description: String, - isEnabled: SettingsScreenControllerImpl, - onToggle: (Boolean) -> Unit -) { - val dimToBemEnabled by isEnabled.dimToBemConversionEnabled.collectAsState(false) - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Column( - modifier = Modifier - .weight(1f) - .padding(end = 16.dp) - ) { - Text(text = title) - Text( - text = description, - fontSize = 12.sp, - color = MaterialTheme.colorScheme.outline - ) - } - Switch( - checked = dimToBemEnabled, - onCheckedChange = onToggle - ) - } -} - @Composable fun SettingsSection( title: String 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 45f8565..ea0fb19 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,8 +5,4 @@ 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 dc065af..dd78c14 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,6 +1,5 @@ package com.github.nacabaro.vbhelper.screens.settingsScreen -import android.content.Intent import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.launch import android.net.Uri @@ -8,13 +7,8 @@ import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts -import com.github.nacabaro.vbhelper.companion.card.CompanionImportCardActivity -import com.github.nacabaro.vbhelper.companion.firmware.CompanionFirmwareImportActivity -import com.github.nacabaro.vbhelper.companion.logs.CompanionWatchLogsActivity -import com.github.nacabaro.vbhelper.companion.logging.TinyLogTree import com.github.nacabaro.vbhelper.database.AppDatabase import com.github.nacabaro.vbhelper.di.VBHelper -import com.github.nacabaro.vbhelper.R import com.github.nacabaro.vbhelper.screens.settingsScreen.controllers.CardImportController import com.github.nacabaro.vbhelper.screens.settingsScreen.controllers.DatabaseManagementController import com.github.nacabaro.vbhelper.source.ApkSecretsImporter @@ -22,7 +16,6 @@ import com.github.nacabaro.vbhelper.source.SecretsImporter import com.github.nacabaro.vbhelper.source.SecretsRepository import com.github.nacabaro.vbhelper.source.proto.Secrets import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.first class SettingsScreenControllerImpl( @@ -36,14 +29,11 @@ class SettingsScreenControllerImpl( private val application = context.applicationContext as VBHelper private val secretsRepository: SecretsRepository = application.container.dataStoreSecretsRepository private val database: AppDatabase = application.container.db - private val cardSettingsRepository = application.container.cardSettingsRepository private val databaseManagementController = DatabaseManagementController( componentActivity = context, application = application ) - val dimToBemConversionEnabled = cardSettingsRepository.enableDimToBemConversion - init { filePickerLauncher = context.registerForActivityResult( ActivityResultContracts.CreateDocument("application/octet-stream") @@ -111,41 +101,13 @@ class SettingsScreenControllerImpl( filePickerCard.launch(arrayOf("*/*")) } - override fun onClickCompanionImportCardImage() { - context.startActivity(Intent(context, CompanionImportCardActivity::class.java)) - } - - override fun onClickCompanionImportFirmware() { - context.startActivity(Intent(context, CompanionFirmwareImportActivity::class.java)) - } - - override fun onClickCompanionSendWatchLogs() { - context.startActivity(Intent(context, CompanionWatchLogsActivity::class.java)) - } - - override fun onClickCompanionSendPhoneLogs() { - val file = TinyLogTree.getMostRecentLogFile(context.applicationContext) - if (file == null) { - Toast.makeText(context, context.getString(R.string.companion_logs_no_files), Toast.LENGTH_SHORT).show() - return - } - application.companionLogService.sendLogFile(context.applicationContext, file, context) - } - - fun onToggleDimToBemConversion(enabled: Boolean) { - context.lifecycleScope.launch(Dispatchers.IO) { - cardSettingsRepository.setEnableDimToBemConversion(enabled) - } - } - private fun importCard(uri: Uri) { context.lifecycleScope.launch(Dispatchers.IO) { val contentResolver = context.contentResolver val inputStream = contentResolver.openInputStream(uri) inputStream.use { fileReader -> - val dimToBemEnabled = cardSettingsRepository.enableDimToBemConversion.first() - val cardImportController = CardImportController(database, dimToBemEnabled) + val cardImportController = CardImportController(database) cardImportController.importCard(fileReader) } 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 5af2606..1626405 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,8 +13,7 @@ import com.github.nacabaro.vbhelper.domain.characters.Sprite import java.io.InputStream class CardImportController( - private val database: AppDatabase, - private val enableDimToBemConversion: Boolean = false + private val database: AppDatabase ) { suspend fun importCard( fileReader: InputStream? @@ -118,16 +117,6 @@ class CardImportController( .spriteDao() .insertSprite(domainSprite) - // Apply DIM-to-BEM conversion if enabled and card is a DIM card (not BEM) - var baseHp = characters[index].hp - var baseBp = characters[index].dp - var baseAp = characters[index].ap - - if (enableDimToBemConversion && card is DimCard) { - baseHp = convertDimStatToBem(baseHp, characters[index].stage, Stat.HP) - baseBp = convertDimStatToBem(baseBp, characters[index].stage, Stat.BP) - baseAp = convertDimStatToBem(baseAp, characters[index].stage, Stat.AP) - } domainCharacters.add( CardCharacter( @@ -137,9 +126,9 @@ class CardImportController( nameSprite = card.spriteData.sprites[spriteCounter].pixelData, stage = characters[index].stage, attribute = NfcCharacter.Attribute.entries[characters[index].attribute], - baseHp = baseHp, - baseBp = baseBp, - baseAp = baseAp, + baseHp = characters[index].hp, + baseBp = characters[index].dp, + baseAp = characters[index].ap, nameWidth = card.spriteData.sprites[spriteCounter].spriteDimensions.width, nameHeight = card.spriteData.sprites[spriteCounter].spriteDimensions.height ) @@ -161,40 +150,6 @@ class CardImportController( .insertCharacter(*domainCharacters.toTypedArray()) } - private enum class Stat { HP, BP, AP } - - private fun convertDimStatToBem(dimStat: Int, phase: Int, statType: Stat): Int { - // DIM-to-BEM conversion factors based on phase and stat type - // These are approximations based on the average conversion ratios - val conversionFactor = when (phase) { - 1 -> 0.0f - 2 -> 0.0f - 3 -> when (statType) { - Stat.HP -> 4657.5f - Stat.BP -> 4657.5f - Stat.AP -> 1022.5f - } - 4 -> when (statType) { - Stat.HP -> 5171.528f - Stat.BP -> 5028.528f - Stat.AP -> 1222.9167f - } - 5 -> when (statType) { - Stat.HP -> 5664.4f - Stat.BP -> 5036.4f - Stat.AP -> 1505.7333f - } - 6 -> when (statType) { - Stat.HP -> 5972.2974f - Stat.BP -> 5256.2974f - Stat.AP -> 1976.7568f - } - else -> 0.0f - } - - return (dimStat * conversionFactor).toInt() - } - private suspend fun importAdventureMissions( cardId: Long, card: com.github.cfogrady.vb.dim.card.Card<*, *, *, *, *, *> 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 0f9e34e..f3fb931 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,14 +1,11 @@ package com.github.nacabaro.vbhelper.screens.settingsScreen.controllers -import androidx.room.Room -import android.database.sqlite.SQLiteDatabase import android.net.Uri import android.provider.OpenableColumns import android.util.Log import android.widget.Toast import androidx.activity.ComponentActivity import androidx.lifecycle.lifecycleScope -import com.github.nacabaro.vbhelper.database.AppDatabase import com.github.nacabaro.vbhelper.di.VBHelper import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -21,19 +18,6 @@ class DatabaseManagementController( val application: VBHelper ) { private val roomDbName = "internalDb" - private val requiredLegacyVersions = setOf(1, 2, 3, 4, 5) - private val requiredCoreTables = setOf( - "UserCharacter", - "Character", - "Card", - "CardCharacter", - "TransformationHistory", - ) - - private data class ValidationResult( - val isValid: Boolean, - val reason: String? = null, - ) fun exportDatabase( destinationUri: Uri) { componentActivity.lifecycleScope.launch(Dispatchers.IO) { @@ -75,19 +59,6 @@ class DatabaseManagementController( return@launch } - val validationResult = validateImportBackup(sourceUri) - if (!validationResult.isValid) { - componentActivity.runOnUiThread { - Toast.makeText( - componentActivity, - validationResult.reason - ?: "Import blocked: backup is incompatible with this VBH-VW version.", - Toast.LENGTH_LONG - ).show() - } - return@launch - } - application.container.db.close() val dbPath = componentActivity.getDatabasePath(roomDbName) @@ -121,118 +92,6 @@ class DatabaseManagementController( } } - private fun validateImportBackup(sourceUri: Uri): ValidationResult { - val tempDbName = "import_validation_temp.db" - val tempDbPath = componentActivity.getDatabasePath(tempDbName) - val tempShm = File(tempDbPath.parentFile, "$tempDbName-shm") - val tempWal = File(tempDbPath.parentFile, "$tempDbName-wal") - - try { - if (tempDbPath.exists()) tempDbPath.delete() - if (tempShm.exists()) tempShm.delete() - if (tempWal.exists()) tempWal.delete() - - componentActivity.contentResolver.openInputStream(sourceUri)?.use { inputStream -> - tempDbPath.outputStream().use { outputStream -> - copyFile(inputStream, outputStream) - } - } ?: return ValidationResult(false, "Import blocked: unable to read selected backup file.") - - // Fast static checks before attempting full Room open/migration. - val legacyVersion = readPragmaUserVersion(tempDbPath) - if (legacyVersion !in requiredLegacyVersions) { - return ValidationResult( - false, - "Import blocked: unsupported DB version ($legacyVersion). Use a VBH-VW export from a compatible app version." - ) - } - - val tableNames = readTableNames(tempDbPath) - if (!tableNames.contains("room_master_table")) { - return ValidationResult( - false, - "Import blocked: backup is not a Room VBH-VW database (room metadata missing)." - ) - } - - val missingTables = requiredCoreTables.filterNot { tableNames.contains(it) } - if (missingTables.isNotEmpty()) { - return ValidationResult( - false, - "Import blocked: backup is missing required tables (${missingTables.joinToString(", ")})." - ) - } - - // Hard validation: open with the app's exact Room schema + migrations. - val probeDb = Room.databaseBuilder( - componentActivity, - AppDatabase::class.java, - tempDbName - ) - .addMigrations(AppDatabase.MIGRATION_1_2) - .addMigrations(AppDatabase.MIGRATION_2_3) - .addMigrations(AppDatabase.MIGRATION_3_5) - .addMigrations(AppDatabase.MIGRATION_4_5) - .addMigrations(AppDatabase.MIGRATION_5_6) - .build() - - try { - probeDb.openHelper.writableDatabase.query("SELECT 1").close() - } catch (e: Exception) { - return ValidationResult( - false, - "Import blocked: backup cannot be opened/migrated by this VBH-VW build." - ) - } finally { - probeDb.close() - } - - return ValidationResult(true) - } finally { - if (tempDbPath.exists()) tempDbPath.delete() - if (tempShm.exists()) tempShm.delete() - if (tempWal.exists()) tempWal.delete() - } - } - - private fun readPragmaUserVersion(dbFile: File): Int { - val sqliteDb = SQLiteDatabase.openDatabase( - dbFile.absolutePath, - null, - SQLiteDatabase.OPEN_READONLY - ) - try { - sqliteDb.rawQuery("PRAGMA user_version", null).use { cursor -> - if (cursor.moveToFirst()) { - return cursor.getInt(0) - } - } - return -1 - } finally { - sqliteDb.close() - } - } - - private fun readTableNames(dbFile: File): Set { - 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 c379a27..53bc737 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,18 +44,12 @@ class AuthRepository( preferences[IS_AUTHENTICATED] = isAuthenticated if (nacatechToken != null) { preferences[AUTH_TOKEN] = nacatechToken - } else { - preferences.remove(AUTH_TOKEN) } if (sessionToken != null) { preferences[SESSION_TOKEN] = sessionToken - } else { - preferences.remove(SESSION_TOKEN) } if (userId != null) { preferences[USER_ID] = userId - } else { - preferences.remove(USER_ID) } } } 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 45078e0..bc3ad95 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 @@ -1,199 +1,114 @@ package com.github.nacabaro.vbhelper.source +import android.content.ClipData +import android.content.Context +import android.content.Intent +import androidx.core.content.FileProvider import com.github.cfogrady.vitalwear.protos.Character import com.github.nacabaro.vbhelper.database.AppDatabase -import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData import com.github.nacabaro.vbhelper.utils.DeviceType -import com.google.protobuf.ByteString import kotlinx.coroutines.flow.firstOrNull - -internal const val DEFAULT_VITALWEAR_TRAINING_TIME_SECONDS = 100L * 60L * 60L - -internal fun resolveTrainingSeconds( - deviceType: DeviceType, - beData: BECharacterData?, -): Long { - val remainingTrainingTimeInMinutes = beData?.remainingTrainingTimeInMinutes - if (remainingTrainingTimeInMinutes != null) { - return remainingTrainingTimeInMinutes.toLong() * 60L - } - - // VBHelper only stores the explicit training timer on BE payloads. When exporting non-BE - // characters to VitalWear, preserve their ability to train by seeding the same default - // window VitalWear uses for freshly created partners instead of sending an immediate zero. - return if (deviceType == DeviceType.BEDevice) { - 0L - } else { - DEFAULT_VITALWEAR_TRAINING_TIME_SECONDS - } -} +import kotlinx.coroutines.runBlocking +import java.io.File class VitalWearCharacterExporter( + private val context: Context, private val database: AppDatabase ) { - /** - * Builds a Character proto from the stored character data. - * Used by the HCE ISO-DEP transfer path. - * - * @param characterId ID of the character to export - * @param forcedTransferProfile If specified (e.g., DeviceType.BEDevice for HCE), overrides stored device type - * and ensures stats are serialized for the target ecosystem. - * Used to lock VitalWear (HCE) transfers to BE profile. - */ - suspend fun buildCharacterProto( - characterId: Long, - forcedTransferProfile: DeviceType? = null, - ): Character { - val characterWithSprites = database.userCharacterDao().getCharacterWithSprites(characterId) - val userCharacter = database.userCharacterDao().getCharacter(characterId) - val cardCharacter = database.characterDao().getCharacterById(userCharacter.charId) - ?: error("Card character not found for user character $characterId") - val sprite = database.spriteDao().getSpriteById(cardCharacter.spriteId) - ?: error("Sprite not found for user character $characterId") - val characterInfo = database.characterDao().getCharacterInfo(userCharacter.charId) - val vwSettings = database.vitalWearSettingsDao().getByCharacterId(characterId) - val card = database.cardDao().getCardByCharacterIdSync(characterId) - ?: error("Card not found for character $characterId") - val cardProgress = database.cardProgressDao().getCardProgressSync(card.id) ?: 0 - val beData = database.userCharacterDao().getBeDataOrNull(characterId) - val vbData = database.userCharacterDao().getVbDataOrNull(characterId) - val normalizedTransformationCountdownMinutes = normalizeTransformationCountdownMinutes( - transformationCountdownMinutes = userCharacter.transformationCountdown, - hasPossibleTransformations = hasPossibleTransformations(userCharacter.charId), - ) - - // Use forced profile if specified (e.g., BE for HCE route), otherwise use stored type. - // For HCE/VitalWear routes, forcedTransferProfile = BE ensures BE stats are serialized. - val transferDeviceType = forcedTransferProfile ?: userCharacter.characterType - - val settingsBuilder = Character.Settings.newBuilder() - .setTrainingInBackground(vwSettings?.trainingInBackground ?: false) - .setAllowedBattles(resolveAllowedBattles(vwSettings?.allowedBattles)) - - // VitalWear chooses DIM-vs-BE runtime path from card type + assumedFranchise. - // For forced BE HCE exports of DIM cards, provide a stable assumed franchise hint. - if (forcedTransferProfile == DeviceType.BEDevice && !card.isBEm) { - settingsBuilder.setAssumedFranchise(0) - } - - return Character.newBuilder() - .setCardId(card.cardId) - .setCardName(card.name) - .setCharacterStats( - Character.CharacterStats.newBuilder() - .setSlotId(characterInfo.charId) - .setVitals(characterWithSprites.vitalPoints) - .setTrainingTimeRemainingInSeconds(resolveTrainingSeconds(transferDeviceType, beData)) - .setTimeUntilNextTransformation(normalizedTransformationCountdownMinutes.toLong() * 60L) - .setTrainedBp(resolveTrainedBp(beData)) - .setTrainedHp(resolveTrainedHp(beData)) - .setTrainedAp(resolveTrainedAp(beData)) - .setTrainedPp(characterWithSprites.trophies) - .setInjured(characterWithSprites.injuryStatus.name.lowercase().contains("inj")) - .setAccumulatedDailyInjuries(vwSettings?.accumulatedDailyInjuries ?: 0) - .setTotalBattles(characterWithSprites.totalBattlesWon + characterWithSprites.totalBattlesLost) - .setCurrentPhaseBattles(characterWithSprites.currentPhaseBattlesWon + characterWithSprites.currentPhaseBattlesLost) - .setTotalWins(characterWithSprites.totalBattlesWon) - .setCurrentPhaseWins(characterWithSprites.currentPhaseBattlesWon) - .setMood(characterWithSprites.mood) - .setDeviceType(transferDeviceType.toTransferDeviceType()) - .setAgeInDays(userCharacter.ageInDays.coerceAtLeast(0)) - .setActivityLevel(userCharacter.activityLevel.coerceAtLeast(0)) - .setHeartRateCurrent(userCharacter.heartRateCurrent.coerceAtLeast(0)) - .setGeneration(vbData?.generation ?: 0) - .setTotalTrophies(vbData?.totalTrophies ?: userCharacter.trophies.coerceAtLeast(0)) - .setNextAdventureMissionStage(characterInfo.currentStage.coerceAtLeast(0)) - .setItemEffectMentalStateValue(beData?.itemEffectMentalStateValue ?: 0) - .setItemEffectMentalStateMinutesRemaining(beData?.itemEffectMentalStateMinutesRemaining ?: 0) - .setItemEffectActivityLevelValue(beData?.itemEffectActivityLevelValue ?: 0) - .setItemEffectActivityLevelMinutesRemaining(beData?.itemEffectActivityLevelMinutesRemaining ?: 0) - .setItemEffectVitalPointsChangeValue(beData?.itemEffectVitalPointsChangeValue ?: 0) - .setItemEffectVitalPointsChangeMinutesRemaining(beData?.itemEffectVitalPointsChangeMinutesRemaining ?: 0) - .setAbilityRarity(beData?.abilityRarity?.ordinal ?: 0) - .setAbilityType(beData?.abilityType ?: 0) - .setAbilityBranch(beData?.abilityBranch ?: 0) - .setAbilityReset(beData?.abilityReset ?: 0) - .setRank(beData?.rank ?: 0) - .setItemType(beData?.itemType ?: 0) - .setItemMultiplier(beData?.itemMultiplier ?: 0) - .setItemRemainingTime(beData?.itemRemainingTime ?: 0) - .setFirmwareMinorVersion(beData?.minorVersion ?: 0) - .setFirmwareMajorVersion(beData?.majorVersion ?: 0) - .build() - ) - .setSettings(settingsBuilder.build()) - .putMaxAdventureCompletedByCard(card.name, (cardProgress - 1).coerceAtLeast(0)) - .addAllTransformationHistory( - database.userCharacterDao().getTransformationHistoryForExport(characterId).map { - Character.TransformationEvent.newBuilder() - .setCardName(it.cardName) - .setPhase(0) - .setSlotId(it.monIndex) + fun buildShareIntent(characterId: Long): Intent { + return runBlocking { + val characterWithSprites = database.userCharacterDao().getCharacterWithSprites(characterId) + val userCharacter = database.userCharacterDao().getCharacter(characterId) + val card = database.cardDao().getCardByCharacterIdSync(characterId) + ?: error("Card not found for character $characterId") + val cardProgress = database.cardProgressDao().getCardProgressSync(card.id) ?: 0 + val proto = Character.newBuilder() + .setCardId(card.cardId) + .setCardName(card.name) + .setCharacterStats( + Character.CharacterStats.newBuilder() + .setSlotId(database.userCharacterDao().getCharacterInfo(characterId).charaIndex) + .setVitals(characterWithSprites.vitalPoints) + .setTrainingTimeRemainingInSeconds(resolveTrainingSeconds(characterId, userCharacter.characterType)) + .setTimeUntilNextTransformation(characterWithSprites.transformationCountdown.toLong() * 60L) + .setTrainedBp(resolveTrainedBp(characterId, userCharacter.characterType)) + .setTrainedHp(resolveTrainedHp(characterId, userCharacter.characterType)) + .setTrainedAp(resolveTrainedAp(characterId, userCharacter.characterType)) + .setTrainedPp(characterWithSprites.trophies) + .setInjured(characterWithSprites.injuryStatus.name.lowercase().contains("inj")) + .setAccumulatedDailyInjuries(0) + .setTotalBattles(characterWithSprites.totalBattlesWon + characterWithSprites.totalBattlesLost) + .setCurrentPhaseBattles(characterWithSprites.currentPhaseBattlesWon + characterWithSprites.currentPhaseBattlesLost) + .setTotalWins(characterWithSprites.totalBattlesWon) + .setCurrentPhaseWins(characterWithSprites.currentPhaseBattlesWon) + .setMood(characterWithSprites.mood) .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() + ) + .setSettings( + Character.Settings.newBuilder() + .setTrainingInBackground(false) + .setAllowedBattles(Character.Settings.AllowedBattles.CARD_ONLY) + .build() + ) + .putMaxAdventureCompletedByCard(card.name, (cardProgress - 1).coerceAtLeast(0)) + .addAllTransformationHistory( + database.userCharacterDao().getTransformationHistoryForExport(characterId).map { + Character.TransformationEvent.newBuilder() + .setCardName(it.cardName) + .setPhase(0) + .setSlotId(it.monIndex) + .build() + } + ) + .build() + + val exportDir = File(context.cacheDir, "exports").apply { mkdirs() } + val exportFile = File(exportDir, "vbhelper_character_$characterId.vitalwear") + exportFile.writeBytes(proto.toByteArray()) + val exportUri = FileProvider.getUriForFile(context, "${context.packageName}.provider", exportFile) + + Intent(Intent.ACTION_SEND).apply { + `package` = "com.github.cfogrady.vitalwear" + type = VITALWEAR_CHARACTER_MIME + putExtra(Intent.EXTRA_STREAM, exportUri) + clipData = ClipData.newRawUri("", exportUri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + } } - - private fun resolveTrainedBp(beData: com.github.nacabaro.vbhelper.domain.device_data.BECharacterData?): Int { - return beData?.trainingBp ?: 0 + private fun resolveTrainingSeconds(characterId: Long, deviceType: DeviceType): Long { + if (deviceType != DeviceType.BEDevice) { + return 0L + } + return database.userCharacterDao().getBeData(characterId).valueOrNull()?.remainingTrainingTimeInMinutes?.toLong()?.times(60L) ?: 0L } - private fun resolveTrainedHp(beData: com.github.nacabaro.vbhelper.domain.device_data.BECharacterData?): Int { - return beData?.trainingHp ?: 0 + private fun resolveTrainedBp(characterId: Long, deviceType: DeviceType): Int { + return if (deviceType == DeviceType.BEDevice) { + database.userCharacterDao().getBeData(characterId).valueOrNull()?.trainingBp ?: 0 + } else 0 } - private fun resolveTrainedAp(beData: com.github.nacabaro.vbhelper.domain.device_data.BECharacterData?): Int { - return beData?.trainingAp ?: 0 + private fun resolveTrainedHp(characterId: Long, deviceType: DeviceType): Int { + return if (deviceType == DeviceType.BEDevice) { + database.userCharacterDao().getBeData(characterId).valueOrNull()?.trainingHp ?: 0 + } else 0 } - private suspend fun hasPossibleTransformations(cardCharacterId: Long): Boolean { - return database.characterDao().getEvolutionRequirementsForCard(cardCharacterId).firstOrNull()?.isNotEmpty() == true + private fun resolveTrainedAp(characterId: Long, deviceType: DeviceType): Int { + return if (deviceType == DeviceType.BEDevice) { + database.userCharacterDao().getBeData(characterId).valueOrNull()?.trainingAp ?: 0 + } else 0 } - - private fun resolveAllowedBattles(rawValue: Int?): Character.Settings.AllowedBattles { - if (rawValue == null) return Character.Settings.AllowedBattles.CARD_ONLY - return Character.Settings.AllowedBattles.forNumber(rawValue) - ?: Character.Settings.AllowedBattles.CARD_ONLY + private fun kotlinx.coroutines.flow.Flow.valueOrNull(): T? { + return runBlocking { + firstOrNull() + } } + companion object { + const val VITALWEAR_CHARACTER_MIME = "application/x-vitalwear-character" + } } 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 704b0f9..6ec3b65 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 @@ -1,25 +1,15 @@ package com.github.nacabaro.vbhelper.source import com.github.cfogrady.vbnfc.data.NfcCharacter -import com.github.cfogrady.vitalwear.common.data.SharedTransferSeenDao import com.github.cfogrady.vitalwear.protos.Character import com.github.nacabaro.vbhelper.database.AppDatabase -import com.github.nacabaro.vbhelper.domain.card.Card -import com.github.nacabaro.vbhelper.domain.card.CardCharacter -import com.github.nacabaro.vbhelper.domain.card.CardProgress import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter -import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData -import com.github.nacabaro.vbhelper.domain.device_data.VitalWearCharacterSettings -import com.github.nacabaro.vbhelper.domain.characters.Sprite import com.github.nacabaro.vbhelper.utils.DeviceType -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.runBlocking import kotlin.math.max class VitalWearCharacterImporter( - private val database: AppDatabase, - private val transferSeenDao: SharedTransferSeenDao, + private val database: AppDatabase ) { data class ImportResult( val success: Boolean, @@ -28,16 +18,15 @@ class VitalWearCharacterImporter( fun importCharacter(character: Character): ImportResult { val importedCard = resolveCard(character) - ?: createPlaceholderCard(character) ?: return ImportResult( success = false, message = "Matching card not found in VBHelper. Import that card first." ) val slotId = character.characterStats.slotId - val cardCharacter = database.characterDao().getCharacterByMonIndexOrNull(slotId, importedCard.id) - ?: createPlaceholderCharacter(importedCard, character) - ?: return ImportResult( + val cardCharacter = runCatching { + database.characterDao().getCharacterByMonIndex(slotId, importedCard.id) + }.getOrNull() ?: return ImportResult( success = false, message = "Character slot $slotId was not found on card ${importedCard.name}." ) @@ -48,110 +37,60 @@ class VitalWearCharacterImporter( val totalWins = character.characterStats.totalWins.coerceAtMost(totalBattles) val currentPhaseBattles = character.characterStats.currentPhaseBattles val currentPhaseWins = character.characterStats.currentPhaseWins.coerceAtMost(currentPhaseBattles) - val normalizedTransformationCountdown = normalizeTransformationCountdownMinutes( - transformationCountdownMinutes = secondsToMinutes(character.characterStats.timeUntilNextTransformation), - hasPossibleTransformations = hasPossibleTransformations(cardCharacter.id), - ) - val hasBePayloadStats = character.characterStats.trainedHp > 0 || - character.characterStats.trainedAp > 0 || - character.characterStats.trainedBp > 0 || - character.characterStats.trainingTimeRemainingInSeconds > 0L - val fallbackIsBeCharacter = importedCard.isBEm || hasBePayloadStats - val deviceType = resolveDeviceType( - transferDeviceType = character.characterStats.deviceType, - fallbackIsBeCharacter = fallbackIsBeCharacter, - ) - val isBeCharacter = deviceType == DeviceType.BEDevice val userCharacterId = database.userCharacterDao().insertCharacterData( UserCharacter( charId = cardCharacter.id, - ageInDays = character.characterStats.ageInDays.takeIf { it > 0 } - ?: max(character.transformationHistoryCount - 1, 0), + ageInDays = max(character.transformationHistoryCount - 1, 0), mood = character.characterStats.mood, vitalPoints = character.characterStats.vitals, - transformationCountdown = normalizedTransformationCountdown, + transformationCountdown = secondsToMinutes(character.characterStats.timeUntilNextTransformation), injuryStatus = resolveInjuryStatus(character.characterStats.injured), trophies = character.characterStats.trainedPp, currentPhaseBattlesWon = currentPhaseWins, currentPhaseBattlesLost = (currentPhaseBattles - currentPhaseWins).coerceAtLeast(0), totalBattlesWon = totalWins, totalBattlesLost = (totalBattles - totalWins).coerceAtLeast(0), - activityLevel = character.characterStats.activityLevel.coerceAtLeast(0), - heartRateCurrent = character.characterStats.heartRateCurrent.coerceAtLeast(0), - characterType = deviceType, + activityLevel = 0, + heartRateCurrent = 0, + characterType = DeviceType.BEDevice, isActive = true ) ) - runBlocking { - database.vitalWearSettingsDao().upsert( - VitalWearCharacterSettings( - characterId = userCharacterId, - trainingInBackground = character.settings.trainingInBackground, - allowedBattles = character.settings.allowedBattles.number, - accumulatedDailyInjuries = character.characterStats.accumulatedDailyInjuries, - ) + database.userCharacterDao().insertBECharacterData( + BECharacterData( + id = userCharacterId, + trainingHp = character.characterStats.trainedHp, + trainingAp = character.characterStats.trainedAp, + trainingBp = character.characterStats.trainedBp, + remainingTrainingTimeInMinutes = secondsToMinutes(character.characterStats.trainingTimeRemainingInSeconds), + itemEffectMentalStateValue = 0, + itemEffectMentalStateMinutesRemaining = 0, + itemEffectActivityLevelValue = 0, + itemEffectActivityLevelMinutesRemaining = 0, + itemEffectVitalPointsChangeValue = 0, + itemEffectVitalPointsChangeMinutesRemaining = 0, + abilityRarity = resolveDefaultAbilityRarity(), + abilityType = 0, + abilityBranch = 0, + abilityReset = 0, + rank = 0, + itemType = 0, + itemMultiplier = 0, + itemRemainingTime = 0, + otp0 = "", + otp1 = "", + minorVersion = 0, + majorVersion = 0 ) - } - - val vbProfile = VBCharacterData( - id = userCharacterId, - generation = if (character.characterStats.generation > 0) { - character.characterStats.generation - } else { - max(character.transformationHistoryCount - 1, 0) - }, - totalTrophies = if (character.characterStats.totalTrophies > 0) { - character.characterStats.totalTrophies - } else { - character.characterStats.trainedPp.coerceAtLeast(0) - } ) - database.userCharacterDao().insertVBCharacterData(vbProfile) - - if (isBeCharacter) { - val abilityRarity = resolveAbilityRarity(character.characterStats.abilityRarity) - database.userCharacterDao().insertBECharacterData( - BECharacterData( - id = userCharacterId, - trainingHp = character.characterStats.trainedHp, - trainingAp = character.characterStats.trainedAp, - trainingBp = character.characterStats.trainedBp, - remainingTrainingTimeInMinutes = secondsToMinutes(character.characterStats.trainingTimeRemainingInSeconds), - itemEffectMentalStateValue = character.characterStats.itemEffectMentalStateValue, - itemEffectMentalStateMinutesRemaining = character.characterStats.itemEffectMentalStateMinutesRemaining, - itemEffectActivityLevelValue = character.characterStats.itemEffectActivityLevelValue, - itemEffectActivityLevelMinutesRemaining = character.characterStats.itemEffectActivityLevelMinutesRemaining, - itemEffectVitalPointsChangeValue = character.characterStats.itemEffectVitalPointsChangeValue, - itemEffectVitalPointsChangeMinutesRemaining = character.characterStats.itemEffectVitalPointsChangeMinutesRemaining, - abilityRarity = abilityRarity, - abilityType = character.characterStats.abilityType, - abilityBranch = character.characterStats.abilityBranch, - abilityReset = character.characterStats.abilityReset, - rank = character.characterStats.rank, - itemType = character.characterStats.itemType, - itemMultiplier = character.characterStats.itemMultiplier, - itemRemainingTime = character.characterStats.itemRemainingTime, - otp0 = "", - otp1 = "", - minorVersion = character.characterStats.firmwareMinorVersion, - majorVersion = character.characterStats.firmwareMajorVersion - ) - ) - } val now = System.currentTimeMillis() - markSeen(importedCard.name, slotId, importedCard.id, now) + database.dexDao().insertCharacter(slotId, importedCard.id, now) - var insertedTransformationCount = 0 for (transformation in character.transformationHistoryList) { - val transformationCard = resolveRelatedCard( - incomingCardName = transformation.cardName, - incomingCardId = character.cardId, - matchedRootCard = importedCard, - incomingRootCardName = character.cardName, - ) + val transformationCard = resolveCard(transformation.cardName, character.cardId) if (transformationCard != null) { database.userCharacterDao().insertTransformation( userCharacterId, @@ -159,28 +98,12 @@ class VitalWearCharacterImporter( transformationCard.id, now ) - insertedTransformationCount++ - markSeen(transformationCard.name, transformation.slotId, transformationCard.id, now) + database.dexDao().insertCharacter(transformation.slotId, transformationCard.id, now) } } - if (insertedTransformationCount == 0) { - // Keep HomeScreen renderable for freshly imported characters with empty history. - database.userCharacterDao().insertTransformation( - userCharacterId, - slotId, - importedCard.id, - now - ) - } - for ((cardName, maxAdventureCompleted) in character.maxAdventureCompletedByCardMap) { - val adventureCard = resolveRelatedCard( - incomingCardName = cardName, - incomingCardId = null, - matchedRootCard = importedCard, - incomingRootCardName = character.cardName, - ) ?: continue + val adventureCard = resolveCard(cardName, null) ?: continue val currentStage = (maxAdventureCompleted + 1).coerceAtLeast(0) database.cardProgressDao().updateCardProgress( currentStage = currentStage, @@ -189,189 +112,6 @@ class VitalWearCharacterImporter( ) } - val nextAdventureMissionStage = character.characterStats.nextAdventureMissionStage.coerceAtLeast(0) - if (nextAdventureMissionStage > 0) { - database.cardProgressDao().updateCardProgress( - currentStage = nextAdventureMissionStage, - cardId = importedCard.id, - unlocked = nextAdventureMissionStage > importedCard.stageCount, - ) - } - - return ImportResult( - success = true, - message = "Imported ${importedCard.name} slot $slotId from VitalWear." - ) - } - - /** - * Import a VitalWear character with a forced device type. - * Used by HCE (VitalWear) read path to ensure imports are marked as BE. - * - * @param character Protobuf character from VitalWear HCE - * @param forcedDeviceType Always use this device type for the imported character (e.g., BEDevice for HCE) - */ - fun importCharacter(character: Character, forcedDeviceType: DeviceType): ImportResult { - val importedCard = resolveCard(character) - ?: createPlaceholderCard(character) - ?: return ImportResult( - success = false, - message = "Matching card not found in VBHelper. Import that card first." - ) - - val slotId = character.characterStats.slotId - val cardCharacter = database.characterDao().getCharacterByMonIndexOrNull(slotId, importedCard.id) - ?: createPlaceholderCharacter(importedCard, character) - ?: return ImportResult( - success = false, - message = "Character slot $slotId was not found on card ${importedCard.name}." - ) - - database.userCharacterDao().clearActiveCharacter() - - val totalBattles = character.characterStats.totalBattles - val totalWins = character.characterStats.totalWins.coerceAtMost(totalBattles) - val currentPhaseBattles = character.characterStats.currentPhaseBattles - val currentPhaseWins = character.characterStats.currentPhaseWins.coerceAtMost(currentPhaseBattles) - val normalizedTransformationCountdown = normalizeTransformationCountdownMinutes( - transformationCountdownMinutes = secondsToMinutes(character.characterStats.timeUntilNextTransformation), - hasPossibleTransformations = hasPossibleTransformations(cardCharacter.id), - ) - - val userCharacterId = database.userCharacterDao().insertCharacterData( - UserCharacter( - charId = cardCharacter.id, - ageInDays = character.characterStats.ageInDays.takeIf { it > 0 } - ?: max(character.transformationHistoryCount - 1, 0), - mood = character.characterStats.mood, - vitalPoints = character.characterStats.vitals, - transformationCountdown = normalizedTransformationCountdown, - injuryStatus = resolveInjuryStatus(character.characterStats.injured), - trophies = character.characterStats.trainedPp, - currentPhaseBattlesWon = currentPhaseWins, - currentPhaseBattlesLost = (currentPhaseBattles - currentPhaseWins).coerceAtLeast(0), - totalBattlesWon = totalWins, - totalBattlesLost = (totalBattles - totalWins).coerceAtLeast(0), - activityLevel = character.characterStats.activityLevel.coerceAtLeast(0), - heartRateCurrent = character.characterStats.heartRateCurrent.coerceAtLeast(0), - characterType = forcedDeviceType, - isActive = true - ) - ) - - runBlocking { - database.vitalWearSettingsDao().upsert( - VitalWearCharacterSettings( - characterId = userCharacterId, - trainingInBackground = character.settings.trainingInBackground, - allowedBattles = character.settings.allowedBattles.number, - accumulatedDailyInjuries = character.characterStats.accumulatedDailyInjuries, - ) - ) - } - - val vbProfile = VBCharacterData( - id = userCharacterId, - generation = if (character.characterStats.generation > 0) { - character.characterStats.generation - } else { - max(character.transformationHistoryCount - 1, 0) - }, - totalTrophies = if (character.characterStats.totalTrophies > 0) { - character.characterStats.totalTrophies - } else { - character.characterStats.trainedPp.coerceAtLeast(0) - } - ) - database.userCharacterDao().insertVBCharacterData(vbProfile) - - if (forcedDeviceType == DeviceType.BEDevice) { - val abilityRarity = resolveAbilityRarity(character.characterStats.abilityRarity) - database.userCharacterDao().insertBECharacterData( - BECharacterData( - id = userCharacterId, - trainingHp = character.characterStats.trainedHp, - trainingAp = character.characterStats.trainedAp, - trainingBp = character.characterStats.trainedBp, - remainingTrainingTimeInMinutes = secondsToMinutes(character.characterStats.trainingTimeRemainingInSeconds), - itemEffectMentalStateValue = character.characterStats.itemEffectMentalStateValue, - itemEffectMentalStateMinutesRemaining = character.characterStats.itemEffectMentalStateMinutesRemaining, - itemEffectActivityLevelValue = character.characterStats.itemEffectActivityLevelValue, - itemEffectActivityLevelMinutesRemaining = character.characterStats.itemEffectActivityLevelMinutesRemaining, - itemEffectVitalPointsChangeValue = character.characterStats.itemEffectVitalPointsChangeValue, - itemEffectVitalPointsChangeMinutesRemaining = character.characterStats.itemEffectVitalPointsChangeMinutesRemaining, - abilityRarity = abilityRarity, - abilityType = character.characterStats.abilityType, - abilityBranch = character.characterStats.abilityBranch, - abilityReset = character.characterStats.abilityReset, - rank = character.characterStats.rank, - itemType = character.characterStats.itemType, - itemMultiplier = character.characterStats.itemMultiplier, - itemRemainingTime = character.characterStats.itemRemainingTime, - otp0 = "", - otp1 = "", - minorVersion = character.characterStats.firmwareMinorVersion, - majorVersion = character.characterStats.firmwareMajorVersion - ) - ) - } - - val now = System.currentTimeMillis() - markSeen(importedCard.name, slotId, importedCard.id, now) - - var insertedTransformationCount = 0 - for (transformation in character.transformationHistoryList) { - val transformationCard = resolveRelatedCard( - incomingCardName = transformation.cardName, - incomingCardId = character.cardId, - matchedRootCard = importedCard, - incomingRootCardName = character.cardName, - ) - if (transformationCard != null) { - database.userCharacterDao().insertTransformation( - userCharacterId, - transformation.slotId, - transformationCard.id, - now - ) - insertedTransformationCount++ - markSeen(transformationCard.name, transformation.slotId, transformationCard.id, now) - } - } - - if (insertedTransformationCount == 0) { - database.userCharacterDao().insertTransformation( - userCharacterId, - slotId, - importedCard.id, - now - ) - } - - for ((cardName, maxAdventureCompleted) in character.maxAdventureCompletedByCardMap) { - val adventureCard = resolveRelatedCard( - incomingCardName = cardName, - incomingCardId = null, - matchedRootCard = importedCard, - incomingRootCardName = character.cardName, - ) ?: continue - val currentStage = (maxAdventureCompleted + 1).coerceAtLeast(0) - database.cardProgressDao().updateCardProgress( - currentStage = currentStage, - cardId = adventureCard.id, - unlocked = currentStage > adventureCard.stageCount - ) - } - - val nextAdventureMissionStage = character.characterStats.nextAdventureMissionStage.coerceAtLeast(0) - if (nextAdventureMissionStage > 0) { - database.cardProgressDao().updateCardProgress( - currentStage = nextAdventureMissionStage, - cardId = importedCard.id, - unlocked = nextAdventureMissionStage > importedCard.stageCount, - ) - } - return ImportResult( success = true, message = "Imported ${importedCard.name} slot $slotId from VitalWear." @@ -380,172 +120,26 @@ class VitalWearCharacterImporter( private fun resolveCard(character: Character) = resolveCard(character.cardName, character.cardId) - private fun resolveCard(cardName: String?, cardId: Int?): Card? { - return selectImportedCard( - candidates = database.cardDao().getAllCards(), - incomingCardName = cardName, - incomingCardId = cardId, - ) - } + private fun resolveCard(cardName: String?, cardId: Int?): com.github.nacabaro.vbhelper.domain.card.Card? { + if (!cardName.isNullOrBlank()) { + database.cardDao().getCardByName(cardName)?.let { return it } + } - private fun resolveRelatedCard( - incomingCardName: String?, - incomingCardId: Int?, - matchedRootCard: Card, - incomingRootCardName: String, - ): Card? { - if (incomingCardName.isNullOrBlank()) { - return if (incomingCardId != null && incomingCardId > 0 && matchedRootCard.cardId == incomingCardId) { - matchedRootCard - } else { - null + if (cardId != null) { + val matches = database.cardDao().getCardByCardId(cardId) + if (matches.size == 1) { + return matches.first() } } - if (cardNamesMatch(incomingCardName, incomingRootCardName)) { - return matchedRootCard - } - - return resolveCard(incomingCardName, incomingCardId) - } - - private fun createPlaceholderCard(character: Character): Card? { - if (!character.hasTransferCard() || !character.hasTransferSpecies()) { - return null - } - - val transferCard = character.transferCard - selectImportedCard( - candidates = database.cardDao().getAllCards(), - incomingCardName = transferCard.cardName, - incomingCardId = transferCard.cardId, - )?.let { return it } - - val transferSpecies = character.transferSpecies - val baseName = transferCard.cardName - .takeIf { it.isNotBlank() } - ?: character.cardName.takeIf { it.isNotBlank() } - ?: "Transferred Card ${transferCard.cardId}" - val placeholderName = buildPlaceholderCardName(baseName, transferCard.cardId) - val logoBytes = when { - !transferSpecies.nameSprite.isEmpty -> transferSpecies.nameSprite.toByteArray() - !transferSpecies.idle1.isEmpty -> transferSpecies.idle1.toByteArray() - else -> byteArrayOf() - } - val logoWidth = when { - transferSpecies.nameSpriteWidth > 0 -> transferSpecies.nameSpriteWidth - transferSpecies.spriteWidth > 0 -> transferSpecies.spriteWidth - else -> 0 - } - val logoHeight = when { - transferSpecies.nameSpriteHeight > 0 -> transferSpecies.nameSpriteHeight - transferSpecies.spriteHeight > 0 -> transferSpecies.spriteHeight - else -> 0 - } - - val insertedId = runBlocking { - database.cardDao().insertNewCard( - Card( - cardId = transferCard.cardId, - logo = logoBytes, - logoWidth = logoWidth, - logoHeight = logoHeight, - name = placeholderName, - stageCount = transferCard.stageCount.coerceAtLeast(0), - isBEm = transferCard.isBem, - ) - ) - } - val resolvedCard = when { - insertedId > 0L -> database.cardDao().getCardById(insertedId) - else -> database.cardDao().getCardByName(placeholderName) - } ?: return null - - if (database.cardProgressDao().getCardProgressSync(resolvedCard.id) == null) { - database.cardProgressDao().insertCardProgress( - CardProgress( - cardId = resolvedCard.id, - currentStage = 1, - unlocked = false, - ) - ) - } - - return resolvedCard - } - - private fun createPlaceholderCharacter(card: Card, character: Character): CardCharacter? { - if (!character.hasTransferSpecies()) { - return null - } - - val transferSpecies = character.transferSpecies - database.characterDao().getCharacterByMonIndexOrNull(transferSpecies.slotId, card.id)?.let { return it } - - val spriteId = database.spriteDao().insertSprite( - Sprite( - spriteIdle1 = transferSpecies.idle1.toByteArray(), - spriteIdle2 = transferSpecies.idle2.toByteArray(), - spriteWalk1 = transferSpecies.walk1.toByteArray(), - spriteWalk2 = transferSpecies.walk2.toByteArray(), - spriteRun1 = transferSpecies.run1.toByteArray(), - spriteRun2 = transferSpecies.run2.toByteArray(), - spriteTrain1 = transferSpecies.train1.toByteArray(), - spriteTrain2 = transferSpecies.train2.toByteArray(), - spriteHappy = transferSpecies.win.toByteArray(), - spriteSleep = transferSpecies.down.toByteArray(), - spriteAttack = transferSpecies.attack.toByteArray(), - spriteDodge = transferSpecies.dodge.toByteArray(), - width = transferSpecies.spriteWidth, - height = transferSpecies.spriteHeight, - ) - ) - - val attribute = enumValues().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 + return null } private fun secondsToMinutes(seconds: Long): Int { if (seconds <= 0L) { return 0 } - // Preserve non-zero timers from HCE payloads instead of flooring 1..59s to 0. - return ((seconds + 59L) / 60L).coerceAtMost(Int.MAX_VALUE.toLong()).toInt() - } - - private fun hasPossibleTransformations(cardCharacterId: Long): Boolean { - return runBlocking { - database.characterDao().getEvolutionRequirementsForCard(cardCharacterId).firstOrNull()?.isNotEmpty() == true - } + return (seconds / 60L).coerceAtMost(Int.MAX_VALUE.toLong()).toInt() } private fun resolveInjuryStatus(injured: Boolean): NfcCharacter.InjuryStatus { @@ -566,66 +160,4 @@ class VitalWearCharacterImporter( private fun resolveDefaultAbilityRarity(): NfcCharacter.AbilityRarity { return enumValues().first() } - - private fun resolveAbilityRarity(rawValue: Int): NfcCharacter.AbilityRarity { - val rarities = enumValues() - return rarities.getOrElse(rawValue) { resolveDefaultAbilityRarity() } - } - - private fun markSeen(cardName: String, slotId: Int, cardId: Long, timestamp: Long) { - database.dexDao().insertCharacter(slotId, cardId, timestamp) - transferSeenDao.markSeen(cardName, slotId, timestamp) - } } - -internal fun selectImportedCard( - candidates: List, - incomingCardName: String?, - incomingCardId: Int?, -): Card? { - val requestedName = incomingCardName?.takeIf { it.isNotBlank() } - val idMatches = if (incomingCardId != null && incomingCardId > 0) { - candidates.filter { it.cardId == incomingCardId } - } else { - emptyList() - } - - requestedName?.let { requestedNameValue -> - candidates.firstOrNull { - it.name == requestedNameValue && (incomingCardId == null || incomingCardId <= 0 || it.cardId == incomingCardId) - }?.let { return it } - - val ignoreCaseMatches = candidates.filter { - it.name.equals(requestedNameValue, ignoreCase = true) && - (incomingCardId == null || incomingCardId <= 0 || it.cardId == incomingCardId) - } - if (ignoreCaseMatches.size == 1) { - return ignoreCaseMatches.single() - } - } - - if (idMatches.size == 1) { - return idMatches.single() - } - - requestedName?.let { requestedNameValue -> - val normalizedMatches = candidates.filter { - cardNamesMatch(it.name, requestedNameValue) && - (incomingCardId == null || incomingCardId <= 0 || it.cardId == incomingCardId) - } - if (normalizedMatches.size == 1) { - return normalizedMatches.single() - } - } - - return null -} - -internal fun cardNamesMatch(left: String, right: String): Boolean { - return left.equals(right, ignoreCase = true) || left.toNormalizedCardLookupKey() == right.toNormalizedCardLookupKey() -} - -private fun String.toNormalizedCardLookupKey(): String { - return lowercase().filter { it.isLetterOrDigit() } -} - 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 a169743..eae66e2 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.ARGB_8888) + return Bitmap.createBitmap(createARGBIntArray(), this.width, this.height, Bitmap.Config.HARDWARE) } object ARGBMasks { @@ -64,7 +64,7 @@ fun BitmapData.getObscuredBitmap(): Bitmap { argbPixels[i] = BLACK } } - return Bitmap.createBitmap(argbPixels, this.width, this.height, Bitmap.Config.ARGB_8888) + return Bitmap.createBitmap(argbPixels, this.width, this.height, Bitmap.Config.HARDWARE) } diff --git a/app/src/main/java/com/github/nacabhelper/screens/BattlesScreen.kt b/app/src/main/java/com/github/nacabhelper/screens/BattlesScreen.kt new file mode 100644 index 0000000..0519ecb --- /dev/null +++ b/app/src/main/java/com/github/nacabhelper/screens/BattlesScreen.kt @@ -0,0 +1 @@ + \ No newline at end of file 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 973ab02..263b4d8 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,48 +7,7 @@ message Character { string card_name = 1; int32 card_id = 2; - message TransferCard { - int32 card_id = 1; - string card_name = 2; - bool is_bem = 3; - int32 stage_count = 4; - int32 franchise = 5; - } - - message TransferSpecies { - int32 slot_id = 1; - int32 stage = 2; - int32 attribute = 3; - int32 base_hp = 4; - int32 base_bp = 5; - int32 base_ap = 6; - bytes name_sprite = 7; - int32 name_sprite_width = 8; - int32 name_sprite_height = 9; - bytes idle1 = 10; - bytes idle2 = 11; - bytes walk1 = 12; - bytes walk2 = 13; - bytes run1 = 14; - bytes run2 = 15; - bytes train1 = 16; - bytes train2 = 17; - bytes win = 18; - bytes down = 19; - bytes attack = 20; - bytes dodge = 21; - bytes splash = 22; - int32 sprite_width = 23; - int32 sprite_height = 24; - } - message CharacterStats { - enum TransferDeviceType { - TRANSFER_DEVICE_TYPE_UNSPECIFIED = 0; - TRANSFER_DEVICE_TYPE_VB = 1; - TRANSFER_DEVICE_TYPE_BE = 2; - } - int32 slot_id = 1; int32 vitals = 2; int64 training_time_remaining_in_seconds = 3; @@ -64,29 +23,6 @@ message Character { int32 total_wins = 13; int32 current_phase_wins = 14; int32 mood = 15; - TransferDeviceType device_type = 16; - int32 age_in_days = 17; - int32 activity_level = 18; - int32 heart_rate_current = 19; - int32 generation = 20; - int32 total_trophies = 21; - int32 next_adventure_mission_stage = 22; - int32 item_effect_mental_state_value = 23; - int32 item_effect_mental_state_minutes_remaining = 24; - int32 item_effect_activity_level_value = 25; - int32 item_effect_activity_level_minutes_remaining = 26; - int32 item_effect_vital_points_change_value = 27; - int32 item_effect_vital_points_change_minutes_remaining = 28; - int32 ability_rarity = 29; - int32 ability_type = 30; - int32 ability_branch = 31; - int32 ability_reset = 32; - int32 rank = 33; - int32 item_type = 34; - int32 item_multiplier = 35; - int32 item_remaining_time = 36; - int32 firmware_minor_version = 37; - int32 firmware_major_version = 38; } CharacterStats character_stats = 3; @@ -112,8 +48,4 @@ message Character { repeated TransformationEvent transformation_history = 5; map 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 8283b4d..fdc0104 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -56,14 +56,6 @@ Ops! DIM enviado com sucesso! O NFC precisa estar ativado. - O dispositivo de destino está cheio (ativo + backup). Remova um personagem antes de enviar. - Transferência ignorada. O envio só é permitido quando o brinquedo tem um personagem ativo e o slot de backup está vazio. - Pronto. Encoste e mantenha o celular e o relógio juntos até concluir. - Conexão detectada. Mantenha os dispositivos juntos... - Transferindo agora. Não afaste o celular ainda. - Transferência concluída. Agora você pode afastar o celular. - Falha na transferência. Reposicione os dispositivos e tente novamente. - Transferência cancelada. Configurações @@ -178,7 +170,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 90d71c0..e5221ec 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -60,25 +60,14 @@ Whoops Sent DIM successfully! NFC must be enabled - Target device is full (active + backup). Remove one character before sending. - Transfer skipped. Send is allowed only when the toy has an active character and an empty backup slot. - Ready. Tap and hold phone/watch together until complete. - Connection detected. Keep devices together... - Transferring now. Do not remove phone yet. - 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 - VBH-VW card management + DiM/BEm management About and credits Data management - VitalWear companion tools Import APK @@ -87,12 +76,7 @@ Import card - Import DIM/BEM cards into VBHelper\'s Dex. - - - Scale DIM cards to BEM stats - - Automatically upscale DIM card stats to BEM level when importing + Import DiM/BEm Credits @@ -111,27 +95,6 @@ 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 @@ -216,7 +179,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 b4b063a..8a3c842 100644 --- a/app/src/main/res/xml/provider_paths.xml +++ b/app/src/main/res/xml/provider_paths.xml @@ -1,8 +1,5 @@ - diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5f078f3..7e4875e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,13 +16,7 @@ protobufGradlePlugin = "0.9.6" protobufJavalite = "4.33.4" roomRuntime = "2.8.4" vbNfcReader = "0.2.0-SNAPSHOT" -dimReader = "2.0.0" -playServicesWearable = "19.0.0" -kotlinxCoroutinesPlayServices = "1.4.3-native-mt" -timber = "5.0.1" -tinyLog = "2.4.1" -slf4jTimber = "1.0.0" -slf4jApi = "2.0.16" +dimReader = "2.1.0" [libraries] androidx-compose-material = { module = "androidx.compose.material:material" } @@ -52,13 +46,6 @@ protobuf-gradle-plugin = { module = "com.google.protobuf:protobuf-gradle-plugin" protobuf-javalite = { module = "com.google.protobuf:protobuf-javalite", version.ref = "protobufJavalite" } vb-nfc-reader = { module = "com.github.cfogrady:vb-nfc-reader", version.ref = "vbNfcReader" } dim-reader = { module = "com.github.cfogrady:vb-dim-reader", version.ref = "dimReader" } -play-services-wearable = { module = "com.google.android.gms:play-services-wearable", version.ref = "playServicesWearable" } -kotlinx-coroutines-play-services = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-play-services", version.ref = "kotlinxCoroutinesPlayServices" } -timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } -tiny-log = { module = "org.tinylog:tinylog-api", version.ref = "tinyLog" } -tiny-log-impl = { module = "org.tinylog:tinylog-impl", version.ref = "tinyLog" } -slf4j-timber = { module = "at.favre.lib:slf4j-timber", version.ref = "slf4jTimber" } -slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4jApi" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 5e8316b..917b611 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -17,16 +17,8 @@ dependencyResolutionManagement { mavenLocal() google() mavenCentral() - maven { url = uri("https://jitpack.io") } } } rootProject.name = "VBHelper" include(":app") - - -includeBuild("../vb-nfc-reader") { - dependencySubstitution { - substitute(module("com.github.cfogrady:vb-nfc-reader")).using(project(":")) - } -}