From 5273bae7b66eba9d07771dbdeda0689d5b65d5fe Mon Sep 17 00:00:00 2001
From: Taveon Nelson <74945303+457R0@users.noreply.github.com>
Date: Sat, 18 Apr 2026 07:24:06 -0500
Subject: [PATCH 01/13] Update: changes and new files from local development
---
app/lint-baseline.xml | 1782 +++++++++++++++++
app/src/main/AndroidManifest.xml | 16 +-
.../github/nacabaro/vbhelper/MainActivity.kt | 65 +
.../github/nacabaro/vbhelper/daos/CardDao.kt | 17 +-
.../nacabaro/vbhelper/daos/CardProgressDao.kt | 5 +-
.../vbhelper/daos/UserCharacterDao.kt | 18 +-
.../nacabaro/vbhelper/dtos/CharacterDtos.kt | 9 +-
.../screens/homeScreens/HomeScreen.kt | 97 +-
.../scanScreen/ChooseConnectionScreen.kt | 5 +
.../screens/storageScreen/StorageDialog.kt | 10 +-
.../screens/storageScreen/StorageScreen.kt | 16 +
.../source/VitalWearCharacterExporter.kt | 114 ++
.../source/VitalWearCharacterImporter.kt | 163 ++
.../nacabaro/vbhelper/utils/DeviceType.kt | 12 +-
.../cfogrady/vitalwear/protos/Character.proto | 51 +
app/src/main/res/values/strings.xml | 7 +-
app/src/main/res/xml/provider_paths.xml | 6 +
17 files changed, 2346 insertions(+), 47 deletions(-)
create mode 100644 app/lint-baseline.xml
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterExporter.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterImporter.kt
create mode 100644 app/src/main/proto/com/github/cfogrady/vitalwear/protos/Character.proto
create mode 100644 app/src/main/res/xml/provider_paths.xml
diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml
new file mode 100644
index 0000000..48625e4
--- /dev/null
+++ b/app/lint-baseline.xml
@@ -0,0 +1,1782 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 8e59f52..39fa631 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -22,6 +22,15 @@
android:theme="@style/Theme.VBHelper"
android:usesCleartextTraffic="true"
tools:targetApi="31">
+
+
+
+
+
+
+
+
-
\ No newline at end of file
+
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 c8f358b..fc13bae 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/MainActivity.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/MainActivity.kt
@@ -1,11 +1,16 @@
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.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
@@ -17,7 +22,10 @@ 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() {
@@ -70,6 +78,7 @@ class MainActivity : ComponentActivity() {
}
Log.i("MainActivity", "Activity onCreated")
+ handleImportIntent(intent)
}
override fun onPause() {
@@ -88,6 +97,58 @@ class MainActivity : ComponentActivity() {
}
}
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ setIntent(intent)
+ 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
+ }
+ }
+
@Composable
private fun MainApplication(
scanScreenController: ScanScreenControllerImpl,
@@ -112,4 +173,8 @@ class MainActivity : ComponentActivity() {
)
)
}
+
+ companion object {
+ private const val VITALWEAR_CHARACTER_MIME = "application/x-vitalwear-character"
+ }
}
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 c87a1f7..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
@@ -19,6 +19,9 @@ interface CardDao {
@Query("SELECT * FROM Card WHERE id = :id")
fun getCardById(id: Long): Card?
+ @Query("SELECT * FROM Card WHERE LOWER(name) = LOWER(:name) LIMIT 1")
+ fun getCardByName(name: String): Card?
+
@Query(
"""
SELECT ca.*
@@ -30,6 +33,18 @@ interface CardDao {
)
fun getCardByCharacterId(id: Long): Flow
+ @Query(
+ """
+ SELECT ca.*
+ FROM Card ca
+ JOIN CardCharacter ch ON ca.id = ch.cardId
+ JOIN UserCharacter uc ON ch.id = uc.charId
+ WHERE uc.id = :id
+ LIMIT 1
+ """
+ )
+ fun getCardByCharacterIdSync(id: Long): Card?
+
@Query("UPDATE Card SET name = :newName WHERE id = :id")
suspend fun renameCard(id: Int, newName: String)
@@ -46,4 +61,4 @@ interface CardDao {
WHERE cc.id = :charaId
""")
fun getCardIconByCharaId(charaId: Long): Flow
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/daos/CardProgressDao.kt b/app/src/main/java/com/github/nacabaro/vbhelper/daos/CardProgressDao.kt
index 3fde614..93d86c3 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/daos/CardProgressDao.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/daos/CardProgressDao.kt
@@ -24,6 +24,9 @@ interface CardProgressDao {
)
fun getCardProgress(cardId: Long): Flow
+ @Query("SELECT currentStage FROM CardProgress WHERE cardId = :cardId LIMIT 1")
+ fun getCardProgressSync(cardId: Long): Int?
+
@Insert
fun insertCardProgress(cardProgress: CardProgress)
-}
\ 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 986ce01..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
@@ -55,6 +55,22 @@ interface UserCharacterDao {
)
fun getTransformationHistory(monId: Long): Flow>
+ @Query(
+ """
+ SELECT
+ t.stageId as stageId,
+ c.charaIndex AS monIndex,
+ ca.name as cardName,
+ t.transformationDate AS transformationDate
+ FROM TransformationHistory t
+ JOIN CardCharacter c ON c.id = t.stageId
+ JOIN Card ca ON ca.id = c.cardId
+ WHERE t.monId = :monId
+ ORDER BY t.transformationDate ASC, t.id ASC
+ """
+ )
+ suspend fun getTransformationHistoryForExport(monId: Long): List
+
@Query(
"""
SELECT
@@ -234,4 +250,4 @@ interface UserCharacterDao {
"""
)
suspend fun getVBDimCharacters(): List
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/dtos/CharacterDtos.kt b/app/src/main/java/com/github/nacabaro/vbhelper/dtos/CharacterDtos.kt
index 3cb7831..0131e6f 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/dtos/CharacterDtos.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/dtos/CharacterDtos.kt
@@ -52,6 +52,13 @@ object CharacterDtos {
val transformationDate: Long
)
+ data class TransformationHistoryExport(
+ val stageId: Long,
+ val monIndex: Int,
+ val cardName: String,
+ val transformationDate: Long
+ )
+
data class CardCharaProgress(
val id: Long,
val spriteIdle: ByteArray,
@@ -125,4 +132,4 @@ object CharacterDtos {
val discoveredOn: Long?,
val fusionAttribute: NfcCharacter.Attribute
)
-}
\ No newline at end of file
+}
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 0cacb5d..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
@@ -2,6 +2,7 @@ package com.github.nacabaro.vbhelper.screens.homeScreens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -40,6 +41,8 @@ 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
@@ -136,42 +139,66 @@ fun HomeScreen(
Text(text = stringResource(R.string.adventure_empty_state))
}
} else {
- val cardIcon = BitmapData(
- bitmap = cardIconData!!.cardIcon,
- width = cardIconData!!.cardIconWidth,
- height = cardIconData!!.cardIconHeight
- )
+ Column(modifier = Modifier.padding(top = contentPadding.calculateTopPadding())) {
+ val cardIcon = BitmapData(
+ bitmap = cardIconData!!.cardIcon,
+ width = cardIconData!!.cardIconWidth,
+ height = cardIconData!!.cardIconHeight
+ )
- if (activeMon!!.isBemCard && beData != null) {
- BEBEmHomeScreen(
- activeMon = activeMon!!,
- beData = beData!!,
- transformationHistory = transformationHistory,
- contentPadding = contentPadding,
- cardIcon = cardIcon
- )
- } else if (!activeMon!!.isBemCard && activeMon!!.characterType == DeviceType.BEDevice && beData != null) {
- BEDiMHomeScreen(
- activeMon = activeMon!!,
- beData = beData!!,
- transformationHistory = transformationHistory,
- contentPadding = contentPadding,
- cardIcon = cardIcon
- )
- } else if (vbData != null) {
- VBDiMHomeScreen(
- activeMon = activeMon!!,
- vbData = vbData!!,
- transformationHistory = transformationHistory,
- contentPadding = contentPadding,
- specialMissions = vbSpecialMissions,
- homeScreenController = homeScreenController,
- onClickCollect = { item, currency ->
- collectedItem = item
- collectedCurrency = currency
+ if (activeMon!!.isBemCard && beData != null) {
+ BEBEmHomeScreen(
+ activeMon = activeMon!!,
+ beData = beData!!,
+ transformationHistory = transformationHistory,
+ contentPadding = PaddingValues(0.dp),
+ cardIcon = cardIcon
+ )
+ } else if (!activeMon!!.isBemCard && activeMon!!.characterType == DeviceType.BEDevice && beData != null) {
+ BEDiMHomeScreen(
+ activeMon = activeMon!!,
+ beData = beData!!,
+ transformationHistory = transformationHistory,
+ contentPadding = PaddingValues(0.dp),
+ cardIcon = cardIcon
+ )
+ } else if (vbData != null) {
+ VBDiMHomeScreen(
+ activeMon = activeMon!!,
+ vbData = vbData!!,
+ transformationHistory = transformationHistory,
+ contentPadding = PaddingValues(0.dp),
+ specialMissions = vbSpecialMissions,
+ homeScreenController = homeScreenController,
+ onClickCollect = { item, currency ->
+ collectedItem = item
+ collectedCurrency = currency
+ },
+ cardIcon = cardIcon
+ )
+ }
+
+ 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()
+ }
},
- cardIcon = cardIcon
- )
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp)
+ ) {
+ Text(text = "Send to VitalWear")
+ }
}
}
}
@@ -221,5 +248,3 @@ fun HomeScreen(
}
}
}
-
-
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 d2025aa..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
@@ -42,6 +42,11 @@ fun ChooseConnectOption(
.fillMaxSize()
.padding(contentPadding)
) {
+ Text(
+ text = "Bandai Toys = Vital Bracelet, Vital Hero, BE Bracelet",
+ fontSize = 14.sp,
+ modifier = Modifier.padding(bottom = 24.dp)
+ )
ScanButton(
text = stringResource(R.string.scan_vb_to_app),
disabled = onClickRead == null,
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/storageScreen/StorageDialog.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/storageScreen/StorageDialog.kt
index 33ac860..979f47d 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/storageScreen/StorageDialog.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/storageScreen/StorageDialog.kt
@@ -43,6 +43,7 @@ fun StorageDialog(
onDismissRequest: () -> Unit,
onClickDelete: () -> Unit,
onSendToBracelet: () -> Unit,
+ onSendToVitalWear: () -> Unit,
onClickSetActive: () -> Unit,
onClickSendToAdventure: (time: Long) -> Unit
) {
@@ -136,6 +137,13 @@ fun StorageDialog(
Text(text = stringResource(R.string.storage_set_active))
}
}
+ Button(
+ onClick = onSendToVitalWear,
+ modifier = Modifier
+ .fillMaxWidth()
+ ) {
+ Text(text = stringResource(R.string.storage_send_to_vitalwear))
+ }
Button(
onClick = {
onSendToAdventureClicked = true
@@ -171,4 +179,4 @@ fun StorageDialog(
onDismissRequest = { onSendToAdventureClicked = false }
)
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/storageScreen/StorageScreen.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/storageScreen/StorageScreen.kt
index e33e6dd..e5e770d 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/storageScreen/StorageScreen.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/storageScreen/StorageScreen.kt
@@ -33,6 +33,7 @@ import com.github.nacabaro.vbhelper.di.VBHelper
import com.github.nacabaro.vbhelper.navigation.NavigationItems
import com.github.nacabaro.vbhelper.screens.adventureScreen.AdventureScreenControllerImpl
import com.github.nacabaro.vbhelper.source.StorageRepository
+import com.github.nacabaro.vbhelper.source.VitalWearCharacterExporter
import com.github.nacabaro.vbhelper.utils.BitmapData
import androidx.compose.ui.res.stringResource
import com.github.nacabaro.vbhelper.R
@@ -136,6 +137,21 @@ fun StorageScreen(
)
)
},
+ onSendToVitalWear = {
+ try {
+ val intent = VitalWearCharacterExporter(application, application.container.db)
+ .buildShareIntent(selectedCharacter!!)
+ .addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
+ application.startActivity(intent)
+ selectedCharacter = null
+ } catch (e: Exception) {
+ Toast.makeText(
+ application,
+ "Could not send character to VitalWear: ${e.message}",
+ Toast.LENGTH_LONG
+ ).show()
+ }
+ },
onClickSendToAdventure = { time ->
adventureScreenController
.sendCharacterToAdventure(
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
new file mode 100644
index 0000000..bc3ad95
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterExporter.kt
@@ -0,0 +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.utils.DeviceType
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.runBlocking
+import java.io.File
+
+class VitalWearCharacterExporter(
+ private val context: Context,
+ private val database: AppDatabase
+) {
+ 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()
+ )
+ .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 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 resolveTrainedBp(characterId: Long, deviceType: DeviceType): Int {
+ return if (deviceType == DeviceType.BEDevice) {
+ database.userCharacterDao().getBeData(characterId).valueOrNull()?.trainingBp ?: 0
+ } else 0
+ }
+
+ private fun resolveTrainedHp(characterId: Long, deviceType: DeviceType): Int {
+ return if (deviceType == DeviceType.BEDevice) {
+ database.userCharacterDao().getBeData(characterId).valueOrNull()?.trainingHp ?: 0
+ } else 0
+ }
+
+ private fun resolveTrainedAp(characterId: Long, deviceType: DeviceType): Int {
+ return if (deviceType == DeviceType.BEDevice) {
+ database.userCharacterDao().getBeData(characterId).valueOrNull()?.trainingAp ?: 0
+ } else 0
+ }
+
+ 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
new file mode 100644
index 0000000..6ec3b65
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterImporter.kt
@@ -0,0 +1,163 @@
+package com.github.nacabaro.vbhelper.source
+
+import com.github.cfogrady.vbnfc.data.NfcCharacter
+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.domain.device_data.UserCharacter
+import com.github.nacabaro.vbhelper.utils.DeviceType
+import kotlin.math.max
+
+class VitalWearCharacterImporter(
+ private val database: AppDatabase
+) {
+ data class ImportResult(
+ val success: Boolean,
+ val message: String
+ )
+
+ fun importCharacter(character: Character): ImportResult {
+ val importedCard = resolveCard(character)
+ ?: return ImportResult(
+ success = false,
+ message = "Matching card not found in VBHelper. Import that card first."
+ )
+
+ val slotId = character.characterStats.slotId
+ val cardCharacter = runCatching {
+ database.characterDao().getCharacterByMonIndex(slotId, importedCard.id)
+ }.getOrNull() ?: return ImportResult(
+ 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 userCharacterId = database.userCharacterDao().insertCharacterData(
+ UserCharacter(
+ charId = cardCharacter.id,
+ ageInDays = max(character.transformationHistoryCount - 1, 0),
+ mood = character.characterStats.mood,
+ vitalPoints = character.characterStats.vitals,
+ 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 = 0,
+ heartRateCurrent = 0,
+ characterType = DeviceType.BEDevice,
+ isActive = true
+ )
+ )
+
+ 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 now = System.currentTimeMillis()
+ database.dexDao().insertCharacter(slotId, importedCard.id, now)
+
+ for (transformation in character.transformationHistoryList) {
+ val transformationCard = resolveCard(transformation.cardName, character.cardId)
+ if (transformationCard != null) {
+ database.userCharacterDao().insertTransformation(
+ userCharacterId,
+ transformation.slotId,
+ transformationCard.id,
+ now
+ )
+ database.dexDao().insertCharacter(transformation.slotId, transformationCard.id, now)
+ }
+ }
+
+ for ((cardName, maxAdventureCompleted) in character.maxAdventureCompletedByCardMap) {
+ val adventureCard = resolveCard(cardName, null) ?: continue
+ val currentStage = (maxAdventureCompleted + 1).coerceAtLeast(0)
+ database.cardProgressDao().updateCardProgress(
+ currentStage = currentStage,
+ cardId = adventureCard.id,
+ unlocked = currentStage > adventureCard.stageCount
+ )
+ }
+
+ return ImportResult(
+ success = true,
+ message = "Imported ${importedCard.name} slot $slotId from VitalWear."
+ )
+ }
+
+ private fun resolveCard(character: Character) = resolveCard(character.cardName, character.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 }
+ }
+
+ if (cardId != null) {
+ val matches = database.cardDao().getCardByCardId(cardId)
+ if (matches.size == 1) {
+ return matches.first()
+ }
+ }
+
+ return null
+ }
+
+ private fun secondsToMinutes(seconds: Long): Int {
+ if (seconds <= 0L) {
+ return 0
+ }
+ return (seconds / 60L).coerceAtMost(Int.MAX_VALUE.toLong()).toInt()
+ }
+
+ private fun resolveInjuryStatus(injured: Boolean): NfcCharacter.InjuryStatus {
+ val statuses = enumValues()
+ if (!injured) {
+ return statuses.firstOrNull {
+ val normalized = it.name.lowercase()
+ normalized.contains("none") || normalized.contains("normal") || normalized.contains("healthy")
+ } ?: statuses.first()
+ }
+
+ return statuses.firstOrNull {
+ val normalized = it.name.lowercase()
+ normalized.contains("inj")
+ } ?: statuses.last()
+ }
+
+ private fun resolveDefaultAbilityRarity(): NfcCharacter.AbilityRarity {
+ return enumValues().first()
+ }
+}
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/utils/DeviceType.kt b/app/src/main/java/com/github/nacabaro/vbhelper/utils/DeviceType.kt
index 5d06c67..7912e82 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/utils/DeviceType.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/utils/DeviceType.kt
@@ -1,6 +1,14 @@
package com.github.nacabaro.vbhelper.utils
enum class DeviceType {
- VBDevice,
- BEDevice
+ VBDevice, // Bandai Vital Bracelet
+ BEDevice, // Bandai BE Bracelet
+ VitalWear // WearOS VitalWear app
+}
+
+fun detectDeviceType(deviceName: String): DeviceType = when {
+ deviceName.startsWith("VitalWear", ignoreCase = true) || deviceName.startsWith("VW-", ignoreCase = true) -> DeviceType.VitalWear
+ deviceName.startsWith("BE-", ignoreCase = true) -> DeviceType.BEDevice
+ deviceName.startsWith("VB-", ignoreCase = true) -> DeviceType.VBDevice
+ else -> DeviceType.VBDevice // Default/fallback, can be refined
}
\ 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
new file mode 100644
index 0000000..263b4d8
--- /dev/null
+++ b/app/src/main/proto/com/github/cfogrady/vitalwear/protos/Character.proto
@@ -0,0 +1,51 @@
+syntax = "proto3";
+
+option java_package = "com.github.cfogrady.vitalwear.protos";
+option java_multiple_files = true;
+
+message Character {
+ string card_name = 1;
+ int32 card_id = 2;
+
+ message CharacterStats {
+ int32 slot_id = 1;
+ int32 vitals = 2;
+ int64 training_time_remaining_in_seconds = 3;
+ int64 time_until_next_transformation = 4;
+ int32 trained_bp = 5;
+ int32 trained_hp = 6;
+ int32 trained_ap = 7;
+ int32 trained_pp = 8;
+ bool injured = 9;
+ int32 accumulated_daily_injuries = 10;
+ int32 total_battles = 11;
+ int32 current_phase_battles = 12;
+ int32 total_wins = 13;
+ int32 current_phase_wins = 14;
+ int32 mood = 15;
+ }
+ CharacterStats character_stats = 3;
+
+ message Settings {
+ bool training_in_background = 1;
+ enum AllowedBattles {
+ CARD_ONLY = 0;
+ ALL_FRANCHISE = 1;
+ ALL_FRANCHISE_AND_DIM = 2;
+ ALL = 3;
+ }
+ AllowedBattles allowed_battles = 2;
+ optional int32 assumed_franchise = 3;
+ }
+ Settings settings = 4;
+
+ message TransformationEvent {
+ string card_name = 1;
+ int32 phase = 2;
+ int32 slot_id = 3;
+ }
+
+ repeated TransformationEvent transformation_history = 5;
+
+ map max_adventure_completed_by_card = 6;
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index b5c51fd..886fb53 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -48,8 +48,8 @@
Scan a Vital Bracelet
- Vital Bracelet to App
- App to Vital Bracelet
+ Vital Bracelet to App (Receive Digimon from Bandai Toys: Vital Bracelet, Vital Hero, BE Bracelet)
+ App to Vital Bracelet (Send Digimon from VBHelper to Bandai Toys: Vital Bracelet, Vital Hero, BE Bracelet)
No NFC on device!
Tag detected is not VB
@@ -199,6 +199,7 @@
Character image
Send to watch
+ Send to VitalWear
Set active
Send on adventure
Delete character
@@ -226,4 +227,4 @@
Dismiss
Remove
-
\ No newline at end of file
+
diff --git a/app/src/main/res/xml/provider_paths.xml b/app/src/main/res/xml/provider_paths.xml
new file mode 100644
index 0000000..8a3c842
--- /dev/null
+++ b/app/src/main/res/xml/provider_paths.xml
@@ -0,0 +1,6 @@
+
+
+
+
From e787ea4a19890d01bca2a2c1413634686fe1fd2e Mon Sep 17 00:00:00 2001
From: Taveon Nelson <74945303+457R0@users.noreply.github.com>
Date: Sat, 18 Apr 2026 17:19:34 -0500
Subject: [PATCH 02/13] Initial commit of VBHelper
---
.../github/nacabaro/vbhelper/MainActivity.kt | 25 +-
.../vbhelper/navigation/AppNavigation.kt | 3 +-
.../navigation/BottomNavigationBar.kt | 15 +-
.../scanScreen/ScanScreenControllerImpl.kt | 22 +-
.../screens/settingsScreen/CreditsScreen.kt | 2 +
.../screens/storageScreen/StorageDialog.kt | 52 +-
.../screens/storageScreen/StorageScreen.kt | 15 -
app/src/main/res/values/strings.xml | 14 +-
build/reports/problems/problems-report.html | 663 ++++++++++++++++++
9 files changed, 745 insertions(+), 66 deletions(-)
create mode 100644 build/reports/problems/problems-report.html
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 fc13bae..9d178a1 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/MainActivity.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/MainActivity.kt
@@ -30,6 +30,7 @@ import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
private val onActivityLifecycleListeners = HashMap()
+ private var initialRoute: String? = null
private fun registerActivityLifecycleListener(key: String, activityLifecycleListener: ActivityLifecycleListener) {
if( onActivityLifecycleListeners[key] != null) {
@@ -62,6 +63,8 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge()
+ initialRoute = getInitialRouteFromIntent(intent)
+
setContent {
VBHelperTheme {
MainApplication(
@@ -72,7 +75,8 @@ class MainActivity : ComponentActivity() {
homeScreenController = homeScreenController,
storageScreenController = storageScreenController,
spriteViewerController = spriteViewerController,
- cardScreenController = cardScreenController
+ cardScreenController = cardScreenController,
+ initialRoute = initialRoute
)
}
}
@@ -100,6 +104,8 @@ class MainActivity : ComponentActivity() {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
+ initialRoute = getInitialRouteFromIntent(intent)
+ // Optionally, you may want to trigger navigation here if needed
handleImportIntent(intent)
}
@@ -149,6 +155,17 @@ class MainActivity : ComponentActivity() {
}
}
+ private fun getInitialRouteFromIntent(intent: Intent?): String? {
+ if (intent == null) return null
+ val data = intent.data
+ if (intent.action == Intent.ACTION_VIEW && data != null) {
+ if (data.scheme == "vbhelper" && data.host == "auth") {
+ return "Battle"
+ }
+ }
+ return null
+ }
+
@Composable
private fun MainApplication(
scanScreenController: ScanScreenControllerImpl,
@@ -158,7 +175,8 @@ class MainActivity : ComponentActivity() {
storageScreenController: StorageScreenControllerImpl,
homeScreenController: HomeScreenControllerImpl,
spriteViewerController: SpriteViewerControllerImpl,
- cardScreenController: CardScreenControllerImpl
+ cardScreenController: CardScreenControllerImpl,
+ initialRoute: String? = null
) {
AppNavigation(
applicationNavigationHandlers = AppNavigationHandlers(
@@ -170,7 +188,8 @@ class MainActivity : ComponentActivity() {
homeScreenController,
spriteViewerController,
cardScreenController
- )
+ ),
+ initialRoute = initialRoute
)
}
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 7d207b2..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
@@ -54,6 +54,7 @@ data class AppNavigationHandlers(
@Composable
fun AppNavigation(
applicationNavigationHandlers: AppNavigationHandlers,
+ initialRoute: String? = null
) {
val navController = rememberNavController()
@@ -64,7 +65,7 @@ fun AppNavigation(
) { contentPadding ->
NavHost(
navController = navController,
- startDestination = NavigationItems.Home.route,
+ startDestination = initialRoute ?: NavigationItems.Home.route,
enterTransition = {
fadeIn(
animationSpec = tween(200)
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 96f4275..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
@@ -30,10 +30,17 @@ fun BottomNavigationBar(navController: NavController) {
label = { Text(text = stringResource(item.label)) },
selected = currentRoute == item.route,
onClick = {
- navController.navigate(item.route) {
- popUpTo(navController.graph.startDestinationId) { saveState = true }
- launchSingleTop = true
- restoreState = true
+ 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
+ }
}
}
)
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 487ab2d..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
@@ -47,14 +47,22 @@ class ScanScreenControllerImpl(
override fun onClickRead(secrets: Secrets, onComplete: ()->Unit, onMultipleCards: (List) -> Unit) {
handleTag(secrets) { tagCommunicator ->
- val character = tagCommunicator.receiveCharacter()
- val resultMessage = characterFromNfc(character) { cards, nfcCharacter ->
- lastScannedCharacter = nfcCharacter
- onMultipleCards(cards)
-
+ 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)
}
- onComplete.invoke()
- resultMessage
}
}
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/CreditsScreen.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/CreditsScreen.kt
index ad09456..8350a90 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/CreditsScreen.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/CreditsScreen.kt
@@ -38,6 +38,8 @@ fun CreditsScreen(
SettingsEntry(title = "nacabaro", description = stringResource(R.string.credits_nacabaro_description)) { }
SettingsEntry(title = "lightheel", description = stringResource(R.string.credits_lightheel_description)) { }
SettingsEntry(title = "shvstrz", description = stringResource(R.string.credits_shvstrz_description)) { }
+
+ SettingsEntry(title = "redeyez", description = stringResource(R.string.credits_RedEyez_description)) { }
}
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/storageScreen/StorageDialog.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/storageScreen/StorageDialog.kt
index 979f47d..ec0173d 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/storageScreen/StorageDialog.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/storageScreen/StorageDialog.kt
@@ -43,7 +43,6 @@ fun StorageDialog(
onDismissRequest: () -> Unit,
onClickDelete: () -> Unit,
onSendToBracelet: () -> Unit,
- onSendToVitalWear: () -> Unit,
onClickSetActive: () -> Unit,
onClickSendToAdventure: (time: Long) -> Unit
) {
@@ -115,35 +114,28 @@ fun StorageDialog(
)
}
}
- Row(
- horizontalArrangement = Arrangement.Center,
- modifier = Modifier
- .fillMaxWidth()
- ) {
- Button(
- onClick = onSendToBracelet,
- modifier = Modifier
- .weight(1f)
- ) {
- Text(text = stringResource(R.string.storage_send_to_watch))
- }
- Spacer(
- modifier = Modifier
- .padding(4.dp)
- )
- Button(
- onClick = onClickSetActive,
- ) {
- Text(text = stringResource(R.string.storage_set_active))
- }
- }
- Button(
- onClick = onSendToVitalWear,
- modifier = Modifier
- .fillMaxWidth()
- ) {
- Text(text = stringResource(R.string.storage_send_to_vitalwear))
- }
+ Row(
+ horizontalArrangement = Arrangement.Center,
+ modifier = Modifier
+ .fillMaxWidth()
+ ) {
+ Button(
+ onClick = onSendToBracelet,
+ modifier = Modifier
+ .weight(1f)
+ ) {
+ Text(text = stringResource(R.string.storage_send_to_watch))
+ }
+ Spacer(
+ modifier = Modifier
+ .padding(4.dp)
+ )
+ Button(
+ onClick = onClickSetActive,
+ ) {
+ Text(text = stringResource(R.string.storage_set_active))
+ }
+ }
Button(
onClick = {
onSendToAdventureClicked = true
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/storageScreen/StorageScreen.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/storageScreen/StorageScreen.kt
index e5e770d..cc9d3d0 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/storageScreen/StorageScreen.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/storageScreen/StorageScreen.kt
@@ -137,21 +137,6 @@ fun StorageScreen(
)
)
},
- onSendToVitalWear = {
- try {
- val intent = VitalWearCharacterExporter(application, application.container.db)
- .buildShareIntent(selectedCharacter!!)
- .addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
- application.startActivity(intent)
- selectedCharacter = null
- } catch (e: Exception) {
- Toast.makeText(
- application,
- "Could not send character to VitalWear: ${e.message}",
- Toast.LENGTH_LONG
- ).show()
- }
- },
onClickSendToAdventure = { time ->
adventureScreenController
.sendCharacterToAdventure(
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 886fb53..e5221ec 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -48,8 +48,8 @@
Scan a Vital Bracelet
- Vital Bracelet to App (Receive Digimon from Bandai Toys: Vital Bracelet, Vital Hero, BE Bracelet)
- App to Vital Bracelet (Send Digimon from VBHelper to Bandai Toys: Vital Bracelet, Vital Hero, BE Bracelet)
+ Watch to VBH
+ VBH to Watch
No NFC on device!
Tag detected is not VB
@@ -71,12 +71,12 @@
Import APK
- Import Secrets From Vital Arena 2.1.0 APK
+ Import Secrets From Vital Arena APK
Import card
- Import DiM/BEm card file
+ Import DiM/BEm
Credits
@@ -114,7 +114,9 @@
Designing the app icon in SVG.
-
+
+ Devout Debugger.
+
Place your Vital Bracelet near the reader...
Cancel
@@ -194,7 +196,7 @@
Delete card
My characters
- Nothing to see here
+ Storage is Empty
This character is in an adventure
Character image
diff --git a/build/reports/problems/problems-report.html b/build/reports/problems/problems-report.html
new file mode 100644
index 0000000..9288671
--- /dev/null
+++ b/build/reports/problems/problems-report.html
@@ -0,0 +1,663 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ Gradle Configuration Cache
+
+
+
+
+
+
+ Loading...
+
+
+
+
+
+
+
From e0a086d9aa1cf27753bbc4afddacef3758d2a631 Mon Sep 17 00:00:00 2001
From: Taveon Nelson <74945303+457R0@users.noreply.github.com>
Date: Tue, 21 Apr 2026 14:36:27 -0500
Subject: [PATCH 03/13] This patch enables transfer between vitalwear and VBH
---
.../scanScreen/ScanNfcaIntegrationTest.kt | 439 +++++++++++++
app/src/main/AndroidManifest.xml | 5 -
.../github/nacabaro/vbhelper/MainActivity.kt | 60 --
.../github/nacabaro/vbhelper/daos/CardDao.kt | 3 +
.../vbhelper/daos/VitalWearSettingsDao.kt | 17 +
.../nacabaro/vbhelper/database/AppDatabase.kt | 44 +-
.../vbhelper/di/DefaultAppContainer.kt | 1 +
.../vbhelper/domain/card/CardCharacter.kt | 9 +-
.../device_data/VitalWearCharacterSettings.kt | 13 +
.../screens/homeScreens/HomeScreen.kt | 23 -
.../screens/scanScreen/DetectedTransport.kt | 8 +
.../vbhelper/screens/scanScreen/ScanScreen.kt | 4 +-
.../scanScreen/ScanScreenController.kt | 11 +-
.../scanScreen/ScanScreenControllerImpl.kt | 583 +++++++++++++++---
.../scanScreen/converters/ToNfcConverter.kt | 14 +-
.../scanScreen/screens/ReadingScreen.kt | 71 ++-
.../scanScreen/screens/WritingScreen.kt | 13 +-
.../source/VitalWearCharacterExporter.kt | 129 ++--
.../source/VitalWearCharacterImporter.kt | 178 ++++--
.../nacabhelper/screens/BattlesScreen.kt | 1 -
app/src/main/res/values-pt-rBR/strings.xml | 2 +
app/src/main/res/values/strings.xml | 2 +
.../source/VitalWearCharacterImporterTest.kt | 56 ++
build/reports/problems/problems-report.html | 2 +-
24 files changed, 1350 insertions(+), 338 deletions(-)
create mode 100644 app/src/androidTest/java/com/github/nacabaro/vbhelper/screens/scanScreen/ScanNfcaIntegrationTest.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/daos/VitalWearSettingsDao.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/VitalWearCharacterSettings.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/DetectedTransport.kt
delete mode 100644 app/src/main/java/com/github/nacabhelper/screens/BattlesScreen.kt
create mode 100644 app/src/test/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterImporterTest.kt
diff --git a/app/src/androidTest/java/com/github/nacabaro/vbhelper/screens/scanScreen/ScanNfcaIntegrationTest.kt b/app/src/androidTest/java/com/github/nacabaro/vbhelper/screens/scanScreen/ScanNfcaIntegrationTest.kt
new file mode 100644
index 0000000..eb93b04
--- /dev/null
+++ b/app/src/androidTest/java/com/github/nacabaro/vbhelper/screens/scanScreen/ScanNfcaIntegrationTest.kt
@@ -0,0 +1,439 @@
+package com.github.nacabaro.vbhelper.screens.scanScreen
+
+import android.nfc.IsoDep
+import android.nfc.NfcA
+import android.nfc.Tag
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.github.cfogrady.vbnfc.TagCommunicator
+import com.github.cfogrady.vbnfc.data.NfcCharacter
+import com.github.cfogrady.vbnfc.vb.VBNfcCharacter
+import org.junit.Test
+import org.junit.Before
+import org.junit.runner.RunWith
+import org.mockito.Mock
+import org.mockito.MockitoAnnotations
+import org.mockito.kotlin.whenever
+import org.mockito.kotlin.any
+import org.mockito.kotlin.verify
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+
+/**
+ * Instrumented tests for NFC-A (Bandai toy) transfer functionality.
+ *
+ * These tests verify:
+ * - Transport detection (NFC-A vs ISO-DEP)
+ * - Read operations (Watch to VBH)
+ * - Write operations (VBH to Watch)
+ * - Slot state detection
+ * - Error handling
+ *
+ * Run with:
+ * ./gradlew connectedAndroidTest --tests ScanNfcaIntegrationTest
+ */
+@RunWith(AndroidJUnit4::class)
+class ScanNfcaIntegrationTest {
+
+ @Mock
+ private lateinit var mockTag: Tag
+
+ @Mock
+ private lateinit var mockNfcA: NfcA
+
+ @Mock
+ private lateinit var mockIsoDep: IsoDep
+
+ @Mock
+ private lateinit var mockTagCommunicator: TagCommunicator
+
+ @Before
+ fun setup() {
+ MockitoAnnotations.openMocks(this)
+ }
+
+ // ==========================================
+ // TEST GROUP 1: Transport Detection
+ // ==========================================
+
+ /**
+ * TEST 1.1: Verify NFC-A is correctly detected
+ *
+ * When: Tag with NFC-A support is detected
+ * Then: Transport should be NFC_A
+ */
+ @Test
+ fun testNfcATransportDetection() {
+ // Arrange
+ whenever(NfcA.get(mockTag)).thenReturn(mockNfcA)
+ whenever(IsoDep.get(mockTag)).thenReturn(null)
+
+ // Act
+ val nfcA = NfcA.get(mockTag)
+ val isoDep = IsoDep.get(mockTag)
+
+ // Assert
+ assertNotNull(nfcA, "NfcA should be detected")
+ assertNull(isoDep, "IsoDep should not be present")
+ }
+
+ /**
+ * TEST 1.2: Verify ISO-DEP vs NFC-A detection priority
+ *
+ * When: Both NFC-A and ISO-DEP present (shouldn't happen, but test anyway)
+ * Then: ISO-DEP should take priority if it's VitalWear
+ */
+ @Test
+ fun testIsoDepPriorityOverNfcA() {
+ // Arrange
+ whenever(NfcA.get(mockTag)).thenReturn(mockNfcA)
+ whenever(IsoDep.get(mockTag)).thenReturn(mockIsoDep)
+
+ // Act
+ val isoDep = IsoDep.get(mockTag)
+ val nfcA = NfcA.get(mockTag)
+
+ // Assert
+ // In actual code, ISO-DEP is checked first
+ assertNotNull(isoDep, "ISO-DEP should be detected first")
+ assertNotNull(nfcA, "NFC-A should also be present")
+ }
+
+ // ==========================================
+ // TEST GROUP 2: Slot State Detection
+ // ==========================================
+
+ /**
+ * TEST 2.1: Verify device not full detection
+ *
+ * When: Device has active=true, backup=false
+ * Then: Should allow write operation
+ */
+ @Test
+ fun testSlotStateAllowsWriteWhenNotFull() {
+ // Arrange: Create mock state - device not full
+ val slotState = NfcASlotState(
+ count = 2,
+ activePresent = true,
+ backupPresent = false
+ )
+
+ // Act
+ val isFull = slotState.isFull()
+
+ // Assert
+ assertEquals(false, isFull, "Device should not be full")
+ }
+
+ /**
+ * TEST 2.2: Verify device full detection
+ *
+ * When: Device has active=true, backup=true
+ * Then: Should block write operation
+ */
+ @Test
+ fun testSlotStateBlocksWriteWhenFull() {
+ // Arrange: Create mock state - device full
+ val slotState = NfcASlotState(
+ count = 2,
+ activePresent = true,
+ backupPresent = true
+ )
+
+ // Act
+ val isFull = slotState.isFull()
+
+ // Assert
+ assertEquals(true, isFull, "Device should be full")
+ }
+
+ /**
+ * TEST 2.3: Verify count-based full detection
+ *
+ * When: Device reports count >= 2
+ * Then: Should be considered full
+ */
+ @Test
+ fun testSlotStateFullByCount() {
+ // Arrange
+ val slotStateFull = NfcASlotState(count = 2, activePresent = null, backupPresent = null)
+ val slotStateNotFull = NfcASlotState(count = 1, activePresent = null, backupPresent = null)
+
+ // Act
+ val isFullCount2 = slotStateFull.isFull()
+ val isFullCount1 = slotStateNotFull.isFull()
+
+ // Assert
+ assertEquals(true, isFullCount2, "Count >= 2 should be full")
+ assertEquals(false, isFullCount1, "Count < 2 should not be full")
+ }
+
+ /**
+ * TEST 2.4: Verify fallback when introspection fails
+ *
+ * When: Cannot introspect slot state (all null)
+ * Then: Should default to non-blocking path
+ */
+ @Test
+ fun testSlotStateFallbackWhenUnknown() {
+ // Arrange: No slot info available
+ val slotState = NfcASlotState(count = null, activePresent = null, backupPresent = null)
+
+ // Act
+ val isFull = slotState.isFull()
+
+ // Assert
+ assertEquals(false, isFull, "Unknown state should not block (default to false)")
+ }
+
+ // ==========================================
+ // TEST GROUP 3: Character Conversion
+ // ==========================================
+
+ /**
+ * TEST 3.1: Verify NFC character to DB character conversion
+ *
+ * When: VBNfcCharacter is read from toy
+ * Then: Should convert to database format correctly
+ */
+ @Test
+ fun testNfcCharacterConversionVB() {
+ // Arrange
+ val vbNfcCharacter = VBNfcCharacter()
+ // In real test, would populate with actual data
+
+ // Act
+ // This would call FromNfcConverter.addCharacter()
+ // For now, just verify the object exists
+ assertNotNull(vbNfcCharacter, "VBNfcCharacter should be created")
+ }
+
+ // ==========================================
+ // TEST GROUP 4: Write Operation Validation
+ // ==========================================
+
+ /**
+ * TEST 4.1: Verify write is blocked when device full
+ *
+ * Scenario:
+ * - User selects character to write
+ * - Device has 2 active+backup (full)
+ * - Click "VBH to Watch"
+ *
+ * Expected:
+ * - WriteResult.BLOCKED_DEVICE_FULL
+ * - No transfer occurs
+ */
+ @Test
+ fun testWriteBlockedWhenDeviceFull() {
+ // Arrange
+ val slotState = NfcASlotState(
+ count = 2,
+ activePresent = true,
+ backupPresent = true
+ )
+
+ // Act
+ val isFull = slotState.isFull()
+
+ // Assert
+ assertEquals(true, isFull, "Should block when full")
+ // In real test, would verify WriteResult.BLOCKED_DEVICE_FULL returned
+ }
+
+ /**
+ * TEST 4.2: Verify write allowed when slot available
+ *
+ * Scenario:
+ * - User selects character to write
+ * - Device has active only (backup empty)
+ * - Click "VBH to Watch"
+ *
+ * Expected:
+ * - WriteResult.MOVE_CONFIRMED
+ * - Active→Backup migration performed
+ * - Character transferred
+ */
+ @Test
+ fun testWriteAllowedWhenSlotAvailable() {
+ // Arrange
+ val slotState = NfcASlotState(
+ count = 2,
+ activePresent = true,
+ backupPresent = false
+ )
+
+ // Act
+ val isFull = slotState.isFull()
+
+ // Assert
+ assertEquals(false, isFull, "Should allow when slot available")
+ }
+
+ /**
+ * TEST 4.3: Verify write rejected when no active character
+ *
+ * Scenario:
+ * - Device only has backup (no active)
+ * - Click "VBH to Watch"
+ *
+ * Expected:
+ * - WriteResult.COPIED
+ * - Transfer skipped
+ */
+ @Test
+ fun testWriteSkippedWhenNoActiveCharacter() {
+ // Arrange: Only backup, no active
+ val slotState = NfcASlotState(
+ count = 1,
+ activePresent = false,
+ backupPresent = true
+ )
+
+ // Act
+ val isValidForWrite = slotState.activePresent == true && slotState.backupPresent == false
+
+ // Assert
+ assertEquals(false, isValidForWrite, "Should reject write when no active")
+ }
+
+ // ==========================================
+ // TEST GROUP 5: Debouncing
+ // ==========================================
+
+ /**
+ * TEST 5.1: Verify rapid re-taps are debounced
+ *
+ * Scenario:
+ * - User taps toy for read
+ * - User immediately taps again (< 1.5 sec)
+ *
+ * Expected:
+ * - Only first tap processes
+ * - Second tap ignored
+ * - Database contains only 1 character
+ */
+ @Test
+ fun testRapidTapDebouncing() {
+ // Arrange
+ val TAG_DEBOUNCE_MS = 1500L
+ val now = System.currentTimeMillis()
+ val firstTapTime = now
+ val secondTapTime = now + 500 // 500ms later (within debounce)
+
+ // Act
+ val timeDifference = secondTapTime - firstTapTime
+ val isWithinDebounce = timeDifference < TAG_DEBOUNCE_MS
+
+ // Assert
+ assertEquals(true, isWithinDebounce, "Second tap should be debounced")
+ }
+
+ /**
+ * TEST 5.2: Verify tap after debounce window is processed
+ *
+ * Scenario:
+ * - User taps toy for read
+ * - User taps again after 2 seconds (> 1.5 sec)
+ *
+ * Expected:
+ * - First tap processes
+ * - Second tap also processes
+ * - Database contains 2 characters
+ */
+ @Test
+ fun testTapAllowedAfterDebounceWindow() {
+ // Arrange
+ val TAG_DEBOUNCE_MS = 1500L
+ val now = System.currentTimeMillis()
+ val firstTapTime = now
+ val secondTapTime = now + 2000 // 2 seconds later (outside debounce)
+
+ // Act
+ val timeDifference = secondTapTime - firstTapTime
+ val isWithinDebounce = timeDifference < TAG_DEBOUNCE_MS
+
+ // Assert
+ assertEquals(false, isWithinDebounce, "Second tap should be processed")
+ }
+
+ // ==========================================
+ // TEST GROUP 6: Error Handling
+ // ==========================================
+
+ /**
+ * TEST 6.1: Verify graceful handling of corrupt character data
+ *
+ * Scenario:
+ * - Toy has corrupted character data
+ * - User clicks "Watch to VBH"
+ * - Tap toy
+ *
+ * Expected:
+ * - Exception caught
+ * - Toast shown: "Whoops"
+ * - App doesn't crash
+ * - User can retry
+ */
+ @Test
+ fun testCorruptCharacterHandling() {
+ // This is more of an integration test
+ // Would require mocking TagCommunicator.receiveCharacter() to throw
+
+ // Pseudocode:
+ // whenever(mockTagCommunicator.receiveCharacter())
+ // .thenThrow(IOException("Corrupt data"))
+
+ // In real test:
+ // Call onClickRead()
+ // Verify: Toast shown, no crash, app recovers
+ }
+
+ /**
+ * TEST 6.2: Verify NFC connection timeout handling
+ *
+ * Scenario:
+ * - User starts read/write
+ * - Remove toy during transfer
+ *
+ * Expected:
+ * - Connection timeout
+ * - Toast: "Whoops"
+ * - Reader mode disabled
+ * - App recovers
+ */
+ @Test
+ fun testNfcConnectionTimeoutHandling() {
+ // Pseudocode:
+ // whenever(mockNfcA.connect())
+ // .thenThrow(IOException("NFC connection lost"))
+
+ // In real test:
+ // Verify timeout is caught, UI updates, no crash
+ }
+
+ // ==========================================
+ // Helper Classes (from ScanScreenControllerImpl)
+ // ==========================================
+
+ /**
+ * Mirrors the NfcASlotState data class from ScanScreenControllerImpl.kt
+ */
+ private data class NfcASlotState(
+ val count: Int?,
+ val activePresent: Boolean?,
+ val backupPresent: Boolean?,
+ ) {
+ fun isFull(): Boolean {
+ if (count != null) {
+ return count >= 2
+ }
+ if (activePresent != null && backupPresent != null) {
+ return activePresent && backupPresent
+ }
+ return false
+ }
+ }
+}
+
+
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 39fa631..652d31d 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -54,11 +54,6 @@
-
-
-
-
-
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 9d178a1..4fd2e1f 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/MainActivity.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/MainActivity.kt
@@ -1,16 +1,12 @@
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.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
@@ -22,10 +18,7 @@ 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() {
@@ -82,7 +75,6 @@ class MainActivity : ComponentActivity() {
}
Log.i("MainActivity", "Activity onCreated")
- handleImportIntent(intent)
}
override fun onPause() {
@@ -105,54 +97,6 @@ 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? {
@@ -192,8 +136,4 @@ 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/daos/CardDao.kt b/app/src/main/java/com/github/nacabaro/vbhelper/daos/CardDao.kt
index b2d61e0..3fbca2a 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,6 +13,9 @@ 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/VitalWearSettingsDao.kt b/app/src/main/java/com/github/nacabaro/vbhelper/daos/VitalWearSettingsDao.kt
new file mode 100644
index 0000000..282fd05
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/daos/VitalWearSettingsDao.kt
@@ -0,0 +1,17 @@
+package com.github.nacabaro.vbhelper.daos
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import com.github.nacabaro.vbhelper.domain.device_data.VitalWearCharacterSettings
+
+@Dao
+interface VitalWearSettingsDao {
+ @Query("SELECT * FROM VitalWearCharacterSettings WHERE characterId = :characterId LIMIT 1")
+ suspend fun getByCharacterId(characterId: Long): VitalWearCharacterSettings?
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun upsert(settings: VitalWearCharacterSettings)
+}
+
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 7aa784c..9853007 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,6 +2,8 @@ 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
@@ -13,6 +15,7 @@ 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
@@ -29,10 +32,11 @@ 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 = 1,
+ version = 2,
entities = [
Card::class,
CardProgress::class,
@@ -50,7 +54,8 @@ import com.github.nacabaro.vbhelper.domain.items.Items
Items::class,
Adventure::class,
Background::class,
- PossibleTransformations::class
+ PossibleTransformations::class,
+ VitalWearCharacterSettings::class,
]
)
abstract class AppDatabase : RoomDatabase() {
@@ -65,4 +70,39 @@ 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()
+ )
+ }
+ }
+ }
}
\ 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 70305b2..78788f2 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
@@ -31,6 +31,7 @@ class DefaultAppContainer(private val context: Context) : AppContainer {
"internalDb"
)
.createFromAsset("items.db")
+ .addMigrations(AppDatabase.MIGRATION_1_2)
.build()
}
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 050cc9a..d59b5fd 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,11 +2,15 @@ package com.github.nacabaro.vbhelper.domain.card
import androidx.room.Entity
import androidx.room.ForeignKey
+import androidx.room.Index
import androidx.room.PrimaryKey
import com.github.cfogrady.vbnfc.data.NfcCharacter
import com.github.nacabaro.vbhelper.domain.characters.Sprite
@Entity(
+ indices = [
+ Index(value = ["cardId", "charaIndex"], unique = true)
+ ],
foreignKeys = [
ForeignKey(
entity = Card::class,
@@ -24,9 +28,8 @@ import com.github.nacabaro.vbhelper.domain.characters.Sprite
)
/*
- * 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
+ * Character represents a character on a specific card slot.
+ * Uniqueness is enforced per cardId + charaIndex.
*/
data class CardCharacter (
@PrimaryKey(autoGenerate = true) val id: Long = 0,
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/VitalWearCharacterSettings.kt b/app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/VitalWearCharacterSettings.kt
new file mode 100644
index 0000000..845604f
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/VitalWearCharacterSettings.kt
@@ -0,0 +1,13 @@
+package com.github.nacabaro.vbhelper.domain.device_data
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+@Entity
+data class VitalWearCharacterSettings(
+ @PrimaryKey val characterId: Long,
+ val trainingInBackground: Boolean = false,
+ val allowedBattles: Int = 1,
+ val accumulatedDailyInjuries: Int = 0,
+)
+
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 37551e8..6ece429 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
@@ -41,8 +41,6 @@ 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
@@ -178,27 +176,6 @@ 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")
- }
}
}
}
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/DetectedTransport.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/DetectedTransport.kt
new file mode 100644
index 0000000..abb0ada
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/DetectedTransport.kt
@@ -0,0 +1,8 @@
+package com.github.nacabaro.vbhelper.screens.scanScreen
+
+enum class DetectedTransport {
+ UNKNOWN,
+ NFC_A,
+ ISO_DEP,
+}
+
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 72fc520..df0dbbc 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,6 +24,7 @@ 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
@@ -131,6 +132,7 @@ fun ScanScreenPreview() {
navController = rememberNavController(),
scanScreenController = object: ScanScreenController {
override val secretsFlow = MutableStateFlow(Secrets.getDefaultInstance())
+ override val detectedTransportFlow: StateFlow = MutableStateFlow(DetectedTransport.UNKNOWN)
override fun unregisterActivityLifecycleListener(key: String) { }
override fun registerActivityLifecycleListener(
key: String,
@@ -141,7 +143,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, onComplete: () -> Unit) {}
+ override fun onClickWrite(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: (ScanScreenController.WriteResult) -> Unit) {}
override fun cancelRead() {}
override fun characterFromNfc(nfcCharacter: NfcCharacter, onMultipleCards: (List, NfcCharacter) -> Unit): String { return "" }
override suspend fun characterToNfc(characterId: Long): NfcCharacter? { return null }
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/ScanScreenController.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/ScanScreenController.kt
index 485fab8..fa47f7d 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,12 +5,21 @@ 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
+
+ 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, onComplete: () -> Unit)
+ fun onClickWrite(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: (WriteResult) -> Unit)
fun cancelRead()
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/ScanScreenControllerImpl.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/ScanScreenControllerImpl.kt
index eb52382..0083596 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,6 +3,7 @@ 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
@@ -16,16 +17,26 @@ import com.github.cfogrady.vbnfc.data.NfcCharacter
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.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 com.github.nacabaro.vbhelper.R
+import kotlinx.coroutines.runBlocking
+import java.util.Arrays
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicLong
class ScanScreenControllerImpl(
override val secretsFlow: Flow,
@@ -33,49 +44,201 @@ class ScanScreenControllerImpl(
private val registerActivityLifecycleListener: (String, ActivityLifecycleListener)->Unit,
private val unregisterActivityLifecycleListener: (String)->Unit,
): ScanScreenController {
+
+ override val detectedTransportFlow: StateFlow
+ get() = _detectedTransportFlow
+
+ private val _detectedTransportFlow = MutableStateFlow(DetectedTransport.UNKNOWN)
private var lastScannedCharacter: NfcCharacter? = null
+ private var lastRequestedCharacterId: 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 val VITALWEAR_AID = byteArrayOf(
+ 0xF0.toByte(), 0x56, 0x49, 0x54, 0x41, 0x4C, 0x57, 0x45, 0x41, 0x52
+ )
+ private const val SW_OK = 0x9000
+ }
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()
}
- 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)
+ // ---- Read (phone receives character FROM watch or bracelet) --------------------
+
+ override fun onClickRead(secrets: Secrets, onComplete: () -> Unit, onMultipleCards: (List) -> Unit) {
+ _detectedTransportFlow.value = DetectedTransport.UNKNOWN
+ 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)
}
- 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()
+ },
+ isoDepHandler = { isoDep ->
+ val application = componentActivity.applicationContext as VBHelper
+ val importer = VitalWearCharacterImporter(application.container.db)
+ try {
+ var importResult: VitalWearCharacterImporter.ImportResult? = null
+ val moved = VitalWearHceReaderClient(isoDep).moveCharacterFromWatch { character ->
+ val result = importer.importCharacter(character)
+ importResult = result
+ result.success
+ }
+ onComplete.invoke()
+ if (moved) {
+ importResult?.message ?: componentActivity.getString(R.string.scan_sent_character_success)
+ } else {
+ importResult?.message
+ ?: "VitalWear import was rejected. Source character remains on the watch."
+ }
+ } catch (readError: Exception) {
+ Log.e("NFC_READ", "HCE read failed; watch may be armed as destination", readError)
+ onComplete.invoke()
+ "No source character detected on watch. If the watch is waiting to receive, use VBH to Watch."
}
- onComplete.invoke()
- componentActivity.getString(R.string.scan_error_generic)
}
- }
+ )
}
+ // ---- Write (phone sends character TO watch or bracelet) ------------------------
+
+ override fun onClickWrite(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: (ScanScreenController.WriteResult) -> Unit) {
+ _detectedTransportFlow.value = DetectedTransport.UNKNOWN
+ handleTag(
+ secrets,
+ nfcAHandler = { tagCommunicator ->
+ try {
+ val initialSlotState = readNfcASlotState(tagCommunicator)
+ if (initialSlotState.isFull()) {
+ onComplete.invoke(ScanScreenController.WriteResult.BLOCKED_DEVICE_FULL)
+ return@handleTag componentActivity.getString(R.string.scan_target_device_full)
+ }
+
+ val migrationCheck = verifyActiveToBackupMigration(tagCommunicator, initialSlotState)
+ if (!migrationCheck.canProceed) {
+ onComplete.invoke(ScanScreenController.WriteResult.COPIED)
+ return@handleTag migrationCheck.message
+ }
+
+ when (nfcCharacter) {
+ is VBNfcCharacter -> tagCommunicator.sendCharacter(nfcCharacter)
+ is BENfcCharacter -> tagCommunicator.sendCharacter(nfcCharacter)
+ }
+ onComplete.invoke(ScanScreenController.WriteResult.MOVE_CONFIRMED)
+ componentActivity.getString(R.string.scan_sent_character_success)
+ } catch (e: Throwable) {
+ Log.e("TAG", e.stackTraceToString())
+ componentActivity.getString(R.string.scan_error_generic)
+ }
+ },
+ isoDepHandler = { isoDep ->
+ val characterId = lastRequestedCharacterId
+ ?: throw IllegalStateException("No character id available for VitalWear HCE write")
+ val application = componentActivity.applicationContext as VBHelper
+ val hceClient = VitalWearHceReaderClient(isoDep)
+ try {
+ val proto = runBlocking {
+ VitalWearCharacterExporter(application.container.db)
+ .buildCharacterProto(characterId)
+ }
+ hceClient.sendCharacterToWatch(proto)
+ onComplete.invoke(ScanScreenController.WriteResult.MOVE_CONFIRMED)
+ componentActivity.getString(R.string.scan_sent_character_success)
+ } catch (writeError: Throwable) {
+ Log.e("NFC_WRITE_HCE", "HCE write failed; attempting opposite direction", writeError)
+ runCatching {
+ var importResult: VitalWearCharacterImporter.ImportResult? = null
+ val moved = hceClient.moveCharacterFromWatch { character ->
+ val result = VitalWearCharacterImporter(application.container.db).importCharacter(character)
+ importResult = result
+ result.success
+ }
+ onComplete.invoke(ScanScreenController.WriteResult.COPIED)
+ if (moved) {
+ importResult?.message
+ ?: "Detected source on watch and imported it instead of sending."
+ } else {
+ importResult?.message
+ ?: "Watch is in source mode, but import was rejected."
+ }
+ }.getOrElse { readFallbackError ->
+ Log.e("NFC_WRITE_HCE", "HCE opposite-direction fallback failed", readFallbackError)
+ componentActivity.getString(R.string.scan_error_generic)
+ }
+ }
+ }
+ )
+ }
+
+ // ---- 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
+ 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() {
- if(nfcAdapter.isEnabled) {
- nfcAdapter.disableReaderMode(componentActivity)
+ _detectedTransportFlow.value = DetectedTransport.UNKNOWN
+ activeReaderSessionId = readerSessionCounter.incrementAndGet()
+ isHandlingTag.set(false)
+ synchronized(tagStateLock) {
+ lastTagId = null
+ lastTagHandledAtMs = 0L
}
+ disableReaderModeSafely()
}
- override fun registerActivityLifecycleListener(
- key: String,
- activityLifecycleListener: ActivityLifecycleListener
- ) {
+ override fun registerActivityLifecycleListener(key: String, activityLifecycleListener: ActivityLifecycleListener) {
registerActivityLifecycleListener.invoke(key, activityLifecycleListener)
}
@@ -83,43 +246,184 @@ class ScanScreenControllerImpl(
unregisterActivityLifecycleListener.invoke(key)
}
- // EXTRACTED DIRECTLY FROM EXAMPLE APP
- private fun handleTag(secrets: Secrets, handlerFunc: (TagCommunicator)->String) {
+ // ---- 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
+ ) {
if (!nfcAdapter.isEnabled) {
showWirelessSettings()
- } 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
- )
+ return
+ }
+ 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_NFC_B 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) {
+
+ // Detect transport once per tap and lock to that route for this transfer.
+ val isoDep = IsoDep.get(tag)
+ val hasIsoDepRoute = isoDep != null && isoDepHandler != null
+ val hasNfcARoute = NfcA.get(tag) != null
+ try {
+ // Prefer ISO-DEP whenever present so HCE sessions do not accidentally fall through to NFC-A.
+ if (hasIsoDepRoute) {
+ val isoDepTarget = isoDep!!
+ val isoDepAction = isoDepHandler!!
+ val confirmedVitalWear = runCatching { isVitalWearHceTarget(isoDepTarget) }.getOrDefault(false)
+ if (!confirmedVitalWear) {
+ Log.w("NFC_HCE", "IsoDep tag did not confirm VitalWear AID; attempting ISO-DEP handler without NFC-A fallback")
+ }
+ try {
+ _detectedTransportFlow.value = DetectedTransport.ISO_DEP
+ isoDepTarget.connect()
+ isoDepTarget.use {
+ val successText = isoDepAction.invoke(isoDepTarget)
+ componentActivity.runOnUiThread {
+ Toast.makeText(componentActivity, successText, Toast.LENGTH_SHORT).show()
+ }
+ }
+ } 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()
+ }
+ }
+ } else if (hasNfcARoute) {
+ if (!handleNfcATag(tag, secrets, nfcAHandler)) {
+ componentActivity.runOnUiThread {
+ Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_tag_not_vb), Toast.LENGTH_SHORT).show()
+ }
+ }
+ } else {
+ _detectedTransportFlow.value = DetectedTransport.UNKNOWN
+ componentActivity.runOnUiThread {
+ Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_tag_not_vb), Toast.LENGTH_SHORT).show()
+ }
+ }
+ } finally {
+ if (activeReaderSessionId == sessionId) {
+ disableReaderModeSafely()
+ isHandlingTag.set(false)
+ }
+ }
+ }
+ }
}
}
- // 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 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())
- val successText = handlerFunc(tagCommunicator)
+ val successText = nfcAHandler(tagCommunicator)
componentActivity.runOnUiThread {
Toast.makeText(componentActivity, successText, Toast.LENGTH_SHORT).show()
}
}
+ 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()
+ }
+ 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()
+ }
+ true
}
}
+ 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()
}
@@ -127,74 +431,153 @@ 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))
}
- override fun characterFromNfc(
- nfcCharacter: NfcCharacter,
- onMultipleCards: (List, NfcCharacter) -> Unit
- ): String {
- val nfcConverter = FromNfcConverter(
- componentActivity = componentActivity
- )
- return nfcConverter.addCharacter(nfcCharacter, onMultipleCards)
+ /**
+ * 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) {
+ return SlotMigrationCheck(
+ canProceed = false,
+ message = "Transfer blocked. Backup slot could not be verified after migration."
+ )
+ }
+
+ 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 suspend fun characterToNfc(characterId: Long): NfcCharacter {
- val nfcGenerator = ToNfcConverter(
- componentActivity = componentActivity
- )
-
- val character = nfcGenerator.characterToNfc(characterId)
+ lastRequestedCharacterId = characterId
+ val character = ToNfcConverter(componentActivity = componentActivity).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/ToNfcConverter.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/converters/ToNfcConverter.kt
index 143fef8..27ca122 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
@@ -12,9 +12,11 @@ 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.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(
@@ -39,7 +41,10 @@ class ToNfcConverter(
.characterDao()
.getCharacterInfo(userCharacter.charId)
- return if (userCharacter.characterType == DeviceType.BEDevice)
+ val card = database.cardDao().getCardByCharacterIdSync(characterId)
+ val shouldEncodeAsBem = card?.isBEm ?: (userCharacter.characterType == DeviceType.BEDevice)
+
+ return if (shouldEncodeAsBem)
nfcToBENfc(characterId, characterInfo, userCharacter)
else
nfcToVBNfc(characterId, characterInfo, userCharacter)
@@ -55,7 +60,12 @@ class ToNfcConverter(
val vbData = database
.userCharacterDao()
.getVbData(characterId)
- .first()
+ .firstOrNull()
+ ?: VBCharacterData(
+ id = characterId,
+ generation = 0,
+ totalTrophies = userCharacter.trophies
+ )
val paddedTransformationArray = generateTransformationHistory(characterId, 9)
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 633be72..2db112d 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,5 +1,6 @@
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.collectAsState
@@ -15,6 +16,7 @@ import com.github.nacabaro.vbhelper.screens.scanScreen.SCAN_SCREEN_ACTIVITY_LIFE
import com.github.nacabaro.vbhelper.screens.scanScreen.ScanScreenController
import com.github.nacabaro.vbhelper.R
+private const val TAG = "ReadingScreen"
@Composable
fun ReadingScreen(
@@ -30,55 +32,72 @@ fun ReadingScreen(
var isDoneReadingCharacter by remember { mutableStateOf(false) }
var cardSelectScreen by remember { mutableStateOf(false) }
+ 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")
+ readingScreen = false
+ isDoneReadingCharacter = true
+ },
+ onMultipleCards = { cards ->
+ Log.d(TAG, "onClickRead.onMultipleCards: showing card select (count=${cards.size})")
+ 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() {
- scanScreenController.onClickRead(
- secrets = secrets!!,
- onComplete = {
- isDoneReadingCharacter = true
- },
- onMultipleCards = { cards ->
- cardsRead = cards
- readingScreen = false
- cardSelectScreen = true
- isDoneReadingCharacter = true
- }
- )
+ 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()
}
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()
}
@@ -86,9 +105,11 @@ 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()
}
)
@@ -96,6 +117,7 @@ fun ReadingScreen(
if (readingScreen) {
ActionScreen(topBannerText = stringResource(R.string.reading_character_title),) {
+ Log.d(TAG, "ActionScreen.onCancel: cancelRead + onCancel")
readingScreen = false
scanScreenController.cancelRead()
onCancel()
@@ -104,6 +126,7 @@ fun ReadingScreen(
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 8542bd0..ca6286f 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
@@ -14,6 +14,7 @@ import com.github.nacabaro.vbhelper.ActivityLifecycleListener
import com.github.nacabaro.vbhelper.di.VBHelper
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.source.StorageRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -38,6 +39,7 @@ fun WritingScreen(
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) {
@@ -54,7 +56,8 @@ fun WritingScreen(
isDoneSendingCard = true
}
} else if (!isDoneWritingCharacter) {
- scanScreenController.onClickWrite(secrets!!, nfcCharacter) {
+ scanScreenController.onClickWrite(secrets!!, nfcCharacter) { result ->
+ writeResult = result
isDoneWritingCharacter = true
}
}
@@ -68,7 +71,8 @@ fun WritingScreen(
isDoneSendingCard = true
}
} else if (!isDoneWritingCharacter) {
- scanScreenController.onClickWrite(secrets!!, nfcCharacter) {
+ scanScreenController.onClickWrite(secrets!!, nfcCharacter) { result ->
+ writeResult = result
isDoneWritingCharacter = true
}
}
@@ -129,8 +133,9 @@ fun WritingScreen(
LaunchedEffect(isDoneSendingCard, isDoneWritingCharacter) {
withContext(Dispatchers.IO) {
if (isDoneSendingCard && isDoneWritingCharacter) {
- storageRepository
- .deleteCharacter(characterId)
+ if (writeResult == WriteResult.MOVE_CONFIRMED) {
+ storageRepository.deleteCharacter(characterId)
+ }
completedWriting = true
}
}
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 bc3ad95..e67c96a 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,86 +1,68 @@
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.utils.DeviceType
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.runBlocking
-import java.io.File
class VitalWearCharacterExporter(
- private val context: Context,
private val database: AppDatabase
) {
- 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)
+ /**
+ * Builds a Character proto from the stored character data.
+ * Used by the HCE ISO-DEP transfer path.
+ */
+ suspend fun buildCharacterProto(characterId: Long): Character {
+ val characterWithSprites = database.userCharacterDao().getCharacterWithSprites(characterId)
+ val userCharacter = database.userCharacterDao().getCharacter(characterId)
+ 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
+ return 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(vwSettings?.accumulatedDailyInjuries ?: 0)
+ .setTotalBattles(characterWithSprites.totalBattlesWon + characterWithSprites.totalBattlesLost)
+ .setCurrentPhaseBattles(characterWithSprites.currentPhaseBattlesWon + characterWithSprites.currentPhaseBattlesLost)
+ .setTotalWins(characterWithSprites.totalBattlesWon)
+ .setCurrentPhaseWins(characterWithSprites.currentPhaseBattlesWon)
+ .setMood(characterWithSprites.mood)
+ .build()
+ )
+ .setSettings(
+ Character.Settings.newBuilder()
+ .setTrainingInBackground(vwSettings?.trainingInBackground ?: false)
+ .setAllowedBattles(resolveAllowedBattles(vwSettings?.allowedBattles))
+ .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()
- )
- .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)
- }
- }
+ }
+ )
+ .build()
}
private fun resolveTrainingSeconds(characterId: Long, deviceType: DeviceType): Long {
- if (deviceType != DeviceType.BEDevice) {
- return 0L
- }
+ if (deviceType != DeviceType.BEDevice) return 0L
return database.userCharacterDao().getBeData(characterId).valueOrNull()?.remainingTrainingTimeInMinutes?.toLong()?.times(60L) ?: 0L
}
@@ -103,12 +85,13 @@ class VitalWearCharacterExporter(
}
private fun kotlinx.coroutines.flow.Flow.valueOrNull(): T? {
- return runBlocking {
- firstOrNull()
- }
+ return runBlocking { firstOrNull() }
}
- companion object {
- const val VITALWEAR_CHARACTER_MIME = "application/x-vitalwear-character"
+ 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
}
+
}
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 6ec3b65..b6cfb25 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
@@ -3,9 +3,13 @@ package com.github.nacabaro.vbhelper.source
import com.github.cfogrady.vbnfc.data.NfcCharacter
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.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.utils.DeviceType
+import kotlinx.coroutines.runBlocking
import kotlin.math.max
class VitalWearCharacterImporter(
@@ -37,6 +41,7 @@ class VitalWearCharacterImporter(
val totalWins = character.characterStats.totalWins.coerceAtMost(totalBattles)
val currentPhaseBattles = character.characterStats.currentPhaseBattles
val currentPhaseWins = character.characterStats.currentPhaseWins.coerceAtMost(currentPhaseBattles)
+ val deviceType = if (importedCard.isBEm) DeviceType.BEDevice else DeviceType.VBDevice
val userCharacterId = database.userCharacterDao().insertCharacterData(
UserCharacter(
@@ -53,44 +58,70 @@ class VitalWearCharacterImporter(
totalBattlesLost = (totalBattles - totalWins).coerceAtLeast(0),
activityLevel = 0,
heartRateCurrent = 0,
- characterType = DeviceType.BEDevice,
+ characterType = deviceType,
isActive = true
)
)
- 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
+ runBlocking {
+ database.vitalWearSettingsDao().upsert(
+ VitalWearCharacterSettings(
+ characterId = userCharacterId,
+ trainingInBackground = character.settings.trainingInBackground,
+ allowedBattles = character.settings.allowedBattles.number,
+ accumulatedDailyInjuries = character.characterStats.accumulatedDailyInjuries,
+ )
)
- )
+ }
+
+ if (importedCard.isBEm) {
+ 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
+ )
+ )
+ } else {
+ database.userCharacterDao().insertVBCharacterData(
+ VBCharacterData(
+ id = userCharacterId,
+ generation = max(character.transformationHistoryCount - 1, 0),
+ totalTrophies = character.characterStats.trainedPp.coerceAtLeast(0)
+ )
+ )
+ }
val now = System.currentTimeMillis()
database.dexDao().insertCharacter(slotId, importedCard.id, now)
for (transformation in character.transformationHistoryList) {
- val transformationCard = resolveCard(transformation.cardName, character.cardId)
+ val transformationCard = resolveRelatedCard(
+ incomingCardName = transformation.cardName,
+ incomingCardId = character.cardId,
+ matchedRootCard = importedCard,
+ incomingRootCardName = character.cardName,
+ )
if (transformationCard != null) {
database.userCharacterDao().insertTransformation(
userCharacterId,
@@ -103,7 +134,12 @@ class VitalWearCharacterImporter(
}
for ((cardName, maxAdventureCompleted) in character.maxAdventureCompletedByCardMap) {
- val adventureCard = resolveCard(cardName, null) ?: continue
+ val adventureCard = resolveRelatedCard(
+ incomingCardName = cardName,
+ incomingCardId = null,
+ matchedRootCard = importedCard,
+ incomingRootCardName = character.cardName,
+ ) ?: continue
val currentStage = (maxAdventureCompleted + 1).coerceAtLeast(0)
database.cardProgressDao().updateCardProgress(
currentStage = currentStage,
@@ -120,19 +156,33 @@ class VitalWearCharacterImporter(
private fun resolveCard(character: Character) = resolveCard(character.cardName, character.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 resolveCard(cardName: String?, cardId: Int?): Card? {
+ return selectImportedCard(
+ candidates = database.cardDao().getAllCards(),
+ incomingCardName = cardName,
+ incomingCardId = cardId,
+ )
+ }
- if (cardId != null) {
- val matches = database.cardDao().getCardByCardId(cardId)
- if (matches.size == 1) {
- return matches.first()
+ 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
}
}
- return null
+ if (cardNamesMatch(incomingCardName, incomingRootCardName)) {
+ return matchedRootCard
+ }
+
+ return resolveCard(incomingCardName, incomingCardId)
}
private fun secondsToMinutes(seconds: Long): Int {
@@ -161,3 +211,55 @@ class VitalWearCharacterImporter(
return enumValues().first()
}
}
+
+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/nacabhelper/screens/BattlesScreen.kt b/app/src/main/java/com/github/nacabhelper/screens/BattlesScreen.kt
deleted file mode 100644
index 0519ecb..0000000
--- a/app/src/main/java/com/github/nacabhelper/screens/BattlesScreen.kt
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml
index fdc0104..9b8cccf 100644
--- a/app/src/main/res/values-pt-rBR/strings.xml
+++ b/app/src/main/res/values-pt-rBR/strings.xml
@@ -56,6 +56,8 @@
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.
Configurações
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index e5221ec..d10d0b9 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -60,6 +60,8 @@
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.
Settings
diff --git a/app/src/test/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterImporterTest.kt b/app/src/test/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterImporterTest.kt
new file mode 100644
index 0000000..39bd4ff
--- /dev/null
+++ b/app/src/test/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterImporterTest.kt
@@ -0,0 +1,56 @@
+package com.github.nacabaro.vbhelper.source
+
+import com.github.nacabaro.vbhelper.domain.card.Card
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Test
+
+class VitalWearCharacterImporterTest {
+ @Test
+ fun selectImportedCard_prefersUniqueCardIdWhenNameWasRenamed() {
+ val selected = selectImportedCard(
+ candidates = listOf(
+ testCard(id = 1, cardId = 77, name = "My Custom DIM"),
+ testCard(id = 2, cardId = 88, name = "Other"),
+ ),
+ incomingCardName = "Impulse City",
+ incomingCardId = 77,
+ )
+
+ assertNotNull(selected)
+ assertEquals("My Custom DIM", selected?.name)
+ }
+
+ @Test
+ fun selectImportedCard_matchesNormalizedRenamedNameWhenCardIdMissing() {
+ val selected = selectImportedCard(
+ candidates = listOf(
+ testCard(id = 1, cardId = 0, name = "Impulse City!"),
+ ),
+ incomingCardName = "impulse-city",
+ incomingCardId = null,
+ )
+
+ assertNotNull(selected)
+ assertEquals("Impulse City!", selected?.name)
+ }
+
+ @Test
+ fun cardNamesMatch_ignoresCaseAndPunctuation() {
+ assertEquals(true, cardNamesMatch("My DIM: Zero", "my-dim zero"))
+ }
+
+ private fun testCard(id: Long, cardId: Int, name: String): Card {
+ return Card(
+ id = id,
+ cardId = cardId,
+ logo = byteArrayOf(),
+ logoWidth = 0,
+ logoHeight = 0,
+ name = name,
+ stageCount = 0,
+ isBEm = false,
+ )
+ }
+}
+
diff --git a/build/reports/problems/problems-report.html b/build/reports/problems/problems-report.html
index 9288671..ad96d9d 100644
--- a/build/reports/problems/problems-report.html
+++ b/build/reports/problems/problems-report.html
@@ -650,7 +650,7 @@ code + .copy-button {
From cd9a6d5d8034bad471a158a82841e193ec1f548f Mon Sep 17 00:00:00 2001
From: Taveon Nelson <74945303+457R0@users.noreply.github.com>
Date: Tue, 21 Apr 2026 14:41:06 -0500
Subject: [PATCH 04/13] Add VitalWear HCE reader client
---
.../transfer/hce/VitalWearHceReaderClient.kt | 122 ++++++++++++++++++
1 file changed, 122 insertions(+)
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/transfer/hce/VitalWearHceReaderClient.kt
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/transfer/hce/VitalWearHceReaderClient.kt b/app/src/main/java/com/github/nacabaro/vbhelper/transfer/hce/VitalWearHceReaderClient.kt
new file mode 100644
index 0000000..65f5f00
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/transfer/hce/VitalWearHceReaderClient.kt
@@ -0,0 +1,122 @@
+package com.github.nacabaro.vbhelper.transfer.hce
+import android.nfc.tech.IsoDep
+import android.util.Log
+import com.github.cfogrady.vitalwear.protos.Character
+/**
+ * Phone-side ISO-DEP client that drives the VitalWear HCE session.
+ * Mirrors the APDU protocol defined in VitalWearHceProtocol on the watch.
+ */
+class VitalWearHceReaderClient(private val isoDep: IsoDep) {
+ init {
+ // Import + DB writes can make COMMIT slower than default transceive time on some devices.
+ isoDep.timeout = 10_000
+ }
+
+ private val AID = byteArrayOf(
+ 0xF0.toByte(), 0x56, 0x49, 0x54, 0x41, 0x4C, 0x57, 0x45, 0x41, 0x52
+ )
+ private val CLA: Byte = 0x80.toByte()
+ private val INS_NEGOTIATE: Byte = 0x10
+ private val INS_READ_CHUNK: Byte = 0x20
+ private val INS_WRITE_CHUNK: Byte = 0x30
+ private val INS_COMMIT: Byte = 0x40
+ private val MODE_WATCH_TO_PHONE: Byte = 0x01
+ private val MODE_PHONE_TO_WATCH: Byte = 0x02
+ private val VERSION: Byte = 0x01
+ private val SW_OK = 0x9000
+ /** MOVE READ: read character bytes first, let caller import/validate, then COMMIT only on success.
+ * If [onCharacterRead] returns false, COMMIT is skipped so source can remain on the watch.
+ */
+ fun moveCharacterFromWatch(onCharacterRead: (Character) -> Boolean): Boolean {
+ val character = readCharacterFromWatchWithoutCommit()
+ if (!onCharacterRead(character)) {
+ return false
+ }
+ requireOk(sendApdu(INS_COMMIT, byteArrayOf()), "COMMIT")
+ return true
+ }
+
+ /** READ: watch has called armSend() — phone reads the character off the watch. */
+ fun receiveCharacterFromWatch(): Character {
+ val character = readCharacterFromWatchWithoutCommit()
+ requireOk(sendApdu(INS_COMMIT, byteArrayOf()), "COMMIT")
+ return character
+ }
+
+ private fun readCharacterFromWatchWithoutCommit(): Character {
+ selectAid()
+ val negResponse = sendApdu(INS_NEGOTIATE, byteArrayOf(MODE_WATCH_TO_PHONE, VERSION))
+ requireOk(negResponse, "NEGOTIATE")
+ val maxChunk = ((negResponse[2].toInt() and 0xFF) shl 8) or (negResponse[3].toInt() and 0xFF)
+ val payloadLen =
+ ((negResponse[4].toInt() and 0xFF) shl 24) or
+ ((negResponse[5].toInt() and 0xFF) shl 16) or
+ ((negResponse[6].toInt() and 0xFF) shl 8) or
+ (negResponse[7].toInt() and 0xFF)
+ Log.d("HCE_CLIENT", "Receiving $payloadLen bytes in chunks of $maxChunk")
+ val payload = ByteArray(payloadLen)
+ var offset = 0
+ while (offset < payloadLen) {
+ val chunkResponse = sendApdu(INS_READ_CHUNK, intToBytes(offset))
+ requireOk(chunkResponse, "READ_CHUNK @ $offset")
+ val chunkData = chunkResponse.dropLast(2).toByteArray()
+ chunkData.copyInto(payload, offset)
+ offset += chunkData.size
+ }
+ return Character.parseFrom(payload)
+ }
+
+ /** WRITE: watch has called armReceive() — phone pushes a character to the watch. */
+ fun sendCharacterToWatch(character: Character) {
+ selectAid()
+ val payload = character.toByteArray()
+ val negResponse = sendApdu(INS_NEGOTIATE, byteArrayOf(MODE_PHONE_TO_WATCH, VERSION))
+ requireOk(negResponse, "NEGOTIATE")
+ val maxChunk = ((negResponse[2].toInt() and 0xFF) shl 8) or (negResponse[3].toInt() and 0xFF)
+ Log.d("HCE_CLIENT", "Sending ${payload.size} bytes in chunks of $maxChunk")
+ var offset = 0
+ while (offset < payload.size) {
+ val chunkEnd = (offset + maxChunk).coerceAtMost(payload.size)
+ val chunk = payload.copyOfRange(offset, chunkEnd)
+ requireOk(sendApdu(INS_WRITE_CHUNK, intToBytes(offset) + chunk), "WRITE_CHUNK @ $offset")
+ offset = chunkEnd
+ }
+ requireOk(sendApdu(INS_COMMIT, byteArrayOf()), "COMMIT")
+ }
+
+ /**
+ * Verification step for two-tap transfer UX:
+ * confirms the watch is armed to receive before the actual write step.
+ */
+ fun verifyWatchReadyToReceive(): Boolean {
+ selectAid()
+ val negResponse = sendApdu(INS_NEGOTIATE, byteArrayOf(MODE_PHONE_TO_WATCH, VERSION))
+ requireOk(negResponse, "NEGOTIATE")
+ return true
+ }
+
+ private fun selectAid() {
+ val selectApdu = byteArrayOf(0x00, 0xA4.toByte(), 0x04, 0x00, AID.size.toByte()) + AID
+ val response = isoDep.transceive(selectApdu)
+ if (statusWord(response) != SW_OK) error("SELECT AID failed: ${response.toHex()}")
+ }
+ private fun sendApdu(ins: Byte, data: ByteArray): ByteArray {
+ val apdu = byteArrayOf(CLA, ins, 0x00, 0x00, data.size.toByte()) + data
+ return isoDep.transceive(apdu)
+ }
+ private fun requireOk(response: ByteArray, context: String) {
+ if (statusWord(response) != SW_OK) error("$context failed: ${response.toHex()}")
+ }
+ 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)
+ }
+ private fun intToBytes(value: Int): ByteArray = byteArrayOf(
+ ((value ushr 24) and 0xFF).toByte(),
+ ((value ushr 16) and 0xFF).toByte(),
+ ((value ushr 8) and 0xFF).toByte(),
+ (value and 0xFF).toByte()
+ )
+ private fun ByteArray.toHex() = joinToString("") { "%02X".format(it) }
+}
From d40428ccfd833917c655c335e5bae0110dc1a43b Mon Sep 17 00:00:00 2001
From: Taveon Nelson <74945303+457R0@users.noreply.github.com>
Date: Tue, 21 Apr 2026 15:00:14 -0500
Subject: [PATCH 05/13] Reduce duplicate HCE scans and suppress NFC redispatch
---
.../scanScreen/ScanScreenControllerImpl.kt | 30 +++++++++++++++----
1 file changed, 24 insertions(+), 6 deletions(-)
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 0083596..40d4c7b 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
@@ -61,6 +61,7 @@ class ScanScreenControllerImpl(
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
)
@@ -266,6 +267,7 @@ class ScanScreenControllerImpl(
options.putInt(NfcAdapter.EXTRA_READER_PRESENCE_CHECK_DELAY, 250)
val flags = NfcAdapter.FLAG_READER_NFC_A or
NfcAdapter.FLAG_READER_NFC_B or
+ NfcAdapter.FLAG_READER_NO_PLATFORM_SOUNDS or
NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK
val sessionId = readerSessionCounter.incrementAndGet()
activeReaderSessionId = sessionId
@@ -312,8 +314,8 @@ class ScanScreenControllerImpl(
try {
// Prefer ISO-DEP whenever present so HCE sessions do not accidentally fall through to NFC-A.
if (hasIsoDepRoute) {
- val isoDepTarget = isoDep!!
- val isoDepAction = isoDepHandler!!
+ val isoDepTarget = isoDep
+ val isoDepAction = isoDepHandler
val confirmedVitalWear = runCatching { isVitalWearHceTarget(isoDepTarget) }.getOrDefault(false)
if (!confirmedVitalWear) {
Log.w("NFC_HCE", "IsoDep tag did not confirm VitalWear AID; attempting ISO-DEP handler without NFC-A fallback")
@@ -346,16 +348,32 @@ class ScanScreenControllerImpl(
}
}
} finally {
- if (activeReaderSessionId == sessionId) {
- disableReaderModeSafely()
- isHandlingTag.set(false)
- }
+ finishHandledTagSession(tag, sessionId)
}
}
}
}
}
+ 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)
+ }
+
+ disableReaderModeSafely()
+ isHandlingTag.set(false)
+ }
+
private fun handleNfcATag(
tag: Tag,
secrets: Secrets,
From 895b704c8d4297388aacc3e2028c3b2e923c914a Mon Sep 17 00:00:00 2001
From: Taveon Nelson <74945303+457R0@users.noreply.github.com>
Date: Tue, 21 Apr 2026 18:57:38 -0500
Subject: [PATCH 06/13] Make VBH-VW transfer DB owner via provider and shared
transfer DAO
---
app/src/main/AndroidManifest.xml | 10 +++
.../common/data/SharedDatabaseFactory.kt | 22 ++++++
.../common/data/SharedTransferDatabase.kt | 16 +++++
.../common/data/SharedTransferSeenDao.kt | 47 +++++++++++++
.../common/data/SharedTransferSeenEntity.kt | 24 +++++++
.../nacabaro/vbhelper/daos/TransferSeenDao.kt | 7 ++
.../nacabaro/vbhelper/database/AppDatabase.kt | 37 +++++++++-
.../nacabaro/vbhelper/di/AppContainer.kt | 2 +
.../vbhelper/di/DefaultAppContainer.kt | 11 ++-
.../domain/transfer/TransferSeenEntity.kt | 7 ++
.../scanScreen/ScanScreenControllerImpl.kt | 66 ++++++++++++++++--
.../scanScreen/converters/FromNfcConverter.kt | 18 +++--
.../source/VitalWearCharacterImporter.kt | 25 ++++++-
.../transfer/SharedTransferSeenProvider.kt | 67 +++++++++++++++++++
14 files changed, 344 insertions(+), 15 deletions(-)
create mode 100644 app/src/main/java/com/github/cfogrady/vitalwear/common/data/SharedDatabaseFactory.kt
create mode 100644 app/src/main/java/com/github/cfogrady/vitalwear/common/data/SharedTransferDatabase.kt
create mode 100644 app/src/main/java/com/github/cfogrady/vitalwear/common/data/SharedTransferSeenDao.kt
create mode 100644 app/src/main/java/com/github/cfogrady/vitalwear/common/data/SharedTransferSeenEntity.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/daos/TransferSeenDao.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/domain/transfer/TransferSeenEntity.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/transfer/SharedTransferSeenProvider.kt
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 652d31d..3f49294 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -2,6 +2,10 @@
+
+
@@ -31,6 +35,12 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
+
val application = componentActivity.applicationContext as VBHelper
- val importer = VitalWearCharacterImporter(application.container.db)
+ val importer = VitalWearCharacterImporter(application.container.db, application.container.transferSeenDao)
try {
var importResult: VitalWearCharacterImporter.ImportResult? = null
val moved = VitalWearHceReaderClient(isoDep).moveCharacterFromWatch { character ->
@@ -176,7 +230,7 @@ class ScanScreenControllerImpl(
runCatching {
var importResult: VitalWearCharacterImporter.ImportResult? = null
val moved = hceClient.moveCharacterFromWatch { character ->
- val result = VitalWearCharacterImporter(application.container.db).importCharacter(character)
+ val result = VitalWearCharacterImporter(application.container.db, application.container.transferSeenDao).importCharacter(character)
importResult = result
result.success
}
@@ -236,7 +290,9 @@ class ScanScreenControllerImpl(
lastTagId = null
lastTagHandledAtMs = 0L
}
- disableReaderModeSafely()
+ // 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) {
@@ -370,7 +426,9 @@ class ScanScreenControllerImpl(
Log.w("NFC", "Failed to ignore handled tag", it)
}
- disableReaderModeSafely()
+ // 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)
}
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 dcc85a3..2cde698 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
@@ -19,7 +19,8 @@ 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,
@@ -118,6 +119,9 @@ class FromNfcConverter (
.userCharacterDao()
.insertCharacterData(characterData)
+ val seenTimestamp = System.currentTimeMillis()
+ markSeen(cardData, nfcCharacter.charIndex.toInt(), seenTimestamp)
+
if (nfcCharacter is BENfcCharacter) {
addBeCharacterToDatabase(
characterId = characterId,
@@ -292,12 +296,14 @@ class FromNfcConverter (
database
.dexDao()
- .insertCharacter(
- item.toCharIndex.toInt(),
- dimData.id,
- date
- )
+ .insertCharacter(item.toCharIndex.toInt(), dimData.id, date)
+ transferSeenDao.markSeen(dimData.name, item.toCharIndex.toInt(), 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/source/VitalWearCharacterImporter.kt b/app/src/main/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterImporter.kt
index b6cfb25..07bba8d 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,6 +1,7 @@
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
@@ -13,7 +14,8 @@ import kotlinx.coroutines.runBlocking
import kotlin.math.max
class VitalWearCharacterImporter(
- private val database: AppDatabase
+ private val database: AppDatabase,
+ private val transferSeenDao: SharedTransferSeenDao,
) {
data class ImportResult(
val success: Boolean,
@@ -113,8 +115,9 @@ class VitalWearCharacterImporter(
}
val now = System.currentTimeMillis()
- database.dexDao().insertCharacter(slotId, importedCard.id, now)
+ markSeen(importedCard.name, slotId, importedCard.id, now)
+ var insertedTransformationCount = 0
for (transformation in character.transformationHistoryList) {
val transformationCard = resolveRelatedCard(
incomingCardName = transformation.cardName,
@@ -129,10 +132,21 @@ class VitalWearCharacterImporter(
transformationCard.id,
now
)
- database.dexDao().insertCharacter(transformation.slotId, transformationCard.id, now)
+ insertedTransformationCount++
+ markSeen(transformationCard.name, transformation.slotId, transformationCard.id, now)
}
}
+ if (insertedTransformationCount == 0) {
+ // Keep HomeScreen renderable for freshly imported characters with empty history.
+ database.userCharacterDao().insertTransformation(
+ userCharacterId,
+ slotId,
+ importedCard.id,
+ now
+ )
+ }
+
for ((cardName, maxAdventureCompleted) in character.maxAdventureCompletedByCardMap) {
val adventureCard = resolveRelatedCard(
incomingCardName = cardName,
@@ -210,6 +224,11 @@ class VitalWearCharacterImporter(
private fun resolveDefaultAbilityRarity(): NfcCharacter.AbilityRarity {
return enumValues().first()
}
+
+ 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(
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/transfer/SharedTransferSeenProvider.kt b/app/src/main/java/com/github/nacabaro/vbhelper/transfer/SharedTransferSeenProvider.kt
new file mode 100644
index 0000000..8734c22
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/transfer/SharedTransferSeenProvider.kt
@@ -0,0 +1,67 @@
+package com.github.nacabaro.vbhelper.transfer
+
+import android.content.ContentProvider
+import android.content.ContentValues
+import android.database.Cursor
+import android.net.Uri
+import android.os.Bundle
+import com.github.cfogrady.vitalwear.common.data.SharedDatabaseFactory
+
+class SharedTransferSeenProvider : ContentProvider() {
+ companion object {
+ const val AUTHORITY = "com.github.nacabaro.vbhelper.transferseen"
+ val URI: Uri = Uri.parse("content://$AUTHORITY")
+
+ const val METHOD_MARK_SEEN = "markSeen"
+ const val EXTRA_CARD_NAME = "cardName"
+ const val EXTRA_SLOT_ID = "slotId"
+ const val EXTRA_SEEN_AT_EPOCH_MILLIS = "seenAtEpochMillis"
+ const val RESULT_OK = "ok"
+ }
+
+ override fun onCreate(): Boolean = true
+
+ override fun call(method: String, arg: String?, extras: Bundle?): Bundle {
+ if (method != METHOD_MARK_SEEN) {
+ return Bundle().apply { putBoolean(RESULT_OK, false) }
+ }
+
+ val context = context ?: return Bundle().apply { putBoolean(RESULT_OK, false) }
+ val cardName = extras?.getString(EXTRA_CARD_NAME).orEmpty()
+ val slotId = extras?.getInt(EXTRA_SLOT_ID, -1) ?: -1
+ val seenAt = extras?.getLong(EXTRA_SEEN_AT_EPOCH_MILLIS, 0L) ?: 0L
+
+ if (cardName.isBlank() || slotId < 0 || seenAt <= 0L) {
+ return Bundle().apply { putBoolean(RESULT_OK, false) }
+ }
+
+ return runCatching {
+ SharedDatabaseFactory.getDatabase(context).transferSeenDao().markSeen(cardName, slotId, seenAt)
+ Bundle().apply { putBoolean(RESULT_OK, true) }
+ }.getOrElse {
+ Bundle().apply { putBoolean(RESULT_OK, false) }
+ }
+ }
+
+ override fun query(
+ uri: Uri,
+ projection: Array?,
+ selection: String?,
+ selectionArgs: Array?,
+ sortOrder: String?
+ ): Cursor? = null
+
+ override fun getType(uri: Uri): String? = null
+
+ override fun insert(uri: Uri, values: ContentValues?): Uri? = null
+
+ override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = 0
+
+ override fun update(
+ uri: Uri,
+ values: ContentValues?,
+ selection: String?,
+ selectionArgs: Array?
+ ): Int = 0
+}
+
From f829efae68625034fdb680f8d7573bed9fc8bfb8 Mon Sep 17 00:00:00 2001
From: 457R0 <7nelson16@gmail.com.com>
Date: Sun, 3 May 2026 18:07:07 -0500
Subject: [PATCH 07/13] Overwrite with local version
---
app/src/main/AndroidManifest.xml | 9 ++
.../vbhelper/daos/UserCharacterDao.kt | 6 +
.../vbhelper/screens/scanScreen/ScanScreen.kt | 1 +
.../scanScreen/ScanScreenController.kt | 1 +
.../scanScreen/ScanScreenControllerImpl.kt | 66 ++++++++++-
.../scanScreen/converters/ToNfcConverter.kt | 52 +++++++--
.../scanScreen/screens/ActionScreen.kt | 7 ++
.../scanScreen/screens/ReadingScreen.kt | 6 +-
.../scanScreen/screens/WritingScreen.kt | 11 +-
.../source/VitalWearCharacterExporter.kt | 92 ++++++++++-----
.../source/VitalWearCharacterImporter.kt | 95 +++++++++++-----
.../VitalWearTransformationCountdown.kt | 38 +++++++
.../vbhelper/transfer/CardImportProvider.kt | 106 ++++++++++++++++++
.../cfogrady/vitalwear/protos/Character.proto | 29 +++++
app/src/main/res/values-pt-rBR/strings.xml | 6 +
app/src/main/res/values/strings.xml | 6 +
.../source/VitalWearCharacterExporterTest.kt | 58 ++++++++++
.../source/VitalWearCharacterImporterTest.kt | 50 +++++++++
build/reports/problems/problems-report.html | 2 +-
gradle/libs.versions.toml | 2 +-
settings.gradle.kts | 12 ++
21 files changed, 590 insertions(+), 65 deletions(-)
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/source/VitalWearTransformationCountdown.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/transfer/CardImportProvider.kt
create mode 100644 app/src/test/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterExporterTest.kt
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 3f49294..05da0ad 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -5,6 +5,9 @@
+
@@ -35,6 +38,12 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
+
+ @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("SELECT * FROM SpecialMissions WHERE characterId = :id")
fun getSpecialMissions(id: Long): Flow>
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 df0dbbc..18aab56 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
@@ -133,6 +133,7 @@ fun ScanScreenPreview() {
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,
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 fa47f7d..e1d0286 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
@@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.StateFlow
interface ScanScreenController {
val secretsFlow: Flow
val detectedTransportFlow: StateFlow
+ val transferStatusFlow: StateFlow
enum class WriteResult {
MOVE_CONFIRMED,
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 e09cd4f..81a9d0a 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
@@ -47,8 +47,11 @@ class ScanScreenControllerImpl(
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 val nfcAdapter: NfcAdapter
@@ -135,6 +138,7 @@ class ScanScreenControllerImpl(
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 ->
@@ -185,6 +189,7 @@ class ScanScreenControllerImpl(
override fun onClickWrite(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: (ScanScreenController.WriteResult) -> Unit) {
_detectedTransportFlow.value = DetectedTransport.UNKNOWN
+ setTransferStatus(R.string.scan_transfer_waiting_tap)
handleTag(
secrets,
nfcAHandler = { tagCommunicator ->
@@ -207,9 +212,17 @@ class ScanScreenControllerImpl(
}
onComplete.invoke(ScanScreenController.WriteResult.MOVE_CONFIRMED)
componentActivity.getString(R.string.scan_sent_character_success)
- } catch (e: Throwable) {
- Log.e("TAG", e.stackTraceToString())
- componentActivity.getString(R.string.scan_error_generic)
+ } catch (writeError: Throwable) {
+ Log.e("NFC_WRITE_A", "NFC-A write failed; attempting opposite direction", writeError)
+ runCatching {
+ val receivedCharacter = tagCommunicator.receiveCharacter()
+ val fallbackMessage = importCharacterFromNfcAFallback(receivedCharacter)
+ onComplete.invoke(ScanScreenController.WriteResult.COPIED)
+ fallbackMessage
+ }.getOrElse { readFallbackError ->
+ Log.e("NFC_WRITE_A", "NFC-A opposite-direction fallback failed", readFallbackError)
+ componentActivity.getString(R.string.scan_error_generic)
+ }
}
},
isoDepHandler = { isoDep ->
@@ -251,10 +264,42 @@ class ScanScreenControllerImpl(
)
}
+ private fun importCharacterFromNfcAFallback(receivedCharacter: NfcCharacter): String {
+ val application = componentActivity.applicationContext as VBHelper
+ val fromNfcConverter = FromNfcConverter(componentActivity)
+ val candidateCards = application.container.db
+ .cardDao()
+ .getCardByCardId(receivedCharacter.dimId.toInt())
+
+ if (candidateCards.isEmpty()) {
+ return "Detected source character on bracelet, but matching card is not imported in VBHelper."
+ }
+
+ val preferredCardId = receivedCharacter.appReserved2
+ .firstOrNull()
+ ?.toLong()
+ ?.takeIf { it > 0L }
+ val preferredCard = preferredCardId?.let { id ->
+ candidateCards.firstOrNull { it.id == id }
+ }
+
+ val selectedCard = when {
+ preferredCard != null -> preferredCard
+ candidateCards.size == 1 -> candidateCards.single()
+ else -> {
+ lastScannedCharacter = receivedCharacter
+ return "Detected source character, but multiple matching cards exist. Use Read and select the card."
+ }
+ }
+
+ return fromNfcConverter.addCharacterUsingCard(receivedCharacter, selectedCard.id)
+ }
+
// ---- Check card (NFC-A only; ISO-DEP watches don't need a DIM prep) -----------
override fun onClickCheckCard(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: () -> Unit) {
_detectedTransportFlow.value = DetectedTransport.UNKNOWN
+ setTransferStatus(R.string.scan_transfer_waiting_tap)
handleTag(
secrets,
nfcAHandler = { tagCommunicator ->
@@ -284,6 +329,7 @@ class ScanScreenControllerImpl(
override fun cancelRead() {
_detectedTransportFlow.value = DetectedTransport.UNKNOWN
+ setTransferStatus(R.string.scan_transfer_cancelled)
activeReaderSessionId = readerSessionCounter.incrementAndGet()
isHandlingTag.set(false)
synchronized(tagStateLock) {
@@ -362,6 +408,7 @@ class ScanScreenControllerImpl(
}
}
if (shouldHandleTag) {
+ setTransferStatus(R.string.scan_transfer_detected_keep_tap)
// Detect transport once per tap and lock to that route for this transfer.
val isoDep = IsoDep.get(tag)
@@ -380,27 +427,32 @@ class ScanScreenControllerImpl(
_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 (hasNfcARoute) {
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 {
_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 {
@@ -443,9 +495,11 @@ class ScanScreenControllerImpl(
nfcData.connect()
nfcData.use {
val tagCommunicator = TagCommunicator.getInstance(nfcData, secrets.getCryptographicTransformerMap())
+ setTransferStatus(R.string.scan_transfer_in_progress)
val successText = nfcAHandler(tagCommunicator)
componentActivity.runOnUiThread {
Toast.makeText(componentActivity, successText, Toast.LENGTH_SHORT).show()
+ setTransferStatus(R.string.scan_transfer_complete_remove)
}
}
true
@@ -455,17 +509,23 @@ class ScanScreenControllerImpl(
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) }
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 27ca122..09fd168 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,6 +11,7 @@ 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
@@ -22,6 +23,11 @@ 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
+ }
+
private val application: VBHelper = componentActivity.applicationContext as VBHelper
private val database: AppDatabase = application.container.db
@@ -41,8 +47,12 @@ class ToNfcConverter(
.characterDao()
.getCharacterInfo(userCharacter.charId)
- val card = database.cardDao().getCardByCharacterIdSync(characterId)
- val shouldEncodeAsBem = card?.isBEm ?: (userCharacter.characterType == DeviceType.BEDevice)
+ // Use the character's own type as the authoritative signal for encoding.
+ // The card's isBEm flag only indicates the *card* is a BEM card, not that the
+ // stored UserCharacter has a BECharacterData row. Mixing those two signals was
+ // the root cause of a NoSuchElementException when a VB-type character was
+ // associated with a BE-type card.
+ val shouldEncodeAsBem = userCharacter.characterType == DeviceType.BEDevice
return if (shouldEncodeAsBem)
nfcToBENfc(characterId, characterInfo, userCharacter)
@@ -186,8 +196,32 @@ class ToNfcConverter(
): BENfcCharacter {
val beData = database
.userCharacterDao()
- .getBeData(characterId)
- .first()
+ .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,
+ )
val paddedTransformationArray = generateTransformationHistory(characterId)
@@ -264,11 +298,15 @@ 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 = calendar
- .get(Calendar.YEAR)
- .toUShort(),
+ year = normalizedYear.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 5aaa7e7..8d58f95 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
@@ -18,6 +18,7 @@ import com.github.nacabaro.vbhelper.R
@Composable
fun ActionScreen(
topBannerText: String,
+ transferStatusMessage: String?,
onClickCancel: () -> Unit,
) {
Scaffold (
@@ -36,6 +37,12 @@ fun ActionScreen(
.fillMaxSize()
) {
Text(stringResource(R.string.action_place_near_reader))
+ if (!transferStatusMessage.isNullOrBlank()) {
+ Text(
+ text = transferStatusMessage,
+ modifier = Modifier.padding(top = 8.dp, start = 16.dp, end = 16.dp)
+ )
+ }
Button(
onClick = onClickCancel,
modifier = Modifier.padding(16.dp)
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 2db112d..3dd2d67 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
@@ -25,6 +25,7 @@ fun ReadingScreen(
onComplete: () -> Unit
) {
val secrets by scanScreenController.secretsFlow.collectAsState(null)
+ val transferStatus by scanScreenController.transferStatusFlow.collectAsState(null)
var cardsRead by remember { mutableStateOf?>(null) }
@@ -116,7 +117,10 @@ fun ReadingScreen(
}
if (readingScreen) {
- ActionScreen(topBannerText = stringResource(R.string.reading_character_title),) {
+ ActionScreen(
+ topBannerText = stringResource(R.string.reading_character_title),
+ transferStatusMessage = transferStatus,
+ ) {
Log.d(TAG, "ActionScreen.onCancel: cancelRead + onCancel")
readingScreen = false
scanScreenController.cancelRead()
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 ca6286f..53265bf 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
@@ -30,6 +30,7 @@ fun WritingScreen(
onCancel: () -> Unit,
) {
val secrets by scanScreenController.secretsFlow.collectAsState(null)
+ val transferStatus by scanScreenController.transferStatusFlow.collectAsState(null)
val application = LocalContext.current.applicationContext as VBHelper
val storageRepository = StorageRepository(application.container.db)
@@ -103,7 +104,10 @@ fun WritingScreen(
)
} else if (!isDoneSendingCard) {
writing = true
- ActionScreen( topBannerText = stringResource(R.string.sending_card_title)) {
+ ActionScreen(
+ topBannerText = stringResource(R.string.sending_card_title),
+ transferStatusMessage = transferStatus,
+ ) {
scanScreenController.cancelRead()
onCancel()
}
@@ -121,7 +125,10 @@ fun WritingScreen(
)
} else if (!isDoneWritingCharacter) {
writing = true
- ActionScreen(topBannerText = stringResource(R.string.writing_character_action_title)) {
+ ActionScreen(
+ topBannerText = stringResource(R.string.writing_character_action_title),
+ transferStatusMessage = transferStatus,
+ ) {
isDoneSendingCard = false
scanScreenController.cancelRead()
onCancel()
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 e67c96a..5d49835 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
@@ -2,9 +2,30 @@ package com.github.nacabaro.vbhelper.source
import com.github.cfogrady.vitalwear.protos.Character
import com.github.nacabaro.vbhelper.database.AppDatabase
+import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData
import com.github.nacabaro.vbhelper.utils.DeviceType
import kotlinx.coroutines.flow.firstOrNull
-import kotlinx.coroutines.runBlocking
+
+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
+ }
+}
class VitalWearCharacterExporter(
private val database: AppDatabase
@@ -16,22 +37,29 @@ class VitalWearCharacterExporter(
suspend fun buildCharacterProto(characterId: Long): Character {
val characterWithSprites = database.userCharacterDao().getCharacterWithSprites(characterId)
val userCharacter = database.userCharacterDao().getCharacter(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),
+ )
return Character.newBuilder()
.setCardId(card.cardId)
.setCardName(card.name)
.setCharacterStats(
Character.CharacterStats.newBuilder()
- .setSlotId(database.userCharacterDao().getCharacterInfo(characterId).charaIndex)
+ .setSlotId(characterInfo.charId)
.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))
+ .setTrainingTimeRemainingInSeconds(resolveTrainingSeconds(userCharacter.characterType, 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)
@@ -40,6 +68,29 @@ class VitalWearCharacterExporter(
.setTotalWins(characterWithSprites.totalBattlesWon)
.setCurrentPhaseWins(characterWithSprites.currentPhaseBattlesWon)
.setMood(characterWithSprites.mood)
+ .setDeviceType(userCharacter.characterType.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(
@@ -61,32 +112,23 @@ class VitalWearCharacterExporter(
.build()
}
- 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 resolveTrainedBp(beData: com.github.nacabaro.vbhelper.domain.device_data.BECharacterData?): Int {
+ return beData?.trainingBp ?: 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 resolveTrainedHp(beData: com.github.nacabaro.vbhelper.domain.device_data.BECharacterData?): Int {
+ return beData?.trainingHp ?: 0
}
- private fun resolveTrainedHp(characterId: Long, deviceType: DeviceType): Int {
- return if (deviceType == DeviceType.BEDevice) {
- database.userCharacterDao().getBeData(characterId).valueOrNull()?.trainingHp ?: 0
- } else 0
+ private fun resolveTrainedAp(beData: com.github.nacabaro.vbhelper.domain.device_data.BECharacterData?): Int {
+ return beData?.trainingAp ?: 0
}
- private fun resolveTrainedAp(characterId: Long, deviceType: DeviceType): Int {
- return if (deviceType == DeviceType.BEDevice) {
- database.userCharacterDao().getBeData(characterId).valueOrNull()?.trainingAp ?: 0
- } else 0
+ private suspend fun hasPossibleTransformations(cardCharacterId: Long): Boolean {
+ return database.characterDao().getEvolutionRequirementsForCard(cardCharacterId).firstOrNull()?.isNotEmpty() == true
}
- private fun kotlinx.coroutines.flow.Flow.valueOrNull(): T? {
- return runBlocking { firstOrNull() }
- }
private fun resolveAllowedBattles(rawValue: Int?): Character.Settings.AllowedBattles {
if (rawValue == null) return Character.Settings.AllowedBattles.CARD_ONLY
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 07bba8d..a04050d 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
@@ -10,6 +10,7 @@ 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.utils.DeviceType
+import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.runBlocking
import kotlin.math.max
@@ -43,23 +44,37 @@ class VitalWearCharacterImporter(
val totalWins = character.characterStats.totalWins.coerceAtMost(totalBattles)
val currentPhaseBattles = character.characterStats.currentPhaseBattles
val currentPhaseWins = character.characterStats.currentPhaseWins.coerceAtMost(currentPhaseBattles)
- val deviceType = if (importedCard.isBEm) DeviceType.BEDevice else DeviceType.VBDevice
+ 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 = max(character.transformationHistoryCount - 1, 0),
+ ageInDays = character.characterStats.ageInDays.takeIf { it > 0 }
+ ?: max(character.transformationHistoryCount - 1, 0),
mood = character.characterStats.mood,
vitalPoints = character.characterStats.vitals,
- transformationCountdown = secondsToMinutes(character.characterStats.timeUntilNextTransformation),
+ 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 = 0,
- heartRateCurrent = 0,
+ activityLevel = character.characterStats.activityLevel.coerceAtLeast(0),
+ heartRateCurrent = character.characterStats.heartRateCurrent.coerceAtLeast(0),
characterType = deviceType,
isActive = true
)
@@ -76,7 +91,8 @@ class VitalWearCharacterImporter(
)
}
- if (importedCard.isBEm) {
+ if (isBeCharacter) {
+ val abilityRarity = resolveAbilityRarity(character.characterStats.abilityRarity)
database.userCharacterDao().insertBECharacterData(
BECharacterData(
id = userCharacterId,
@@ -84,32 +100,40 @@ class VitalWearCharacterImporter(
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,
+ 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 = 0,
- majorVersion = 0
+ minorVersion = character.characterStats.firmwareMinorVersion,
+ majorVersion = character.characterStats.firmwareMajorVersion
)
)
} else {
database.userCharacterDao().insertVBCharacterData(
VBCharacterData(
id = userCharacterId,
- generation = max(character.transformationHistoryCount - 1, 0),
- totalTrophies = character.characterStats.trainedPp.coerceAtLeast(0)
+ 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)
+ }
)
)
}
@@ -162,6 +186,15 @@ 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."
@@ -203,7 +236,14 @@ class VitalWearCharacterImporter(
if (seconds <= 0L) {
return 0
}
- return (seconds / 60L).coerceAtMost(Int.MAX_VALUE.toLong()).toInt()
+ // 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
+ }
}
private fun resolveInjuryStatus(injured: Boolean): NfcCharacter.InjuryStatus {
@@ -225,6 +265,11 @@ class VitalWearCharacterImporter(
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)
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/source/VitalWearTransformationCountdown.kt b/app/src/main/java/com/github/nacabaro/vbhelper/source/VitalWearTransformationCountdown.kt
new file mode 100644
index 0000000..13c8dfd
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/source/VitalWearTransformationCountdown.kt
@@ -0,0 +1,38 @@
+package com.github.nacabaro.vbhelper.source
+
+import com.github.cfogrady.vitalwear.protos.Character
+import com.github.nacabaro.vbhelper.utils.DeviceType
+
+internal const val MINIMUM_TRANSFORMATION_COUNTDOWN_MINUTES = 1
+
+internal fun normalizeTransformationCountdownMinutes(
+ transformationCountdownMinutes: Int,
+ hasPossibleTransformations: Boolean,
+): Int {
+ val sanitizedCountdown = transformationCountdownMinutes.coerceAtLeast(0)
+ if (!hasPossibleTransformations) {
+ return sanitizedCountdown
+ }
+ return sanitizedCountdown.coerceAtLeast(MINIMUM_TRANSFORMATION_COUNTDOWN_MINUTES)
+}
+
+internal fun DeviceType.toTransferDeviceType(): Character.CharacterStats.TransferDeviceType {
+ return when (this) {
+ DeviceType.BEDevice -> Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_BE
+ DeviceType.VBDevice,
+ DeviceType.VitalWear -> Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_VB
+ }
+}
+
+internal fun resolveDeviceType(
+ transferDeviceType: Character.CharacterStats.TransferDeviceType,
+ fallbackIsBeCharacter: Boolean,
+): DeviceType {
+ return when (transferDeviceType) {
+ Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_BE -> DeviceType.BEDevice
+ Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_VB -> DeviceType.VBDevice
+ Character.CharacterStats.TransferDeviceType.UNRECOGNIZED,
+ Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_UNSPECIFIED -> if (fallbackIsBeCharacter) DeviceType.BEDevice else DeviceType.VBDevice
+ }
+}
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/transfer/CardImportProvider.kt b/app/src/main/java/com/github/nacabaro/vbhelper/transfer/CardImportProvider.kt
new file mode 100644
index 0000000..674354a
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/transfer/CardImportProvider.kt
@@ -0,0 +1,106 @@
+package com.github.nacabaro.vbhelper.transfer
+
+import android.content.ContentProvider
+import android.content.ContentValues
+import android.database.Cursor
+import android.net.Uri
+import android.os.Bundle
+import com.github.cfogrady.vb.dim.card.DimReader
+import com.github.nacabaro.vbhelper.di.VBHelper
+import com.github.nacabaro.vbhelper.screens.settingsScreen.controllers.CardImportController
+import kotlinx.coroutines.runBlocking
+
+/**
+ * ContentProvider that lets the VitalWear Companion app read and write card data into
+ * VBHelper's single database (internalDb). Both apps are signed with the same key, so
+ * the signature-level permission is automatically granted without user interaction.
+ *
+ * Usage from the companion:
+ * val names = call(METHOD_GET_CARD_NAMES)?.getStringArray(EXTRA_CARD_NAMES_LIST)
+ * call(METHOD_IMPORT_CARD, null, Bundle { putByteArray(EXTRA_CARD_BYTES, ...) })
+ */
+class CardImportProvider : ContentProvider() {
+
+ companion object {
+ const val AUTHORITY = "com.github.nacabaro.vbhelper.cardimport"
+ val URI: Uri = Uri.parse("content://$AUTHORITY")
+
+ /** Returns a Bundle with EXTRA_CARD_NAMES_LIST containing all imported card names. */
+ const val METHOD_GET_CARD_NAMES = "getCardNames"
+
+ /**
+ * Parses and stores a DIM card.
+ * Required extras: EXTRA_CARD_BYTES (ByteArray)
+ * Optional extras: EXTRA_CARD_NAME (String) — user-visible label; if omitted the DIM
+ * file's built-in name is used.
+ * Returns a Bundle with RESULT_OK = true on success.
+ */
+ const val METHOD_IMPORT_CARD = "importCard"
+
+ const val EXTRA_CARD_BYTES = "cardBytes"
+ const val EXTRA_CARD_NAME = "cardName"
+ const val EXTRA_CARD_NAMES_LIST = "cardNames"
+ const val RESULT_OK = "ok"
+ }
+
+ override fun onCreate(): Boolean = true
+
+ override fun call(method: String, arg: String?, extras: Bundle?): Bundle {
+ val ctx = context ?: return Bundle().apply { putBoolean(RESULT_OK, false) }
+ val db = (ctx.applicationContext as VBHelper).container.db
+
+ return when (method) {
+
+ METHOD_GET_CARD_NAMES -> runCatching {
+ val names = db.cardDao().getAllCards().map { it.name }.toTypedArray()
+ Bundle().apply { putStringArray(EXTRA_CARD_NAMES_LIST, names) }
+ }.getOrElse {
+ Bundle().apply { putStringArray(EXTRA_CARD_NAMES_LIST, emptyArray()) }
+ }
+
+ METHOD_IMPORT_CARD -> {
+ val cardBytes = extras?.getByteArray(EXTRA_CARD_BYTES)
+ ?: return Bundle().apply { putBoolean(RESULT_OK, false) }
+ val customName = extras.getString(EXTRA_CARD_NAME)
+
+ runCatching {
+ runBlocking {
+ val dimReader = DimReader()
+ val peeked = cardBytes.inputStream().use { dimReader.readCard(it, false) }
+ val builtInName = peeked.spriteData.text
+ val dimId = peeked.header.dimId
+
+ val alreadyExists =
+ db.cardDao().getCardByName(builtInName) != null ||
+ db.cardDao().getCardByCardId(dimId).isNotEmpty()
+
+ if (!alreadyExists) {
+ CardImportController(db).importCard(cardBytes.inputStream())
+ // Rename to the user's custom label if it differs from the DIM's text
+ if (!customName.isNullOrBlank() && customName != builtInName) {
+ db.cardDao().getCardByName(builtInName)?.let { card ->
+ db.cardDao().renameCard(card.id.toInt(), customName)
+ }
+ }
+ }
+ }
+ Bundle().apply { putBoolean(RESULT_OK, true) }
+ }.getOrElse {
+ Bundle().apply { putBoolean(RESULT_OK, false) }
+ }
+ }
+
+ else -> Bundle().apply { putBoolean(RESULT_OK, false) }
+ }
+ }
+
+ // ---- Unused mandatory overrides ----
+ override fun query(uri: Uri, projection: Array?, selection: String?,
+ selectionArgs: Array?, sortOrder: String?): Cursor? = null
+ override fun getType(uri: Uri): String? = null
+ override fun insert(uri: Uri, values: ContentValues?): Uri? = null
+ override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = 0
+ override fun update(uri: Uri, values: ContentValues?, selection: String?,
+ selectionArgs: Array?): Int = 0
+}
+
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 263b4d8..3c7a06c 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
@@ -8,6 +8,12 @@ message Character {
int32 card_id = 2;
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;
@@ -23,6 +29,29 @@ 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;
diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml
index 9b8cccf..17a5f50 100644
--- a/app/src/main/res/values-pt-rBR/strings.xml
+++ b/app/src/main/res/values-pt-rBR/strings.xml
@@ -58,6 +58,12 @@
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
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index d10d0b9..3235e11 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -62,6 +62,12 @@
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.
Settings
diff --git a/app/src/test/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterExporterTest.kt b/app/src/test/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterExporterTest.kt
new file mode 100644
index 0000000..edc09d5
--- /dev/null
+++ b/app/src/test/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterExporterTest.kt
@@ -0,0 +1,58 @@
+package com.github.nacabaro.vbhelper.source
+
+import com.github.cfogrady.vbnfc.data.NfcCharacter
+import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData
+import com.github.nacabaro.vbhelper.utils.DeviceType
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class VitalWearCharacterExporterTest {
+ @Test
+ fun resolveTrainingSeconds_preservesBeTrainingMinutesAsSeconds() {
+ val beData = testBeData(remainingTrainingTimeInMinutes = 37)
+
+ assertEquals(37L * 60L, resolveTrainingSeconds(DeviceType.BEDevice, beData))
+ }
+
+ @Test
+ fun resolveTrainingSeconds_defaultsNonBeCharactersToVitalWearTrainingWindow() {
+ assertEquals(
+ DEFAULT_VITALWEAR_TRAINING_TIME_SECONDS,
+ resolveTrainingSeconds(DeviceType.VBDevice, beData = null)
+ )
+ }
+
+ @Test
+ fun resolveTrainingSeconds_keepsZeroForBeCharactersWithoutStoredBeData() {
+ assertEquals(0L, resolveTrainingSeconds(DeviceType.BEDevice, beData = null))
+ }
+
+ private fun testBeData(remainingTrainingTimeInMinutes: Int): BECharacterData {
+ return BECharacterData(
+ id = 1L,
+ trainingHp = 0,
+ trainingAp = 0,
+ trainingBp = 0,
+ remainingTrainingTimeInMinutes = remainingTrainingTimeInMinutes,
+ itemEffectMentalStateValue = 0,
+ itemEffectMentalStateMinutesRemaining = 0,
+ itemEffectActivityLevelValue = 0,
+ itemEffectActivityLevelMinutesRemaining = 0,
+ itemEffectVitalPointsChangeValue = 0,
+ itemEffectVitalPointsChangeMinutesRemaining = 0,
+ abilityRarity = NfcCharacter.AbilityRarity.entries.first(),
+ abilityType = 0,
+ abilityBranch = 0,
+ abilityReset = 0,
+ rank = 0,
+ itemType = 0,
+ itemMultiplier = 0,
+ itemRemainingTime = 0,
+ otp0 = "",
+ otp1 = "",
+ minorVersion = 0,
+ majorVersion = 0,
+ )
+ }
+}
+
diff --git a/app/src/test/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterImporterTest.kt b/app/src/test/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterImporterTest.kt
index 39bd4ff..7a1f634 100644
--- a/app/src/test/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterImporterTest.kt
+++ b/app/src/test/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterImporterTest.kt
@@ -1,11 +1,61 @@
package com.github.nacabaro.vbhelper.source
+import com.github.cfogrady.vitalwear.protos.Character
import com.github.nacabaro.vbhelper.domain.card.Card
+import com.github.nacabaro.vbhelper.utils.DeviceType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Test
class VitalWearCharacterImporterTest {
+ @Test
+ fun normalizeTransformationCountdownMinutes_keepsOneMinuteMinimumWhenCharacterCanStillEvolve() {
+ assertEquals(1, normalizeTransformationCountdownMinutes(0, hasPossibleTransformations = true))
+ }
+
+ @Test
+ fun normalizeTransformationCountdownMinutes_preservesPositiveCountdownWhenCharacterCanStillEvolve() {
+ assertEquals(12, normalizeTransformationCountdownMinutes(12, hasPossibleTransformations = true))
+ }
+
+ @Test
+ fun normalizeTransformationCountdownMinutes_allowsZeroWhenNoTransformationsRemain() {
+ assertEquals(0, normalizeTransformationCountdownMinutes(0, hasPossibleTransformations = false))
+ }
+
+ @Test
+ fun resolveDeviceType_prefersExplicitTransferTypeOverFallbackHeuristic() {
+ assertEquals(
+ DeviceType.VBDevice,
+ resolveDeviceType(
+ transferDeviceType = Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_VB,
+ fallbackIsBeCharacter = true,
+ )
+ )
+ }
+
+ @Test
+ fun resolveDeviceType_fallsBackToBeWhenTransferTypeIsUnspecified() {
+ assertEquals(
+ DeviceType.BEDevice,
+ resolveDeviceType(
+ transferDeviceType = Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_UNSPECIFIED,
+ fallbackIsBeCharacter = true,
+ )
+ )
+ }
+
+ @Test
+ fun resolveDeviceType_usesFallbackWhenTransferTypeIsUnrecognized() {
+ assertEquals(
+ DeviceType.VBDevice,
+ resolveDeviceType(
+ transferDeviceType = Character.CharacterStats.TransferDeviceType.UNRECOGNIZED,
+ fallbackIsBeCharacter = false,
+ )
+ )
+ }
+
@Test
fun selectImportedCard_prefersUniqueCardIdWhenNameWasRenamed() {
val selected = selectImportedCard(
diff --git a/build/reports/problems/problems-report.html b/build/reports/problems/problems-report.html
index ad96d9d..2f7e871 100644
--- a/build/reports/problems/problems-report.html
+++ b/build/reports/problems/problems-report.html
@@ -650,7 +650,7 @@ code + .copy-button {
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 7e4875e..97d43aa 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -16,7 +16,7 @@ protobufGradlePlugin = "0.9.6"
protobufJavalite = "4.33.4"
roomRuntime = "2.8.4"
vbNfcReader = "0.2.0-SNAPSHOT"
-dimReader = "2.1.0"
+dimReader = "2.0.0"
[libraries]
androidx-compose-material = { module = "androidx.compose.material:material" }
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 917b611..50f6d5b 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -22,3 +22,15 @@ dependencyResolutionManagement {
rootProject.name = "VBHelper"
include(":app")
+
+includeBuild("../VB-DIM-Reader-2.0.0") {
+ dependencySubstitution {
+ substitute(module("com.github.cfogrady:vb-dim-reader")).using(project(":"))
+ }
+}
+
+includeBuild("../vb-nfc-reader") {
+ dependencySubstitution {
+ substitute(module("com.github.cfogrady:vb-nfc-reader")).using(project(":"))
+ }
+}
From 8fd4f6c4148e399bb7ab51bbe47a15baa0530922 Mon Sep 17 00:00:00 2001
From: Taveon Nelson <74945303+457R0@users.noreply.github.com>
Date: Mon, 18 May 2026 04:57:57 -0500
Subject: [PATCH 08/13] VBH is the companion app now
Got tired of having to reinstall all three apps so i ported the companion apps features to vitalwear, now you only need VBH and Vitalwear.
I've also added emblems to items in icons so user isnt confused.
---
app/src/main/AndroidManifest.xml | 1 +
.../github/nacabaro/vbhelper/MainActivity.kt | 14 ++-
.../daos/CharacterTransferPolicyDao.kt | 17 +++
.../nacabaro/vbhelper/database/AppDatabase.kt | 28 ++++-
.../device_data/CharacterTransferPolicy.kt | 32 +++++
.../vbhelper/navigation/AppNavigation.kt | 17 ++-
.../navigation/BottomNavigationBar.kt | 29 ++++-
.../scanScreen/ScanScreenControllerImpl.kt | 63 ++++++++--
.../scanScreen/converters/FromNfcConverter.kt | 15 +++
.../scanScreen/converters/ToNfcConverter.kt | 29 +++--
.../source/VitalWearCharacterExporter.kt | 15 ++-
.../source/VitalWearCharacterImporter.kt | 37 ++++++
.../CharacterTransferPolicyResolver.kt | 112 ++++++++++++++++++
.../vbhelper/transfer/TransferRoute.kt | 31 +++++
build/reports/problems/problems-report.html | 2 +-
15 files changed, 414 insertions(+), 28 deletions(-)
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/daos/CharacterTransferPolicyDao.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/CharacterTransferPolicy.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/transfer/CharacterTransferPolicyResolver.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/transfer/TransferRoute.kt
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 05da0ad..b2c32af 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -54,6 +54,7 @@
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
+ android:launchMode="singleTask"
android:theme="@style/Theme.VBHelper">
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 4fd2e1f..3be27d1 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/MainActivity.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/MainActivity.kt
@@ -19,11 +19,14 @@ import com.github.nacabaro.vbhelper.screens.cardScreen.CardScreenControllerImpl
import com.github.nacabaro.vbhelper.screens.spriteViewer.SpriteViewerControllerImpl
import com.github.nacabaro.vbhelper.screens.storageScreen.StorageScreenControllerImpl
import com.github.nacabaro.vbhelper.ui.theme.VBHelperTheme
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
class MainActivity : ComponentActivity() {
private val onActivityLifecycleListeners = HashMap()
private var initialRoute: String? = null
+ private val deepLinkNavigationEvents = MutableSharedFlow(extraBufferCapacity = 1)
private fun registerActivityLifecycleListener(key: String, activityLifecycleListener: ActivityLifecycleListener) {
if( onActivityLifecycleListeners[key] != null) {
@@ -57,6 +60,7 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge()
initialRoute = getInitialRouteFromIntent(intent)
+ initialRoute?.let { deepLinkNavigationEvents.tryEmit(it) }
setContent {
VBHelperTheme {
@@ -69,7 +73,8 @@ class MainActivity : ComponentActivity() {
storageScreenController = storageScreenController,
spriteViewerController = spriteViewerController,
cardScreenController = cardScreenController,
- initialRoute = initialRoute
+ initialRoute = initialRoute,
+ deepLinkNavigationEvents = deepLinkNavigationEvents.asSharedFlow(),
)
}
}
@@ -97,6 +102,7 @@ class MainActivity : ComponentActivity() {
super.onNewIntent(intent)
setIntent(intent)
initialRoute = getInitialRouteFromIntent(intent)
+ initialRoute?.let { deepLinkNavigationEvents.tryEmit(it) }
}
private fun getInitialRouteFromIntent(intent: Intent?): String? {
@@ -120,7 +126,8 @@ class MainActivity : ComponentActivity() {
homeScreenController: HomeScreenControllerImpl,
spriteViewerController: SpriteViewerControllerImpl,
cardScreenController: CardScreenControllerImpl,
- initialRoute: String? = null
+ initialRoute: String? = null,
+ deepLinkNavigationEvents: kotlinx.coroutines.flow.Flow,
) {
AppNavigation(
applicationNavigationHandlers = AppNavigationHandlers(
@@ -133,7 +140,8 @@ class MainActivity : ComponentActivity() {
spriteViewerController,
cardScreenController
),
- initialRoute = initialRoute
+ initialRoute = initialRoute,
+ navigationEvents = deepLinkNavigationEvents,
)
}
}
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/daos/CharacterTransferPolicyDao.kt b/app/src/main/java/com/github/nacabaro/vbhelper/daos/CharacterTransferPolicyDao.kt
new file mode 100644
index 0000000..7f65e07
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/daos/CharacterTransferPolicyDao.kt
@@ -0,0 +1,17 @@
+package com.github.nacabaro.vbhelper.daos
+
+import androidx.room.Dao
+import androidx.room.Query
+import androidx.room.Upsert
+import com.github.nacabaro.vbhelper.domain.device_data.CharacterTransferPolicy
+
+@Dao
+interface CharacterTransferPolicyDao {
+ @Query("SELECT * FROM CharacterTransferPolicy WHERE characterId = :characterId")
+ fun getByCharacterId(characterId: Long): CharacterTransferPolicy?
+
+ @Upsert
+ fun upsert(policy: CharacterTransferPolicy)
+}
+
+
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 b2067e5..06b77b8 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/database/AppDatabase.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/database/AppDatabase.kt
@@ -7,6 +7,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
import com.github.nacabaro.vbhelper.daos.AdventureDao
import com.github.nacabaro.vbhelper.daos.CardAdventureDao
import com.github.nacabaro.vbhelper.daos.CharacterDao
+import com.github.nacabaro.vbhelper.daos.CharacterTransferPolicyDao
import com.github.nacabaro.vbhelper.daos.DexDao
import com.github.nacabaro.vbhelper.daos.CardDao
import com.github.nacabaro.vbhelper.daos.CardFusionsDao
@@ -27,6 +28,7 @@ import com.github.nacabaro.vbhelper.domain.characters.Sprite
import com.github.nacabaro.vbhelper.domain.characters.Adventure
import com.github.nacabaro.vbhelper.domain.characters.Dex
import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData
+import com.github.nacabaro.vbhelper.domain.device_data.CharacterTransferPolicy
import com.github.nacabaro.vbhelper.domain.device_data.SpecialMissions
import com.github.nacabaro.vbhelper.domain.device_data.TransformationHistory
import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter
@@ -36,7 +38,7 @@ import com.github.nacabaro.vbhelper.domain.device_data.VitalWearCharacterSetting
import com.github.nacabaro.vbhelper.domain.items.Items
@Database(
- version = 5,
+ version = 6,
entities = [
Card::class,
CardProgress::class,
@@ -50,6 +52,7 @@ import com.github.nacabaro.vbhelper.domain.items.Items
SpecialMissions::class,
TransformationHistory::class,
VitalsHistory::class,
+ CharacterTransferPolicy::class,
Dex::class,
Items::class,
Adventure::class,
@@ -71,6 +74,7 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun cardAdventureDao(): CardAdventureDao
abstract fun cardFusionsDao(): CardFusionsDao
abstract fun vitalWearSettingsDao(): VitalWearSettingsDao
+ abstract fun characterTransferPolicyDao(): CharacterTransferPolicyDao
companion object {
val MIGRATION_1_2 = object : Migration(1, 2) {
@@ -139,5 +143,27 @@ abstract class AppDatabase : RoomDatabase() {
}
}
+ val MIGRATION_5_6 = object : Migration(5, 6) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL(
+ """
+ CREATE TABLE IF NOT EXISTS `CharacterTransferPolicy` (
+ `characterId` INTEGER NOT NULL,
+ `nativeDeviceType` TEXT NOT NULL,
+ `preferredHceExportFormat` TEXT NOT NULL,
+ `preferredNfcaExportFormat` TEXT NOT NULL,
+ `lastObservedImportFormat` TEXT,
+ `lastTransferTransport` TEXT,
+ `lastTransferTarget` TEXT,
+ `preserveVbRoundTrip` INTEGER NOT NULL,
+ `preserveBeRoundTrip` INTEGER NOT NULL,
+ PRIMARY KEY(`characterId`),
+ FOREIGN KEY(`characterId`) REFERENCES `UserCharacter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE
+ )
+ """.trimIndent()
+ )
+ }
+ }
+
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/CharacterTransferPolicy.kt b/app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/CharacterTransferPolicy.kt
new file mode 100644
index 0000000..0a541d6
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/CharacterTransferPolicy.kt
@@ -0,0 +1,32 @@
+package com.github.nacabaro.vbhelper.domain.device_data
+
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.PrimaryKey
+import com.github.nacabaro.vbhelper.transfer.ExportFormat
+import com.github.nacabaro.vbhelper.transfer.TransferTarget
+import com.github.nacabaro.vbhelper.transfer.TransferTransport
+import com.github.nacabaro.vbhelper.utils.DeviceType
+
+@Entity(
+ foreignKeys = [
+ ForeignKey(
+ entity = UserCharacter::class,
+ parentColumns = ["id"],
+ childColumns = ["characterId"],
+ onDelete = ForeignKey.CASCADE,
+ )
+ ]
+)
+data class CharacterTransferPolicy(
+ @PrimaryKey val characterId: Long,
+ val nativeDeviceType: DeviceType,
+ val preferredHceExportFormat: ExportFormat,
+ val preferredNfcaExportFormat: ExportFormat,
+ val lastObservedImportFormat: ExportFormat?,
+ val lastTransferTransport: TransferTransport?,
+ val lastTransferTarget: TransferTarget?,
+ val preserveVbRoundTrip: Boolean,
+ val preserveBeRoundTrip: Boolean,
+)
+
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 86b474d..2b3d047 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,6 +6,7 @@ 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
@@ -39,6 +40,7 @@ import com.github.nacabaro.vbhelper.screens.settingsScreen.CreditsScreen
import com.github.nacabaro.vbhelper.screens.spriteViewer.SpriteViewerControllerImpl
import com.github.nacabaro.vbhelper.screens.storageScreen.StorageScreenControllerImpl
import com.github.nacabaro.vbhelper.source.StorageRepository
+import kotlinx.coroutines.flow.Flow
data class AppNavigationHandlers(
val settingsScreenController: SettingsScreenControllerImpl,
@@ -54,10 +56,23 @@ data class AppNavigationHandlers(
@Composable
fun AppNavigation(
applicationNavigationHandlers: AppNavigationHandlers,
- initialRoute: String? = null
+ initialRoute: String? = null,
+ navigationEvents: Flow? = null,
) {
val navController = rememberNavController()
+ LaunchedEffect(navController, navigationEvents) {
+ navigationEvents?.collect { route ->
+ navController.navigate(route) {
+ launchSingleTop = true
+ restoreState = true
+ popUpTo(navController.graph.startDestinationId) {
+ saveState = true
+ }
+ }
+ }
+ }
+
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 16e6728..672ffd7 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
@@ -31,9 +31,32 @@ fun BottomNavigationBar(navController: NavController) {
selected = currentRoute == item.route,
onClick = {
if (item == NavigationItems.Home) {
- navController.navigate(NavigationItems.Home.route) {
- popUpTo(0) { inclusive = false }
- launchSingleTop = true
+ // Home should always show the Home root, not a restored nested route
+ // like Settings that was opened from Home previously.
+ val poppedToHome = navController.popBackStack(
+ NavigationItems.Home.route,
+ inclusive = false,
+ )
+ if (!poppedToHome) {
+ navController.navigate(NavigationItems.Home.route) {
+ popUpTo(navController.graph.startDestinationId) { inclusive = false }
+ launchSingleTop = true
+ restoreState = false
+ }
+ }
+ } else if (item == NavigationItems.Storage) {
+ // Adventure is launched from Storage; tapping Storage again should always
+ // bring the user back to the Storage root instead of appearing stuck.
+ val poppedToStorage = navController.popBackStack(
+ NavigationItems.Storage.route,
+ inclusive = false,
+ )
+ if (!poppedToStorage) {
+ navController.navigate(NavigationItems.Storage.route) {
+ popUpTo(navController.graph.startDestinationId) { saveState = true }
+ launchSingleTop = true
+ restoreState = true
+ }
}
} else {
navController.navigate(item.route) {
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 81a9d0a..ff3506f 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
@@ -22,9 +22,12 @@ import com.github.nacabaro.vbhelper.screens.scanScreen.converters.FromNfcConvert
import com.github.nacabaro.vbhelper.screens.scanScreen.converters.ToNfcConverter
import com.github.nacabaro.vbhelper.source.VitalWearCharacterExporter
import com.github.nacabaro.vbhelper.source.VitalWearCharacterImporter
+import com.github.nacabaro.vbhelper.transfer.ExportFormat
import com.github.nacabaro.vbhelper.source.getCryptographicTransformerMap
import com.github.nacabaro.vbhelper.source.isMissingSecrets
import com.github.nacabaro.vbhelper.source.proto.Secrets
+import com.github.nacabaro.vbhelper.transfer.TransferTarget
+import com.github.nacabaro.vbhelper.transfer.TransferTransport
import com.github.nacabaro.vbhelper.transfer.hce.VitalWearHceReaderClient
import com.github.nacabaro.vbhelper.R
import kotlinx.coroutines.Dispatchers
@@ -213,15 +216,51 @@ class ScanScreenControllerImpl(
onComplete.invoke(ScanScreenController.WriteResult.MOVE_CONFIRMED)
componentActivity.getString(R.string.scan_sent_character_success)
} catch (writeError: Throwable) {
- Log.e("NFC_WRITE_A", "NFC-A write failed; attempting opposite direction", writeError)
- runCatching {
- val receivedCharacter = tagCommunicator.receiveCharacter()
- val fallbackMessage = importCharacterFromNfcAFallback(receivedCharacter)
- onComplete.invoke(ScanScreenController.WriteResult.COPIED)
- fallbackMessage
- }.getOrElse { readFallbackError ->
- Log.e("NFC_WRITE_A", "NFC-A opposite-direction fallback failed", readFallbackError)
- componentActivity.getString(R.string.scan_error_generic)
+ Log.e("NFC_WRITE_A", "NFC-A write failed; trying alternate payload format", writeError)
+ val characterId = lastRequestedCharacterId
+ val alternateFormat = when (nfcCharacter) {
+ is BENfcCharacter -> ExportFormat.VB
+ is VBNfcCharacter -> ExportFormat.BE
+ else -> null
+ }
+
+ val alternateSent = if (characterId != null && alternateFormat != null) {
+ runCatching {
+ val alternateCharacter = runBlocking {
+ ToNfcConverter(componentActivity = componentActivity).characterToNfc(
+ characterId = characterId,
+ target = TransferTarget.REAL_BRACELET,
+ transport = TransferTransport.NFCA,
+ forcedFormat = alternateFormat,
+ )
+ }
+ when (alternateCharacter) {
+ is VBNfcCharacter -> tagCommunicator.sendCharacter(alternateCharacter)
+ is BENfcCharacter -> tagCommunicator.sendCharacter(alternateCharacter)
+ }
+ true
+ }.getOrElse { alternateError ->
+ Log.e("NFC_WRITE_A", "Alternate payload write failed", alternateError)
+ false
+ }
+ } else {
+ false
+ }
+
+ if (alternateSent) {
+ onComplete.invoke(ScanScreenController.WriteResult.MOVE_CONFIRMED)
+ "Sent character after converting payload for the tapped bracelet."
+ } else {
+ Log.e("NFC_WRITE_A", "NFC-A write failed; attempting opposite direction fallback", writeError)
+ runCatching {
+ val receivedCharacter = tagCommunicator.receiveCharacter()
+ val fallbackMessage = importCharacterFromNfcAFallback(receivedCharacter)
+ onComplete.invoke(ScanScreenController.WriteResult.COPIED)
+ fallbackMessage
+ }.getOrElse { readFallbackError ->
+ Log.e("NFC_WRITE_A", "NFC-A opposite-direction fallback failed", readFallbackError)
+ componentActivity.getString(R.string.scan_error_generic)
+ }
}
}
},
@@ -707,7 +746,11 @@ class ScanScreenControllerImpl(
override suspend fun characterToNfc(characterId: Long): NfcCharacter {
lastRequestedCharacterId = characterId
- val character = ToNfcConverter(componentActivity = componentActivity).characterToNfc(characterId)
+ val character = ToNfcConverter(componentActivity = componentActivity).characterToNfc(
+ characterId = characterId,
+ target = TransferTarget.REAL_BRACELET,
+ transport = TransferTransport.NFCA,
+ )
Log.d("CharacterType", character.toString())
return character
}
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 2cde698..6f20581 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/converters/FromNfcConverter.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/converters/FromNfcConverter.kt
@@ -11,6 +11,8 @@ import com.github.nacabaro.vbhelper.domain.device_data.SpecialMissions
import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter
import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData
import com.github.nacabaro.vbhelper.domain.device_data.VitalsHistory
+import com.github.nacabaro.vbhelper.transfer.CharacterTransferPolicyResolver
+import com.github.nacabaro.vbhelper.transfer.ExportFormat
import com.github.nacabaro.vbhelper.utils.DeviceType
import java.util.GregorianCalendar
@@ -20,6 +22,7 @@ class FromNfcConverter (
private val application = componentActivity.applicationContext as VBHelper
private val database = application.container.db
private val transferSeenDao = application.container.transferSeenDao
+ private val transferPolicyResolver = CharacterTransferPolicyResolver(database.characterTransferPolicyDao())
fun addCharacterUsingCard(
@@ -134,6 +137,18 @@ class FromNfcConverter (
)
}
+ database
+ .characterTransferPolicyDao()
+ .upsert(
+ transferPolicyResolver.policyForNfcaImport(
+ characterId = characterId,
+ observedFormat = when (nfcCharacter) {
+ is BENfcCharacter -> ExportFormat.BE
+ else -> ExportFormat.VB
+ }
+ )
+ )
+
addTransformationHistoryToDatabase(
characterId = characterId,
nfcCharacter = nfcCharacter,
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 09fd168..86d3d52 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/converters/ToNfcConverter.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/converters/ToNfcConverter.kt
@@ -15,6 +15,10 @@ import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData
import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter
import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData
import com.github.nacabaro.vbhelper.dtos.CharacterDtos
+import com.github.nacabaro.vbhelper.transfer.CharacterTransferPolicyResolver
+import com.github.nacabaro.vbhelper.transfer.ExportFormat
+import com.github.nacabaro.vbhelper.transfer.TransferTarget
+import com.github.nacabaro.vbhelper.transfer.TransferTransport
import com.github.nacabaro.vbhelper.utils.DeviceType
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
@@ -30,11 +34,15 @@ class ToNfcConverter(
private val application: VBHelper = componentActivity.applicationContext as VBHelper
private val database: AppDatabase = application.container.db
+ private val transferPolicyResolver = CharacterTransferPolicyResolver(database.characterTransferPolicyDao())
suspend fun characterToNfc(
- characterId: Long
+ characterId: Long,
+ target: TransferTarget = TransferTarget.REAL_BRACELET,
+ transport: TransferTransport = TransferTransport.NFCA,
+ forcedFormat: ExportFormat? = null,
): NfcCharacter {
val app = componentActivity.applicationContext as VBHelper
val database = app.container.db
@@ -46,15 +54,20 @@ class ToNfcConverter(
val characterInfo = database
.characterDao()
.getCharacterInfo(userCharacter.charId)
+ val card = database
+ .cardDao()
+ .getCardByCharacterIdSync(characterId)
+ ?: error("Card not found for character $characterId")
- // Use the character's own type as the authoritative signal for encoding.
- // The card's isBEm flag only indicates the *card* is a BEM card, not that the
- // stored UserCharacter has a BECharacterData row. Mixing those two signals was
- // the root cause of a NoSuchElementException when a VB-type character was
- // associated with a BE-type card.
- val shouldEncodeAsBem = userCharacter.characterType == DeviceType.BEDevice
+ val exportFormat = forcedFormat ?: transferPolicyResolver.resolveExportFormat(
+ characterId = characterId,
+ characterType = userCharacter.characterType,
+ transport = transport,
+ target = target,
+ isBemCard = card.isBEm,
+ )
- return if (shouldEncodeAsBem)
+ return if (exportFormat == ExportFormat.BE)
nfcToBENfc(characterId, characterInfo, userCharacter)
else
nfcToVBNfc(characterId, characterInfo, userCharacter)
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 5d49835..2f710e6 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterExporter.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterExporter.kt
@@ -3,6 +3,10 @@ package com.github.nacabaro.vbhelper.source
import com.github.cfogrady.vitalwear.protos.Character
import com.github.nacabaro.vbhelper.database.AppDatabase
import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData
+import com.github.nacabaro.vbhelper.transfer.CharacterTransferPolicyResolver
+import com.github.nacabaro.vbhelper.transfer.TransferTarget
+import com.github.nacabaro.vbhelper.transfer.TransferTransport
+import com.github.nacabaro.vbhelper.transfer.toTransferDeviceType
import com.github.nacabaro.vbhelper.utils.DeviceType
import kotlinx.coroutines.flow.firstOrNull
@@ -30,6 +34,8 @@ internal fun resolveTrainingSeconds(
class VitalWearCharacterExporter(
private val database: AppDatabase
) {
+ private val transferPolicyResolver = CharacterTransferPolicyResolver(database.characterTransferPolicyDao())
+
/**
* Builds a Character proto from the stored character data.
* Used by the HCE ISO-DEP transfer path.
@@ -44,6 +50,13 @@ class VitalWearCharacterExporter(
val cardProgress = database.cardProgressDao().getCardProgressSync(card.id) ?: 0
val beData = database.userCharacterDao().getBeDataOrNull(characterId)
val vbData = database.userCharacterDao().getVbDataOrNull(characterId)
+ val exportFormat = transferPolicyResolver.resolveExportFormat(
+ characterId = characterId,
+ characterType = userCharacter.characterType,
+ transport = TransferTransport.HCE,
+ target = TransferTarget.VITAL_WEAR,
+ isBemCard = card.isBEm,
+ )
val normalizedTransformationCountdownMinutes = normalizeTransformationCountdownMinutes(
transformationCountdownMinutes = userCharacter.transformationCountdown,
hasPossibleTransformations = hasPossibleTransformations(userCharacter.charId),
@@ -68,7 +81,7 @@ class VitalWearCharacterExporter(
.setTotalWins(characterWithSprites.totalBattlesWon)
.setCurrentPhaseWins(characterWithSprites.currentPhaseBattlesWon)
.setMood(characterWithSprites.mood)
- .setDeviceType(userCharacter.characterType.toTransferDeviceType())
+ .setDeviceType(exportFormat.toTransferDeviceType())
.setAgeInDays(userCharacter.ageInDays.coerceAtLeast(0))
.setActivityLevel(userCharacter.activityLevel.coerceAtLeast(0))
.setHeartRateCurrent(userCharacter.heartRateCurrent.coerceAtLeast(0))
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 a04050d..fdd5593 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
@@ -9,6 +9,8 @@ import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData
import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter
import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData
import com.github.nacabaro.vbhelper.domain.device_data.VitalWearCharacterSettings
+import com.github.nacabaro.vbhelper.transfer.CharacterTransferPolicyResolver
+import com.github.nacabaro.vbhelper.transfer.ExportFormat
import com.github.nacabaro.vbhelper.utils.DeviceType
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.runBlocking
@@ -18,6 +20,8 @@ class VitalWearCharacterImporter(
private val database: AppDatabase,
private val transferSeenDao: SharedTransferSeenDao,
) {
+ private val transferPolicyResolver = CharacterTransferPolicyResolver(database.characterTransferPolicyDao())
+
data class ImportResult(
val success: Boolean,
val message: String
@@ -58,6 +62,11 @@ class VitalWearCharacterImporter(
fallbackIsBeCharacter = fallbackIsBeCharacter,
)
val isBeCharacter = deviceType == DeviceType.BEDevice
+ val observedImportFormat = resolveObservedHceImportFormat(
+ transferDeviceType = character.characterStats.deviceType,
+ importedCard = importedCard,
+ hasBePayloadStats = hasBePayloadStats,
+ )
val userCharacterId = database.userCharacterDao().insertCharacterData(
UserCharacter(
@@ -138,6 +147,17 @@ class VitalWearCharacterImporter(
)
}
+ runBlocking {
+ database.characterTransferPolicyDao().upsert(
+ transferPolicyResolver.policyForHceImport(
+ characterId = userCharacterId,
+ importedCardIsBem = importedCard.isBEm,
+ resolvedDeviceType = deviceType,
+ observedFormat = observedImportFormat,
+ )
+ )
+ }
+
val now = System.currentTimeMillis()
markSeen(importedCard.name, slotId, importedCard.id, now)
@@ -270,6 +290,23 @@ class VitalWearCharacterImporter(
return rarities.getOrElse(rawValue) { resolveDefaultAbilityRarity() }
}
+ private fun resolveObservedHceImportFormat(
+ transferDeviceType: Character.CharacterStats.TransferDeviceType,
+ importedCard: Card,
+ hasBePayloadStats: Boolean,
+ ): ExportFormat {
+ return when (transferDeviceType) {
+ Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_BE -> ExportFormat.BE
+ Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_VB -> ExportFormat.VB
+ Character.CharacterStats.TransferDeviceType.UNRECOGNIZED,
+ Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_UNSPECIFIED -> if (importedCard.isBEm || hasBePayloadStats) {
+ ExportFormat.BE
+ } else {
+ ExportFormat.VB
+ }
+ }
+ }
+
private fun markSeen(cardName: String, slotId: Int, cardId: Long, timestamp: Long) {
database.dexDao().insertCharacter(slotId, cardId, timestamp)
transferSeenDao.markSeen(cardName, slotId, timestamp)
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/transfer/CharacterTransferPolicyResolver.kt b/app/src/main/java/com/github/nacabaro/vbhelper/transfer/CharacterTransferPolicyResolver.kt
new file mode 100644
index 0000000..811885a
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/transfer/CharacterTransferPolicyResolver.kt
@@ -0,0 +1,112 @@
+package com.github.nacabaro.vbhelper.transfer
+
+import com.github.nacabaro.vbhelper.daos.CharacterTransferPolicyDao
+import com.github.nacabaro.vbhelper.domain.device_data.CharacterTransferPolicy
+import com.github.nacabaro.vbhelper.utils.DeviceType
+
+class CharacterTransferPolicyResolver(
+ private val policyDao: CharacterTransferPolicyDao,
+) {
+ fun resolveExportFormat(
+ characterId: Long,
+ characterType: DeviceType,
+ transport: TransferTransport,
+ target: TransferTarget,
+ isBemCard: Boolean,
+ ): ExportFormat {
+ val policy = policyDao.getByCharacterId(characterId)
+ return when (transport) {
+ TransferTransport.HCE -> policy?.preferredHceExportFormat
+ ?: defaultHceExportFormat(characterType, isBemCard, target)
+ TransferTransport.NFCA -> policy?.preferredNfcaExportFormat
+ ?: defaultNfcaExportFormat(characterType, isBemCard, target)
+ }
+ }
+
+ fun policyForNfcaImport(
+ characterId: Long,
+ observedFormat: ExportFormat,
+ ): CharacterTransferPolicy {
+ val nativeDeviceType = observedFormat.toNativeDeviceType()
+ return CharacterTransferPolicy(
+ characterId = characterId,
+ nativeDeviceType = nativeDeviceType,
+ preferredHceExportFormat = defaultHceExportFormat(nativeDeviceType, nativeDeviceType == DeviceType.BEDevice, TransferTarget.VITAL_WEAR),
+ preferredNfcaExportFormat = observedFormat,
+ lastObservedImportFormat = observedFormat,
+ lastTransferTransport = TransferTransport.NFCA,
+ lastTransferTarget = TransferTarget.REAL_BRACELET,
+ preserveVbRoundTrip = observedFormat == ExportFormat.VB,
+ preserveBeRoundTrip = observedFormat == ExportFormat.BE,
+ )
+ }
+
+ fun policyForHceImport(
+ characterId: Long,
+ importedCardIsBem: Boolean,
+ resolvedDeviceType: DeviceType,
+ observedFormat: ExportFormat,
+ ): CharacterTransferPolicy {
+ val nativeDeviceType = when {
+ importedCardIsBem -> DeviceType.BEDevice
+ resolvedDeviceType == DeviceType.BEDevice && observedFormat == ExportFormat.BE -> DeviceType.VBDevice
+ resolvedDeviceType == DeviceType.VitalWear -> DeviceType.VBDevice
+ else -> resolvedDeviceType
+ }
+ val preferredNfcaExportFormat = if (importedCardIsBem || nativeDeviceType == DeviceType.BEDevice) {
+ ExportFormat.BE
+ } else {
+ ExportFormat.VB
+ }
+
+ return CharacterTransferPolicy(
+ characterId = characterId,
+ nativeDeviceType = nativeDeviceType,
+ preferredHceExportFormat = defaultHceExportFormat(nativeDeviceType, importedCardIsBem, TransferTarget.VITAL_WEAR),
+ preferredNfcaExportFormat = preferredNfcaExportFormat,
+ lastObservedImportFormat = observedFormat,
+ lastTransferTransport = TransferTransport.HCE,
+ lastTransferTarget = TransferTarget.VITAL_WEAR,
+ preserveVbRoundTrip = preferredNfcaExportFormat == ExportFormat.VB,
+ preserveBeRoundTrip = importedCardIsBem || observedFormat == ExportFormat.BE,
+ )
+ }
+
+ private fun defaultHceExportFormat(
+ characterType: DeviceType,
+ isBemCard: Boolean,
+ target: TransferTarget,
+ ): ExportFormat {
+ if (target != TransferTarget.VITAL_WEAR) {
+ return defaultNfcaExportFormat(characterType, isBemCard, target)
+ }
+
+ // VitalWear is the HCE endpoint. Default to BE projection so HCE transfers can carry the
+ // richer payload, while NFCA policy still controls what is sent back to real bracelets.
+ return ExportFormat.BE
+ }
+
+ private fun defaultNfcaExportFormat(
+ characterType: DeviceType,
+ isBemCard: Boolean,
+ target: TransferTarget,
+ ): ExportFormat {
+ require(target == TransferTarget.REAL_BRACELET) {
+ "NFCA exports are only supported for real bracelets."
+ }
+ return if (isBemCard || characterType == DeviceType.BEDevice) {
+ ExportFormat.BE
+ } else {
+ ExportFormat.VB
+ }
+ }
+
+ private fun ExportFormat.toNativeDeviceType(): DeviceType {
+ return when (this) {
+ ExportFormat.BE -> DeviceType.BEDevice
+ ExportFormat.VB -> DeviceType.VBDevice
+ }
+ }
+}
+
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/transfer/TransferRoute.kt b/app/src/main/java/com/github/nacabaro/vbhelper/transfer/TransferRoute.kt
new file mode 100644
index 0000000..72c47f2
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/transfer/TransferRoute.kt
@@ -0,0 +1,31 @@
+package com.github.nacabaro.vbhelper.transfer
+
+import com.github.cfogrady.vitalwear.protos.Character
+
+/**
+ * Real bracelets talk over NFC-A while VitalWear uses HCE / ISO-DEP.
+ * Keep transport, target, and payload format separate so one canonical character can be
+ * projected differently depending on where it is being transferred.
+ */
+enum class TransferTransport {
+ NFCA,
+ HCE,
+}
+
+enum class TransferTarget {
+ REAL_BRACELET,
+ VITAL_WEAR,
+}
+
+enum class ExportFormat {
+ VB,
+ BE,
+}
+
+fun ExportFormat.toTransferDeviceType(): Character.CharacterStats.TransferDeviceType {
+ return when (this) {
+ ExportFormat.VB -> Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_VB
+ ExportFormat.BE -> Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_BE
+ }
+}
+
diff --git a/build/reports/problems/problems-report.html b/build/reports/problems/problems-report.html
index 2f7e871..d103092 100644
--- a/build/reports/problems/problems-report.html
+++ b/build/reports/problems/problems-report.html
@@ -650,7 +650,7 @@ code + .copy-button {
From 9aa62f0d81326c8ea5ab836a0a2ba4da07cdb1e7 Mon Sep 17 00:00:00 2001
From: Taveon Nelson <74945303+457R0@users.noreply.github.com>
Date: Mon, 18 May 2026 05:17:08 -0500
Subject: [PATCH 09/13] companion reup
---
app/build.gradle.kts | 18 +-
app/src/main/AndroidManifest.xml | 19 +-
.../github/nacabaro/vbhelper/MainActivity.kt | 48 +-
.../vbhelper/battle/AnimatedSpriteImage.kt | 17 +-
.../vbhelper/battle/ArenaBattleSystem.kt | 68 +--
.../vbhelper/battle/AuthInterceptor.kt | 1 +
.../vbhelper/battle/AuthenticateResponse.kt | 27 +-
.../vbhelper/battle/DigimonAnimationState.kt | 44 +-
.../vbhelper/battle/HitEffectComposables.kt | 4 +-
.../vbhelper/battle/HitEffectSpriteManager.kt | 118 +---
.../battle/IndividualSpriteManager.kt | 70 +--
.../vbhelper/battle/RetrofitHelper.kt | 147 +++--
.../vbhelper/battle/SpriteFileManager.kt | 301 +----------
.../nacabaro/vbhelper/battle/SpriteImage.kt | 6 +-
.../companion/card/ByteManipulation.kt | 46 ++
.../card/CompanionImportCardActivity.kt | 400 ++++++++++++++
.../card/CompanionValidateCardActivity.kt | 162 ++++++
.../vbhelper/companion/card/VBNfcData.kt | 48 ++
.../vbhelper/companion/common/ChannelTypes.kt | 10 +
.../CompanionFirmwareImportActivity.kt | 140 +++++
.../vbhelper/companion/logging/TinyLogTree.kt | 77 +++
.../companion/logs/CompanionLogService.kt | 122 +++++
.../logs/CompanionWatchLogsActivity.kt | 41 ++
.../logs/WatchCommunicationService.kt | 21 +
.../CompanionCharacterImportActivity.kt | 23 +
.../companion/ui/CompanionConfirmation.kt | 42 ++
.../vbhelper/companion/ui/CompanionLoading.kt | 32 ++
.../validation/CompanionValidatedDatabase.kt | 14 +
.../companion/validation/ValidatedCardDao.kt | 16 +
.../validation/ValidatedCardEntity.kt | 13 +
.../validation/ValidatedCardManager.kt | 35 ++
.../nacabaro/vbhelper/daos/AdventureDao.kt | 2 +
.../nacabaro/vbhelper/daos/CardFusionsDao.kt | 3 +-
.../nacabaro/vbhelper/daos/CharacterDao.kt | 6 +
.../nacabaro/vbhelper/daos/SpriteDao.kt | 3 +
.../vbhelper/daos/UserCharacterDao.kt | 11 +
.../nacabaro/vbhelper/database/AppDatabase.kt | 35 +-
.../nacabaro/vbhelper/di/AppContainer.kt | 2 +
.../vbhelper/di/DefaultAppContainer.kt | 4 +
.../github/nacabaro/vbhelper/di/VBHelper.kt | 25 +
.../vbhelper/domain/card/Background.kt | 2 +
.../vbhelper/domain/card/CardAdventure.kt | 5 +
.../vbhelper/domain/card/CardCharacter.kt | 3 +-
.../vbhelper/domain/card/CardFusions.kt | 5 +
.../domain/card/PossibleTransformations.kt | 5 +
.../domain/device_data/SpecialMissions.kt | 2 +
.../device_data/TransformationHistory.kt | 5 +
.../domain/device_data/UnifiedCharacter.kt | 31 ++
.../domain/device_data/UserCharacter.kt | 2 +
.../domain/device_data/VitalsHistory.kt | 2 +
.../vbhelper/navigation/AppNavigation.kt | 24 +-
.../navigation/BottomNavigationBar.kt | 41 +-
.../navigation/NavigationExtensions.kt | 0
.../vbhelper/navigation/NavigationItems.kt | 4 +-
.../vbhelper/screens/BattlesScreen.kt | 510 +++++++++++-------
.../screens/homeScreens/HomeScreen.kt | 38 +-
.../homeScreens/screens/UnifiedHomeScreen.kt | 241 +++++++++
.../screens/itemsScreen/ItemElement.kt | 56 ++
.../screens/itemsScreen/ItemsScreen.kt | 4 +-
.../itemsScreen/ItemsScreenControllerImpl.kt | 21 +-
.../scanScreen/ChooseConnectionScreen.kt | 2 +-
.../vbhelper/screens/scanScreen/ScanScreen.kt | 7 +-
.../scanScreen/ScanScreenController.kt | 7 +-
.../scanScreen/ScanScreenControllerImpl.kt | 337 ++++++------
.../screens/scanScreen/TransferHaptics.kt | 51 ++
.../scanScreen/converters/FromNfcConverter.kt | 57 +-
.../scanScreen/converters/ToNfcConverter.kt | 54 +-
.../scanScreen/screens/ActionScreen.kt | 82 +--
.../scanScreen/screens/ReadingScreen.kt | 27 +-
.../screens/TransferAnimationScreen.kt | 267 +++++++++
.../scanScreen/screens/WritingScreen.kt | 63 ++-
.../screens/settingsScreen/SettingsScreen.kt | 72 +++
.../SettingsScreenController.kt | 4 +
.../SettingsScreenControllerImpl.kt | 40 +-
.../controllers/CardImportController.kt | 53 +-
.../DatabaseManagementController.kt | 141 +++++
.../vbhelper/source/AuthRepository.kt | 6 +
.../vbhelper/source/CardSettingsRepository.kt | 28 +
.../vbhelper/source/DeviceTypeConverter.kt | 54 ++
.../source/VitalWearCharacterExporter.kt | 91 +++-
.../source/VitalWearCharacterImporter.kt | 367 +++++++++++--
.../transfer/hce/VitalWearHceReaderClient.kt | 53 +-
.../nacabaro/vbhelper/utils/BitmapData.kt | 4 +-
.../cfogrady/vitalwear/protos/Character.proto | 39 ++
app/src/main/res/values-pt-rBR/strings.xml | 2 +-
app/src/main/res/values/strings.xml | 35 +-
app/src/main/res/xml/provider_paths.xml | 3 +
.../ToNfcConverterProfileSelectionTest.kt | 25 +
gradle/libs.versions.toml | 13 +
settings.gradle.kts | 6 +-
90 files changed, 3889 insertions(+), 1323 deletions(-)
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/companion/card/ByteManipulation.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/companion/card/CompanionImportCardActivity.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/companion/card/CompanionValidateCardActivity.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/companion/card/VBNfcData.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/companion/common/ChannelTypes.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/companion/firmware/CompanionFirmwareImportActivity.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/companion/logging/TinyLogTree.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/companion/logs/CompanionLogService.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/companion/logs/CompanionWatchLogsActivity.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/companion/logs/WatchCommunicationService.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/companion/transfer/CompanionCharacterImportActivity.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/companion/ui/CompanionConfirmation.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/companion/ui/CompanionLoading.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/companion/validation/CompanionValidatedDatabase.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/companion/validation/ValidatedCardDao.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/companion/validation/ValidatedCardEntity.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/companion/validation/ValidatedCardManager.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/UnifiedCharacter.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/navigation/NavigationExtensions.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/screens/homeScreens/screens/UnifiedHomeScreen.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/TransferHaptics.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/screens/TransferAnimationScreen.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/source/CardSettingsRepository.kt
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/source/DeviceTypeConverter.kt
create mode 100644 app/src/test/java/com/github/nacabaro/vbhelper/screens/scanScreen/converters/ToNfcConverterProfileSelectionTest.kt
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 99c898f..a2f1042 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -73,8 +73,12 @@ protobuf {
dependencies {
implementation(libs.androidx.room.runtime)
implementation(libs.vb.nfc.reader)
- implementation(libs.dim.reader)
- implementation(libs.androidx.core.ktx)
+ // Temporarily commented out due to Lombok compilation issues in VB-DIM-Reader
+ // implementation(libs.dim.reader)
+ implementation(files("../../VB-DIM-Reader-2.0.0/build/libs/VB-DIM-Reader.jar"))
+ //VB-DIM-Reader runtime dependency required by the local JAR
+ implementation("at.favre.lib:bytes:1.5.0")
+
implementation(libs.androidx.room.ktx)
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.navigation.compose)
@@ -90,6 +94,13 @@ dependencies {
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.androidx.compose.material.icons.extended)
+ implementation(libs.play.services.wearable)
+ implementation(libs.kotlinx.coroutines.play.services)
+ implementation(libs.timber)
+ implementation(libs.tiny.log)
+ implementation(libs.tiny.log.impl)
+ implementation(libs.slf4j.api)
+ runtimeOnly(libs.slf4j.timber)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
@@ -118,4 +129,7 @@ dependencies {
// HTTP request logging
implementation("com.squareup.okhttp3:logging-interceptor:4.11.0")
+
+ // In-app browser for OAuth login
+ implementation("androidx.browser:browser:1.8.0")
}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index b2c32af..538abe9 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -11,6 +11,7 @@
+
@@ -50,11 +51,17 @@
android:exported="true"
android:readPermission="com.github.nacabaro.vbhelper.permission.ACCESS_TRANSFER_SEEN"
android:writePermission="com.github.nacabaro.vbhelper.permission.ACCESS_TRANSFER_SEEN" />
+
+
+
+
+
@@ -75,6 +82,16 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/MainActivity.kt b/app/src/main/java/com/github/nacabaro/vbhelper/MainActivity.kt
index 3be27d1..a9676e3 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/MainActivity.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/MainActivity.kt
@@ -7,9 +7,13 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
import com.github.nacabaro.vbhelper.navigation.AppNavigation
import com.github.nacabaro.vbhelper.di.VBHelper
import com.github.nacabaro.vbhelper.navigation.AppNavigationHandlers
+import com.github.nacabaro.vbhelper.navigation.NavigationItems
import com.github.nacabaro.vbhelper.screens.homeScreens.HomeScreenControllerImpl
import com.github.nacabaro.vbhelper.screens.itemsScreen.ItemsScreenControllerImpl
import com.github.nacabaro.vbhelper.screens.scanScreen.ScanScreenControllerImpl
@@ -19,14 +23,14 @@ import com.github.nacabaro.vbhelper.screens.cardScreen.CardScreenControllerImpl
import com.github.nacabaro.vbhelper.screens.spriteViewer.SpriteViewerControllerImpl
import com.github.nacabaro.vbhelper.screens.storageScreen.StorageScreenControllerImpl
import com.github.nacabaro.vbhelper.ui.theme.VBHelperTheme
-import kotlinx.coroutines.flow.MutableSharedFlow
-import kotlinx.coroutines.flow.asSharedFlow
+import androidx.lifecycle.lifecycleScope
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.Dispatchers
class MainActivity : ComponentActivity() {
private val onActivityLifecycleListeners = HashMap()
- private var initialRoute: String? = null
- private val deepLinkNavigationEvents = MutableSharedFlow(extraBufferCapacity = 1)
+ private var initialRoute: String? by mutableStateOf(null)
private fun registerActivityLifecycleListener(key: String, activityLifecycleListener: ActivityLifecycleListener) {
if( onActivityLifecycleListeners[key] != null) {
@@ -60,7 +64,6 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge()
initialRoute = getInitialRouteFromIntent(intent)
- initialRoute?.let { deepLinkNavigationEvents.tryEmit(it) }
setContent {
VBHelperTheme {
@@ -73,8 +76,7 @@ class MainActivity : ComponentActivity() {
storageScreenController = storageScreenController,
spriteViewerController = spriteViewerController,
cardScreenController = cardScreenController,
- initialRoute = initialRoute,
- deepLinkNavigationEvents = deepLinkNavigationEvents.asSharedFlow(),
+ initialRoute = initialRoute
)
}
}
@@ -102,15 +104,35 @@ class MainActivity : ComponentActivity() {
super.onNewIntent(intent)
setIntent(intent)
initialRoute = getInitialRouteFromIntent(intent)
- initialRoute?.let { deepLinkNavigationEvents.tryEmit(it) }
}
private fun getInitialRouteFromIntent(intent: Intent?): String? {
if (intent == null) return null
val data = intent.data
if (intent.action == Intent.ACTION_VIEW && data != null) {
- if (data.scheme == "vbhelper" && data.host == "auth") {
- return "Battle"
+ val isAppAuthCallback = data.scheme == "vbhelper" && data.host == "auth"
+ val isLocalhostAuthCallback =
+ (data.scheme == "http" || data.scheme == "https") &&
+ (data.host == "localhost" || data.host == "127.0.0.1") &&
+ data.path?.startsWith("/authenticate") == true
+ val token = data.getQueryParameter("c") ?: data.getQueryParameter("token")
+ val hasAuthToken = !token.isNullOrEmpty()
+
+ if (isAppAuthCallback || isLocalhostAuthCallback || hasAuthToken) {
+ if (!token.isNullOrEmpty()) {
+ val application = applicationContext as VBHelper
+ val authRepository = com.github.nacabaro.vbhelper.battle.BattleAuthContainer(this).authRepository
+ val userId = data.getQueryParameter("userId")?.toLongOrNull()
+
+ lifecycleScope.launch(Dispatchers.IO) {
+ authRepository.setAuthenticated(
+ isAuthenticated = true,
+ nacatechToken = token,
+ userId = userId
+ )
+ }
+ }
+ return NavigationItems.Battles.route
}
}
return null
@@ -126,8 +148,7 @@ class MainActivity : ComponentActivity() {
homeScreenController: HomeScreenControllerImpl,
spriteViewerController: SpriteViewerControllerImpl,
cardScreenController: CardScreenControllerImpl,
- initialRoute: String? = null,
- deepLinkNavigationEvents: kotlinx.coroutines.flow.Flow,
+ initialRoute: String? = null
) {
AppNavigation(
applicationNavigationHandlers = AppNavigationHandlers(
@@ -140,8 +161,7 @@ class MainActivity : ComponentActivity() {
spriteViewerController,
cardScreenController
),
- initialRoute = initialRoute,
- navigationEvents = deepLinkNavigationEvents,
+ initialRoute = initialRoute
)
}
}
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/battle/AnimatedSpriteImage.kt b/app/src/main/java/com/github/nacabaro/vbhelper/battle/AnimatedSpriteImage.kt
index f75b935..7d6f6cc 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/battle/AnimatedSpriteImage.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/battle/AnimatedSpriteImage.kt
@@ -5,9 +5,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
-import androidx.compose.ui.platform.LocalContext
import kotlinx.coroutines.launch
-import kotlinx.coroutines.delay
@Composable
fun AnimatedSpriteImage(
@@ -15,12 +13,10 @@ fun AnimatedSpriteImage(
animationType: DigimonAnimationType = DigimonAnimationType.IDLE,
modifier: Modifier = Modifier,
contentScale: ContentScale = ContentScale.Fit,
- reloadMappings: Boolean = false,
animationOffset: Long = 0L // New parameter for offsetting animation timing
) {
- val context = LocalContext.current
- val spriteManager = remember { IndividualSpriteManager(context) }
-
+ val spriteManager = remember { IndividualSpriteManager() }
+
// Calculate frame offset based on animation offset
// 750ms is the idle animation duration, so we calculate how many frames to offset
val frameOffset = if (animationOffset > 0L) {
@@ -30,18 +26,11 @@ fun AnimatedSpriteImage(
0
}
- val animationStateMachine = remember { DigimonAnimationStateMachine(characterId, context, frameOffset, animationOffset) }
+ val animationStateMachine = remember { DigimonAnimationStateMachine(characterId, frameOffset, animationOffset) }
val coroutineScope = rememberCoroutineScope()
var bitmap by remember { mutableStateOf(null) }
- // Reload mappings when reloadMappings parameter changes
- LaunchedEffect(reloadMappings) {
- if (reloadMappings) {
- animationStateMachine.reloadMappings()
- }
- }
-
// Start the animation when the component is first created
LaunchedEffect(characterId) {
coroutineScope.launch {
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/battle/ArenaBattleSystem.kt b/app/src/main/java/com/github/nacabaro/vbhelper/battle/ArenaBattleSystem.kt
index cac56be..5a6beaf 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/battle/ArenaBattleSystem.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/battle/ArenaBattleSystem.kt
@@ -1,18 +1,11 @@
package com.github.nacabaro.vbhelper.battle
-import android.util.Log
-import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
-import androidx.compose.runtime.State
class ArenaBattleSystem {
- companion object {
- private const val TAG = "ArenaBattleSystem"
- }
-
- // Attack phases: 0=Idle, 1=Player attack on player screen, 2=Player attack on opponent screen,
+ // Attack phases: 0=Idle, 1=Player attack on player screen, 2=Player attack on opponent screen,
// 3=Opponent attack on opponent screen, 4=Opponent attack on player screen
private var _attackPhase by mutableStateOf(0)
val attackPhase: Int get() = _attackPhase
@@ -44,19 +37,6 @@ class ArenaBattleSystem {
private var _critBarProgress by mutableStateOf(0)
val critBarProgress: Int get() = _critBarProgress
- // Dodge animation states
- private var _isDodging by mutableStateOf(false)
- val isDodging: Boolean get() = _isDodging
-
- private var _dodgeProgress by mutableStateOf(0f)
- val dodgeProgress: Float get() = _dodgeProgress
-
- private var _dodgeDirection by mutableStateOf(1f) // 1f = up, -1f = down
- val dodgeDirection: Float get() = _dodgeDirection
-
- private var _isHit by mutableStateOf(false)
- val isHit: Boolean get() = _isHit
-
private var _hitProgress by mutableStateOf(0f)
val hitProgress: Float get() = _hitProgress
@@ -180,10 +160,6 @@ class ArenaBattleSystem {
_isPlayerAttacking = false
_attackIsHit = false
_currentView = 0
- _isDodging = false
- _dodgeProgress = 0f
- _dodgeDirection = 1f
- _isHit = false
_hitProgress = 0f
_isPlayerDodging = false
_isOpponentDodging = false
@@ -215,41 +191,10 @@ class ArenaBattleSystem {
//Log.d(TAG, "Updated crit bar progress: $progress")
}
- // Dodge animation methods
- fun startDodge() {
- _isDodging = true
- _dodgeProgress = 0f
- _dodgeDirection = 1f // Start moving up
- }
-
- fun setDodgeProgress(progress: Float) {
- _dodgeProgress = progress
- }
-
- fun setDodgeDirection(direction: Float) {
- _dodgeDirection = direction
- }
-
- fun endDodge() {
- _isDodging = false
- _dodgeProgress = 0f
- }
-
- // Hit animation methods
- fun startHit() {
- _isHit = true
- _hitProgress = 0f
- }
-
fun setHitProgress(progress: Float) {
_hitProgress = progress
}
- fun endHit() {
- _isHit = false
- _hitProgress = 0f
- }
-
// Player-specific dodge methods
fun startPlayerDodge() {
_isPlayerDodging = true
@@ -345,17 +290,6 @@ class ArenaBattleSystem {
_isOpponentShakeDelayed = false
}
- // Combined method to handle attack result
- fun handleAttackResult(isHit: Boolean) {
- _attackIsHit = isHit
- if (isHit) {
- // Player attack hit - opponent gets hit
- startOpponentHit()
- } else {
- // Player attack missed - opponent dodges
- startOpponentDodge()
- }
- }
// Method to handle opponent attack result
fun handleOpponentAttackResult(isHit: Boolean) {
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/battle/AuthInterceptor.kt b/app/src/main/java/com/github/nacabaro/vbhelper/battle/AuthInterceptor.kt
index df03762..6488586 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/battle/AuthInterceptor.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/battle/AuthInterceptor.kt
@@ -20,6 +20,7 @@ class AuthInterceptor(private val token: String) : Interceptor {
// Use X-Session-Token header (preferred) or Authorization: Bearer
val authenticatedRequest = originalRequest.newBuilder()
.header("X-Session-Token", token)
+ .header("Authorization", "Bearer $token")
.build()
// Debug: Log which header is being used (first few chars of token for security)
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/battle/AuthenticateResponse.kt b/app/src/main/java/com/github/nacabaro/vbhelper/battle/AuthenticateResponse.kt
index cb1f4fd..5b1f1b0 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/battle/AuthenticateResponse.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/battle/AuthenticateResponse.kt
@@ -1,21 +1,46 @@
package com.github.nacabaro.vbhelper.battle
+import com.google.gson.annotations.SerializedName
+
data class AdditionalInfo(
+ @SerializedName(value = "avatar", alternate = ["avatar_url"])
val avatar: String? = null,
+ @SerializedName(value = "id", alternate = ["user_id"])
val id: Long? = null,
+ @SerializedName(value = "name", alternate = ["username", "display_name"])
val name: String? = null,
val status: String? = null
)
data class UserInfo(
+ @SerializedName(value = "userId", alternate = ["user_id", "id"])
val userId: String? = null,
+ @SerializedName(value = "additionalInfo", alternate = ["additional_info"])
val additionalInfo: AdditionalInfo? = null
)
data class AuthenticateResponse(
+ @SerializedName(value = "success", alternate = ["ok"])
val success: Boolean,
val message: String? = null,
+ @SerializedName(value = "userInfo", alternate = ["user_info", "user"])
val userInfo: UserInfo? = null,
- val sessionToken: String? = null
+ @SerializedName(value = "sessionToken", alternate = ["session_token", "token", "session"])
+ val sessionToken: String? = null,
+ @SerializedName(value = "userId", alternate = ["user_id"])
+ val topLevelUserId: String? = null,
+ @SerializedName(value = "id", alternate = ["uid"])
+ val topLevelId: Long? = null,
)
+fun AuthenticateResponse.extractSessionToken(): String? {
+ return sessionToken?.takeIf { it.isNotBlank() }
+}
+
+fun AuthenticateResponse.extractUserId(): Long? {
+ return userInfo?.userId?.toLongOrNull()
+ ?: topLevelUserId?.toLongOrNull()
+ ?: userInfo?.additionalInfo?.id
+ ?: topLevelId
+}
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/battle/DigimonAnimationState.kt b/app/src/main/java/com/github/nacabaro/vbhelper/battle/DigimonAnimationState.kt
index c2ee41e..40c4b00 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/battle/DigimonAnimationState.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/battle/DigimonAnimationState.kt
@@ -4,22 +4,13 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kotlinx.coroutines.delay
-import android.content.Context
-import java.io.File
enum class DigimonAnimationType {
IDLE,
IDLE2,
WALK,
- WALK2,
- RUN,
- RUN2,
- WORKOUT,
- WORKOUT2,
- HAPPY,
SLEEP,
- ATTACK,
- FLEE
+ ATTACK
}
data class AnimationState(
@@ -31,7 +22,6 @@ data class AnimationState(
class DigimonAnimationStateMachine(
private val characterId: String,
- private val context: Context,
private val initialFrameOffset: Int = 0, // New parameter for offsetting the starting frame
private val timingOffset: Long = 0L // New parameter for offsetting the timing
) {
@@ -44,21 +34,13 @@ class DigimonAnimationStateMachine(
var isPlaying by mutableStateOf(false)
private set
- // Direct mapping of frame numbers (1-12) to animation types
- // This is based on the standard Digimon sprite frame order
+ // Direct mapping of frame numbers used by current battle animations.
private val frameToAnimationType = mapOf(
1 to DigimonAnimationType.IDLE,
2 to DigimonAnimationType.IDLE2,
3 to DigimonAnimationType.WALK,
- 4 to DigimonAnimationType.WALK2,
- 5 to DigimonAnimationType.RUN,
- 6 to DigimonAnimationType.RUN2,
- 7 to DigimonAnimationType.WORKOUT,
- 8 to DigimonAnimationType.WORKOUT2,
- 9 to DigimonAnimationType.HAPPY,
10 to DigimonAnimationType.SLEEP,
- 11 to DigimonAnimationType.ATTACK,
- 12 to DigimonAnimationType.FLEE
+ 11 to DigimonAnimationType.ATTACK
)
// Reverse mapping for getting frame numbers for each animation type
@@ -69,15 +51,8 @@ class DigimonAnimationStateMachine(
DigimonAnimationType.IDLE to 750L,
DigimonAnimationType.IDLE2 to 750L,
DigimonAnimationType.WALK to 200L,
- DigimonAnimationType.WALK2 to 200L,
- DigimonAnimationType.RUN to 150L,
- DigimonAnimationType.RUN2 to 150L,
- DigimonAnimationType.WORKOUT to 300L,
- DigimonAnimationType.WORKOUT2 to 300L,
- DigimonAnimationType.HAPPY to 400L,
DigimonAnimationType.SLEEP to 1500L,
- DigimonAnimationType.ATTACK to 650L,
- DigimonAnimationType.FLEE to 150L
+ DigimonAnimationType.ATTACK to 650L
)
/*
@@ -155,13 +130,4 @@ class DigimonAnimationStateMachine(
return currentFrameNumber
}
- fun getCurrentCharacterId(): String {
- return characterId
- }
-
- // Method to reload mappings (useful for testing)
- fun reloadMappings() {
- println("Reloading mappings for character: $characterId")
- // No need to reload since we use direct frame mapping
- }
-}
\ No newline at end of file
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/battle/HitEffectComposables.kt b/app/src/main/java/com/github/nacabaro/vbhelper/battle/HitEffectComposables.kt
index e4a3383..800a0f3 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/battle/HitEffectComposables.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/battle/HitEffectComposables.kt
@@ -10,7 +10,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
-import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
@@ -25,10 +24,9 @@ fun HitEffectOverlay(
) {
if (!isVisible) return
- val context = LocalContext.current
val configuration = LocalConfiguration.current
val isLandscapeMode = configuration.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE
- val hitEffectManager = remember { HitEffectSpriteManager(context) }
+ val hitEffectManager = remember { HitEffectSpriteManager() }
val coroutineScope = rememberCoroutineScope()
var currentFrame by remember { mutableStateOf(0) }
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/battle/HitEffectSpriteManager.kt b/app/src/main/java/com/github/nacabaro/vbhelper/battle/HitEffectSpriteManager.kt
index fa116be..e6fae95 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/battle/HitEffectSpriteManager.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/battle/HitEffectSpriteManager.kt
@@ -1,13 +1,10 @@
package com.github.nacabaro.vbhelper.battle
-import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
-import android.graphics.Rect
-import android.os.Environment
import java.io.File
-class HitEffectSpriteManager(private val context: Context) {
+class HitEffectSpriteManager {
private val spriteCache = mutableMapOf()
// Get the external storage directory for hit effect sprites
@@ -56,117 +53,4 @@ class HitEffectSpriteManager(private val context: Context) {
}
}
- /**
- * Load a damage effect sprite from spritesheet
- * @param spritesheetName The spritesheet name (e.g., "dmg_ef1", "dmg_ef2")
- * @param frameIndex The frame index (0-3 for dmg_ef1 and dmg_ef2, 0 for dmg_ef3)
- * @return Bitmap of the damage effect frame, or null if not found
- */
- fun loadDamageEffectSprite(spritesheetName: String, frameIndex: Int = 0): Bitmap? {
- val cacheKey = "dmg_${spritesheetName}_frame_${frameIndex}"
-
- // Check cache first
- if (spriteCache.containsKey(cacheKey)) {
- return spriteCache[cacheKey]
- }
-
- try {
- val spritesheetFile = File(getHitSpritesDir(), "$spritesheetName.png")
-
- if (!spritesheetFile.exists()) {
- println("Damage effect spritesheet not found: ${spritesheetFile.absolutePath}")
- return null
- }
-
- val spritesheet = BitmapFactory.decodeFile(spritesheetFile.absolutePath)
- if (spritesheet == null) {
- println("Failed to decode damage effect spritesheet: ${spritesheetFile.absolutePath}")
- return null
- }
-
- // Extract frame from spritesheet
- val frameBitmap = when (spritesheetName) {
- "dmg_ef1", "dmg_ef2" -> {
- // These are 2x2 spritesheets (4 frames)
- val frameWidth = spritesheet.width / 2
- val frameHeight = spritesheet.height / 2
- val row = frameIndex / 2
- val col = frameIndex % 2
- val x = col * frameWidth
- val y = row * frameHeight
- Bitmap.createBitmap(spritesheet, x, y, frameWidth, frameHeight)
- }
- "dmg_ef3" -> {
- // This is a single sprite
- spritesheet
- }
- else -> {
- println("Unknown spritesheet name: $spritesheetName")
- return null
- }
- }
-
- println("Successfully loaded damage effect frame: $spritesheetName frame $frameIndex (${frameBitmap.width}x${frameBitmap.height})")
-
- // Cache the result
- spriteCache[cacheKey] = frameBitmap
-
- return frameBitmap
-
- } catch (e: Exception) {
- println("Error loading damage effect sprite: ${e.message}")
- e.printStackTrace()
- return null
- }
- }
-
- /**
- * Get all available hit sprites
- * @return List of hit sprite names (without .png extension)
- */
- fun getAvailableHitSprites(): List {
- val hitSpritesDir = getHitSpritesDir()
-
- if (!hitSpritesDir.exists()) {
- return emptyList()
- }
-
- return hitSpritesDir.listFiles { file ->
- file.name.startsWith("hit_") && file.name.endsWith(".png")
- }?.map { file ->
- file.name.substringBefore(".png")
- }?.sorted() ?: emptyList()
- }
-
- /**
- * Get available damage effect spritesheet names
- * @return List of available damage effect spritesheet names
- */
- fun getAvailableDamageEffectSpritesheets(): List {
- try {
- if (!getHitSpritesDir().exists()) {
- return emptyList()
- }
-
- val dmgFiles = getHitSpritesDir().listFiles { file ->
- file.name.startsWith("dmg_ef") && file.name.endsWith(".png")
- } ?: emptyArray()
-
- return dmgFiles.map { file ->
- file.name.substringBefore(".png")
- }.sorted()
-
- } catch (e: Exception) {
- println("Error getting available damage effect spritesheets: ${e.message}")
- e.printStackTrace()
- return emptyList()
- }
- }
-
- /**
- * Clear the sprite cache
- */
- fun clearCache() {
- spriteCache.clear()
- }
}
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/battle/IndividualSpriteManager.kt b/app/src/main/java/com/github/nacabaro/vbhelper/battle/IndividualSpriteManager.kt
index 84c68a7..b6bb2bc 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/battle/IndividualSpriteManager.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/battle/IndividualSpriteManager.kt
@@ -1,12 +1,10 @@
package com.github.nacabaro.vbhelper.battle
-import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
-import android.os.Environment
import java.io.File
-class IndividualSpriteManager(private val context: Context) {
+class IndividualSpriteManager {
private val spriteCache = mutableMapOf()
// Get the external storage directory for sprite files
@@ -65,68 +63,4 @@ class IndividualSpriteManager(private val context: Context) {
}
}
- /**
- * Get all available sprite frames for a character
- * @param characterId The character ID
- * @return List of frame numbers (1-12) that exist for this character
- */
- fun getAvailableFrames(characterId: String): List {
- val spriteBaseDir = getSpriteBaseDir()
- val characterDir = File(spriteBaseDir, characterId)
-
- if (!characterDir.exists()) {
- return emptyList()
- }
-
- val spriteFiles = characterDir.listFiles { file ->
- file.name.startsWith("${characterId}_") && file.name.endsWith(".png")
- } ?: emptyArray()
-
- return spriteFiles.mapNotNull { file ->
- val fileName = file.name
- val frameMatch = Regex("${characterId}_(\\d{2})\\.png").find(fileName)
- frameMatch?.groupValues?.get(1)?.toIntOrNull()
- }.sorted()
- }
-
- /**
- * Get all available character IDs
- * @return List of character IDs that have sprite directories
- */
- fun getAvailableCharacters(): List {
- val spriteBaseDir = getSpriteBaseDir()
-
- if (!spriteBaseDir.exists()) {
- return emptyList()
- }
-
- return spriteBaseDir.listFiles { file ->
- file.isDirectory && file.listFiles()?.any { it.name.endsWith(".png") } == true
- }?.map { it.name }?.sorted() ?: emptyList()
- }
-
- /**
- * Clear the sprite cache
- */
- fun clearCache() {
- spriteCache.clear()
- }
-
- /**
- * Check if a character has sprite files
- * @param characterId The character ID to check
- * @return true if the character has sprite files, false otherwise
- */
- fun hasCharacterSprites(characterId: String): Boolean {
- val characterDir = File(getSpriteBaseDir(), characterId)
- if (!characterDir.exists()) {
- return false
- }
-
- val spriteFiles = characterDir.listFiles { file ->
- file.name.startsWith("${characterId}_") && file.name.endsWith(".png")
- } ?: emptyArray()
-
- return spriteFiles.isNotEmpty()
- }
-}
\ No newline at end of file
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/battle/RetrofitHelper.kt b/app/src/main/java/com/github/nacabaro/vbhelper/battle/RetrofitHelper.kt
index b465ddc..fc0ce24 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/battle/RetrofitHelper.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/battle/RetrofitHelper.kt
@@ -12,10 +12,14 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withTimeoutOrNull
import com.github.nacabaro.vbhelper.battle.BattleAuthContainer
+import java.net.SocketTimeoutException
+import java.io.InterruptedIOException
+import java.util.concurrent.TimeUnit
class RetrofitHelper {
-
+
/**
* Creates an OkHttpClient with authentication interceptor for game endpoints.
* Requires a non-null, non-empty token.
@@ -28,9 +32,22 @@ class RetrofitHelper {
return OkHttpClient.Builder()
.addInterceptor(AuthInterceptor(token))
.addInterceptor(loggingInterceptor)
+ .connectTimeout(30, TimeUnit.SECONDS)
+ .readTimeout(30, TimeUnit.SECONDS)
+ .writeTimeout(30, TimeUnit.SECONDS)
+ .callTimeout(45, TimeUnit.SECONDS)
+ .retryOnConnectionFailure(true)
.build()
}
-
+
+ private fun classifyNetworkError(t: Throwable): String {
+ return when (t) {
+ is SocketTimeoutException -> "Authentication server timed out"
+ is InterruptedIOException -> "Request timed out"
+ else -> t.message ?: "Network error"
+ }
+ }
+
/**
* Gets the session token from AuthRepository for API calls.
* Falls back to nacatech token if session token is not available (backward compatibility).
@@ -38,7 +55,8 @@ class RetrofitHelper {
private fun getAuthToken(context: Context): String? {
return try {
val authContainer = BattleAuthContainer(context)
- runBlocking {
+ runBlocking(Dispatchers.IO) {
+ withTimeoutOrNull(5000L) {
// Prefer session token, fall back to nacatech token for backward compatibility
val sessionToken = authContainer.authRepository.sessionToken.first()
if (!sessionToken.isNullOrEmpty()) {
@@ -52,6 +70,10 @@ class RetrofitHelper {
}
nacatechToken
}
+ } ?: run {
+ println("RetrofitHelper: Timed out while reading auth token from DataStore")
+ null
+ }
}
} catch (e: Exception) {
println("RetrofitHelper: Error getting auth token: ${e.message}")
@@ -76,7 +98,30 @@ class RetrofitHelper {
.addConverterFactory(GsonConverterFactory.create())
.build()
}
-
+ /**
+ * Creates a Retrofit instance without authentication for public endpoints.
+ */
+ private fun createPublicRetrofit(): Retrofit {
+ val loggingInterceptor = HttpLoggingInterceptor().apply {
+ level = HttpLoggingInterceptor.Level.BODY
+ }
+
+ val client = OkHttpClient.Builder()
+ .addInterceptor(loggingInterceptor)
+ .connectTimeout(30, TimeUnit.SECONDS)
+ .readTimeout(30, TimeUnit.SECONDS)
+ .writeTimeout(30, TimeUnit.SECONDS)
+ .callTimeout(45, TimeUnit.SECONDS)
+ .retryOnConnectionFailure(true)
+ .build()
+
+ return Retrofit.Builder()
+ .baseUrl("http://battle.io-void.com:8080/")
+ .client(client)
+ .addConverterFactory(GsonConverterFactory.create())
+ .build()
+ }
+
/**
* Handles HTTP error responses (401, 403, 429).
* For 401/403, clears authentication state to trigger re-authentication.
@@ -121,16 +166,15 @@ class RetrofitHelper {
}
}
- fun getOpponents(context: Context, stage: String, callback: (OpponentsDataModel) -> Unit) {
+ fun getOpponents(context: Context, stage: String, onComplete: (() -> Unit)? = null, callback: (OpponentsDataModel) -> Unit) {
//println("RetrofitHelper: Starting API call for stage: $stage")
try {
- // Create an authenticated Retrofit instance
- val retrofit = createAuthenticatedRetrofit(context)
- if (retrofit == null) {
- println("RetrofitHelper: Cannot create authenticated Retrofit - no token available")
- Toast.makeText(context, "Authentication required. Please log in.", Toast.LENGTH_SHORT).show()
- return
+ // Opponents endpoint requires auth on the current backend.
+ // Prefer authenticated Retrofit; fall back to public client only if no token is available.
+ val retrofit = createAuthenticatedRetrofit(context) ?: run {
+ println("RetrofitHelper: No auth token for opponents request, falling back to public Retrofit")
+ createPublicRetrofit()
}
// Create an ApiService instance from the Retrofit instance.
@@ -146,9 +190,11 @@ class RetrofitHelper {
// make an asynchronous API request.
call.enqueue(object : Callback {
override fun onFailure(call: Call, t: Throwable) {
- println("RetrofitHelper: API call failed: ${t.message}")
+ val classified = classifyNetworkError(t)
+ println("RetrofitHelper: API call failed (${t::class.java.simpleName}): $classified")
t.printStackTrace()
- Toast.makeText(context, "Request Fail", Toast.LENGTH_SHORT).show()
+ Toast.makeText(context, classified, Toast.LENGTH_SHORT).show()
+ onComplete?.invoke()
}
override fun onResponse(call: Call, response: Response) {
@@ -156,13 +202,19 @@ class RetrofitHelper {
println("RetrofitHelper: Response body: ${response.body()}")
if(response.isSuccessful){
- //println("RetrofitHelper: Response successful, calling callback")
- val opponentsList: OpponentsDataModel = response.body() as OpponentsDataModel
- callback(opponentsList)
+ val opponentsList = response.body()
+ if (opponentsList != null) {
+ callback(opponentsList)
+ } else {
+ println("RetrofitHelper: Successful opponents response had empty body")
+ Toast.makeText(context, "Empty response from server", Toast.LENGTH_SHORT).show()
+ }
+ onComplete?.invoke()
} else {
val errorBody = response.errorBody()?.string()
- println("RetrofitHelper: Response not successful - Error: $errorBody")
- handleErrorResponse(context, response, errorBody ?: "Unknown error")
+ println("RetrofitHelper: Opponents response not successful - Code: ${response.code()}, Error: $errorBody")
+ handleErrorResponse(context, response, errorBody ?: "Unable to load opponents right now.")
+ onComplete?.invoke()
}
}
})
@@ -170,6 +222,7 @@ class RetrofitHelper {
println("RetrofitHelper: Exception in getOpponents: ${e.message}")
e.printStackTrace()
Toast.makeText(context, "Request failed: ${e.message}", Toast.LENGTH_SHORT).show()
+ onComplete?.invoke()
}
}
@@ -284,9 +337,10 @@ class RetrofitHelper {
override fun onFailure(call: Call, t: Throwable) {
// This method is called when the API request fails.
- println("RetrofitHelper: PVP API call failed: ${t.message}")
+ val classified = classifyNetworkError(t)
+ println("RetrofitHelper: PVP API call failed (${t::class.java.simpleName}): $classified")
t.printStackTrace()
- Toast.makeText(context, "Request Fail", Toast.LENGTH_SHORT).show()
+ Toast.makeText(context, classified, Toast.LENGTH_SHORT).show()
}
override fun onResponse(call: Call, response: Response) {
@@ -294,13 +348,13 @@ class RetrofitHelper {
println("RetrofitHelper: PVP API response received - Code: ${response.code()}")
if(response.isSuccessful){
- // If the response is successful, parse the
- // response body to a DataModel object.
- val apiResults: PVPDataModel = response.body() as PVPDataModel
-
- // Call the callback function with the DataModel
- // object as a parameter.
- callback(apiResults)
+ val apiResults = response.body()
+ if (apiResults != null) {
+ callback(apiResults)
+ } else {
+ println("RetrofitHelper: Successful PVP response had empty body")
+ Toast.makeText(context, "Empty response from server", Toast.LENGTH_SHORT).show()
+ }
} else {
val errorBody = response.errorBody()?.string()
println("RetrofitHelper: PVP API response not successful - Code: ${response.code()}, Error: $errorBody")
@@ -315,12 +369,20 @@ class RetrofitHelper {
}
}
- fun authenticate(context: Context, token: String, callback: (AuthenticateResponse) -> Unit) {
+ fun authenticate(
+ context: Context,
+ token: String,
+ showUserFacingErrors: Boolean = true,
+ callback: (AuthenticateResponse) -> Unit,
+ ) {
//println("RetrofitHelper: Starting validate API call with token: $token")
if (token.isEmpty()) {
println("RetrofitHelper: ERROR - Token is empty!")
- Toast.makeText(context, "Authentication failed: Token is empty", Toast.LENGTH_SHORT).show()
+ if (showUserFacingErrors) {
+ Toast.makeText(context, "Authentication failed: Token is empty", Toast.LENGTH_SHORT).show()
+ }
+ callback(AuthenticateResponse(success = false, message = "Token is empty"))
return
}
@@ -331,6 +393,10 @@ class RetrofitHelper {
}
val okHttpClient = OkHttpClient.Builder()
.addInterceptor(loggingInterceptor)
+ .connectTimeout(30, TimeUnit.SECONDS)
+ .readTimeout(30, TimeUnit.SECONDS)
+ .writeTimeout(30, TimeUnit.SECONDS)
+ .callTimeout(45, TimeUnit.SECONDS)
.build()
val retrofit: Retrofit = Retrofit.Builder()
@@ -348,7 +414,11 @@ class RetrofitHelper {
override fun onFailure(call: Call, t: Throwable) {
println("RetrofitHelper: Validate API call failed: ${t.message}")
t.printStackTrace()
- Toast.makeText(context, "Authentication failed: ${t.message}", Toast.LENGTH_SHORT).show()
+ val message = classifyNetworkError(t)
+ if (showUserFacingErrors) {
+ Toast.makeText(context, "Authentication failed: $message", Toast.LENGTH_SHORT).show()
+ }
+ callback(AuthenticateResponse(success = false, message = message))
}
override fun onResponse(call: Call, response: Response) {
@@ -360,11 +430,25 @@ class RetrofitHelper {
} else {
println("RetrofitHelper: Validation failed: Invalid response body")
Toast.makeText(context, "Authentication failed: Invalid response", Toast.LENGTH_SHORT).show()
+ callback(AuthenticateResponse(success = false, message = "Invalid response body"))
}
} else {
val errorBody = response.errorBody()?.string()
println("RetrofitHelper: Validate response not successful - Code: ${response.code()}, Error: $errorBody")
- Toast.makeText(context, "Authentication failed: ${response.code()}", Toast.LENGTH_SHORT).show()
+ val userMessage = when {
+ response.code() == 522 -> "Authentication server timed out. Please try again in a moment."
+ response.code() >= 500 -> "Authentication server is unavailable. Please try again later."
+ else -> "Authentication failed: ${response.code()}"
+ }
+ if (showUserFacingErrors) {
+ Toast.makeText(context, userMessage, Toast.LENGTH_SHORT).show()
+ }
+ callback(
+ AuthenticateResponse(
+ success = false,
+ message = "HTTP ${response.code()}: ${errorBody ?: "Authentication server error"}"
+ )
+ )
}
}
})
@@ -372,6 +456,7 @@ class RetrofitHelper {
println("RetrofitHelper: Exception in validate: ${e.message}")
e.printStackTrace()
Toast.makeText(context, "Authentication failed: ${e.message}", Toast.LENGTH_SHORT).show()
+ callback(AuthenticateResponse(success = false, message = e.message ?: "Authentication error"))
}
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/battle/SpriteFileManager.kt b/app/src/main/java/com/github/nacabaro/vbhelper/battle/SpriteFileManager.kt
index 65238e4..e2812c3 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/battle/SpriteFileManager.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/battle/SpriteFileManager.kt
@@ -1,275 +1,13 @@
package com.github.nacabaro.vbhelper.battle
-import android.content.Context
import java.io.File
-import java.io.FileOutputStream
-import java.io.IOException
-import java.io.FileInputStream
-class SpriteFileManager(private val context: Context) {
-
- // Get the external storage directory where files are already located
- fun getExternalSpriteBaseDir(): File {
+class SpriteFileManager {
+ private fun getExternalSpriteBaseDir(): File {
val externalDir = android.os.Environment.getExternalStorageDirectory()
return File(externalDir, "VBHelper/battle_sprites")
}
-
- // Get the internal storage directory for sprite files
- private fun getInternalSpriteBaseDir(): File {
- return File(context.filesDir, "battle_sprites")
- }
-
- fun copySpriteFilesToInternalStorage() {
- try {
- println("Starting sprite file copy process from external storage to internal storage...")
-
- val externalDir = getExternalSpriteBaseDir()
- val internalDir = getInternalSpriteBaseDir()
-
- // Check if external directory exists
- if (!externalDir.exists()) {
- println("External sprite directory does not exist: ${externalDir.absolutePath}")
- return
- }
-
- println("External sprite directory exists: ${externalDir.absolutePath}")
- println("Copying to internal storage: ${internalDir.absolutePath}")
-
- // Create internal directory if it doesn't exist
- if (!internalDir.exists()) {
- val created = internalDir.mkdirs()
- println("Created internal sprite directory: $created")
- }
-
- // Copy all subdirectories from external to internal storage
- val externalFiles = externalDir.listFiles()
- if (externalFiles != null) {
- println("Found ${externalFiles.size} items in external directory")
- externalFiles.forEach { item ->
- val targetItem = File(internalDir, item.name)
- if (item.isDirectory) {
- println("Copying directory: ${item.name}")
- copyDirectory(item, targetItem)
- } else {
- println("Copying file: ${item.name}")
- copyFile(item, targetItem)
- }
- }
- }
-
- println("Sprite files copied successfully to internal storage: ${internalDir.absolutePath}")
-
- } catch (e: Exception) {
- println("Error copying sprite files to internal storage: ${e.message}")
- e.printStackTrace()
- }
- }
-
- fun copySpriteFilesToExternalStorage() {
- try {
- println("Starting sprite file copy process to external storage...")
-
- // Debug: List what's in the assets directory
- val assetManager = context.assets
- val battleSpritesFiles = assetManager.list("battle_sprites")
- println("battle_sprites directory in assets contains: ${battleSpritesFiles?.joinToString(", ")}")
-
- val extractedAssetsFiles = assetManager.list("battle_sprites/extracted_assets")
- println("battle_sprites/extracted_assets directory in assets contains: ${extractedAssetsFiles?.joinToString(", ")}")
-
- // Check specifically for extracted_atksprites in assets (now directly under battle_sprites)
- val atkspritesInAssets = assetManager.list("battle_sprites/extracted_atksprites")
- println("extracted_atksprites in assets contains: ${atkspritesInAssets?.size ?: 0} files")
- if (atkspritesInAssets != null && atkspritesInAssets.isNotEmpty()) {
- println("First few attack files in assets: ${atkspritesInAssets.take(5).joinToString(", ")}")
- }
-
- // Check for extracted_battlebgs in assets (now directly under battle_sprites)
- val battlebgsInAssets = assetManager.list("battle_sprites/extracted_battlebgs")
- println("extracted_battlebgs in assets contains: ${battlebgsInAssets?.size ?: 0} files")
- if (battlebgsInAssets != null && battlebgsInAssets.isNotEmpty()) {
- println("First few battle background files in assets: ${battlebgsInAssets.take(5).joinToString(", ")}")
- }
-
- // Try to list all possible subdirectories in battle_sprites
- println("Checking all possible subdirectories in battle_sprites...")
- battleSpritesFiles?.forEach { subdir ->
- try {
- val subdirFiles = assetManager.list("battle_sprites/$subdir")
- println(" $subdir contains: ${subdirFiles?.size ?: 0} files")
- if (subdirFiles != null && subdirFiles.isNotEmpty()) {
- println(" First few files: ${subdirFiles.take(3).joinToString(", ")}")
- }
- } catch (e: Exception) {
- println(" Error listing $subdir: ${e.message}")
- }
- }
-
- // Create the base directory for battle_sprites in external storage
- val battleSpritesDir = getExternalSpriteBaseDir()
- if (!battleSpritesDir.exists()) {
- battleSpritesDir.mkdirs()
- println("Created battle_sprites directory in external storage: ${battleSpritesDir.absolutePath}")
- } else {
- println("battle_sprites directory already exists in external storage: ${battleSpritesDir.absolutePath}")
- }
-
- // Copy all subdirectories from battle_sprites assets to external storage
- println("Copying all battle_sprites subdirectories to external storage...")
- battleSpritesFiles?.forEach { subdir ->
- val sourcePath = "battle_sprites/$subdir"
- val targetDir = File(battleSpritesDir, subdir)
- println("Copying $sourcePath to ${targetDir.absolutePath}")
- copyAssetDirectory(sourcePath, targetDir)
- }
-
- println("Sprite files copied successfully to external storage: ${battleSpritesDir.absolutePath}")
-
- // Verify that attack sprites were copied
- val atkspritesDir = File(battleSpritesDir, "extracted_atksprites")
- if (atkspritesDir.exists()) {
- val attackFiles = atkspritesDir.listFiles()
- println("Attack sprites directory exists with ${attackFiles?.size ?: 0} files")
- if (attackFiles != null && attackFiles.isNotEmpty()) {
- println("First few attack files: ${attackFiles.take(5).map { it.name }}")
- }
- } else {
- println("WARNING: extracted_atksprites directory does not exist!")
- // List what's actually in the battle_sprites directory
- val battleSpritesContents = battleSpritesDir.listFiles()
- println("battle_sprites directory contains: ${battleSpritesContents?.map { it.name }?.joinToString(", ")}")
- }
-
- // Verify that battle backgrounds were copied
- val battlebgsDir = File(battleSpritesDir, "extracted_battlebgs")
- if (battlebgsDir.exists()) {
- val bgFiles = battlebgsDir.listFiles()
- println("Battle backgrounds directory exists with ${bgFiles?.size ?: 0} files")
- if (bgFiles != null && bgFiles.isNotEmpty()) {
- println("First few battle background files: ${bgFiles.take(5).map { it.name }}")
- }
- } else {
- println("WARNING: extracted_battlebgs directory does not exist!")
- }
-
- } catch (e: Exception) {
- println("Error copying sprite files to external storage: ${e.message}")
- e.printStackTrace()
- }
- }
-
- private fun copyAssetDirectory(assetPath: String, targetDir: File) {
- try {
- val assetManager = context.assets
- val files = assetManager.list(assetPath) ?: return
-
- println("Copying asset directory: $assetPath (${files.size} items)")
- println("Files found: ${files.joinToString(", ")}")
-
- for (file in files) {
- val assetFilePath = if (assetPath.isEmpty()) file else "$assetPath/$file"
- val targetFile = File(targetDir, file)
-
- // Create subdirectories if needed
- if (targetFile.parentFile != null && !targetFile.parentFile!!.exists()) {
- targetFile.parentFile!!.mkdirs()
- }
-
- // Check if it's a directory by trying to list its contents
- try {
- val subFiles = assetManager.list(assetFilePath)
- if (subFiles != null && subFiles.isNotEmpty()) {
- // It's a directory, create it and copy contents
- println("Copying subdirectory: $assetFilePath (${subFiles.size} files)")
- if (!targetFile.exists()) {
- targetFile.mkdirs()
- }
- copyAssetDirectory(assetFilePath, targetFile)
- } else {
- // It's a file, copy it
- copyAssetFile(assetFilePath, targetFile)
- }
- } catch (e: Exception) {
- // If we can't list contents, it's probably a file
- println("Treating $assetFilePath as file (could not list contents)")
- copyAssetFile(assetFilePath, targetFile)
- }
- }
-
- // Special handling for extracted_atksprites - try to copy it directly if it wasn't found
- if (assetPath == "battle_sprites/extracted_assets") {
- println("Special handling: Checking for extracted_atksprites directory...")
- try {
- val atkspritesFiles = assetManager.list("battle_sprites/extracted_assets/extracted_atksprites")
- if (atkspritesFiles != null && atkspritesFiles.isNotEmpty()) {
- println("Found extracted_atksprites with ${atkspritesFiles.size} files")
- val atkspritesDir = File(targetDir, "extracted_atksprites")
- if (!atkspritesDir.exists()) {
- atkspritesDir.mkdirs()
- }
- copyAssetDirectory("battle_sprites/extracted_assets/extracted_atksprites", atkspritesDir)
- } else {
- println("extracted_atksprites directory not found in assets")
- }
- } catch (e: Exception) {
- println("Error checking extracted_atksprites: ${e.message}")
- }
- }
-
- } catch (e: Exception) {
- println("Error copying asset directory $assetPath: ${e.message}")
- e.printStackTrace()
- }
- }
-
- private fun copyDirectory(sourceDir: File, targetDir: File) {
- if (!targetDir.exists()) {
- targetDir.mkdirs()
- }
-
- val files = sourceDir.listFiles()
- if (files != null) {
- files.forEach { file ->
- val targetFile = File(targetDir, file.name)
- if (file.isDirectory) {
- copyDirectory(file, targetFile)
- } else {
- copyFile(file, targetFile)
- }
- }
- }
- }
-
- private fun copyFile(sourceFile: File, targetFile: File) {
- try {
- val inputStream = FileInputStream(sourceFile)
- val outputStream = FileOutputStream(targetFile)
-
- inputStream.copyTo(outputStream)
- inputStream.close()
- outputStream.close()
-
- println("Copied: ${sourceFile.name} -> ${targetFile.absolutePath}")
- } catch (e: IOException) {
- println("Error copying file ${sourceFile.name}: ${e.message}")
- }
- }
-
- private fun copyAssetFile(assetPath: String, targetFile: File) {
- try {
- val inputStream = context.assets.open(assetPath)
- val outputStream = FileOutputStream(targetFile)
-
- inputStream.copyTo(outputStream)
- inputStream.close()
- outputStream.close()
-
- println("Copied: $assetPath -> ${targetFile.absolutePath}")
- } catch (e: IOException) {
- println("Error copying asset file $assetPath: ${e.message}")
- }
- }
-
+
fun checkSpriteFilesExist(): Boolean {
val battleSpritesDir = getExternalSpriteBaseDir()
val extractedAssetsDir = File(battleSpritesDir, "extracted_assets")
@@ -294,35 +32,4 @@ class SpriteFileManager(private val context: Context) {
return battleSpritesExist && assetsExist && statsExist && atkspritesExist && battlebgsExist
}
-
- fun clearSpriteFiles() {
- try {
- val battleSpritesDir = getInternalSpriteBaseDir()
-
- if (battleSpritesDir.exists()) {
- deleteDirectory(battleSpritesDir)
- println("Cleared battle_sprites directory from internal storage")
- }
-
- } catch (e: Exception) {
- println("Error clearing sprite files: ${e.message}")
- e.printStackTrace()
- }
- }
-
- private fun deleteDirectory(directory: File) {
- if (directory.exists()) {
- val files = directory.listFiles()
- if (files != null) {
- for (file in files) {
- if (file.isDirectory) {
- deleteDirectory(file)
- } else {
- file.delete()
- }
- }
- }
- directory.delete()
- }
- }
-}
\ No newline at end of file
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/battle/SpriteImage.kt b/app/src/main/java/com/github/nacabaro/vbhelper/battle/SpriteImage.kt
index 5e1c141..61cbee1 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/battle/SpriteImage.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/battle/SpriteImage.kt
@@ -5,7 +5,6 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
-import androidx.compose.ui.platform.LocalContext
@Composable
fun SpriteImage(
@@ -14,9 +13,8 @@ fun SpriteImage(
modifier: Modifier = Modifier,
contentScale: ContentScale = ContentScale.Fit
) {
- val context = LocalContext.current
- val spriteManager = remember { IndividualSpriteManager(context) }
-
+ val spriteManager = remember { IndividualSpriteManager() }
+
var bitmap by remember { mutableStateOf(null) }
LaunchedEffect(characterId, frameNumber) {
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/companion/card/ByteManipulation.kt b/app/src/main/java/com/github/nacabaro/vbhelper/companion/card/ByteManipulation.kt
new file mode 100644
index 0000000..327d3fc
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/companion/card/ByteManipulation.kt
@@ -0,0 +1,46 @@
+package com.github.nacabaro.vbhelper.companion.card
+
+enum class Endian {
+ Big,
+ Little,
+}
+
+fun ByteArray.getUInt32(index: Int = 0, endian: Endian = Endian.Little): UInt {
+ require(this.size >= index + 4) { "Must be 4 bytes from index to get a UInt" }
+ var result: UInt = 0u
+ for (i in 0 until 4) {
+ result = if (endian == Endian.Big) {
+ result or ((this[index + i].toUInt() and 0xFFu) shl (8 * (3 - i)))
+ } else {
+ result or ((this[index + i].toUInt() and 0xFFu) shl (8 * i))
+ }
+ }
+ return result
+}
+
+fun ByteArray.getUInt16(index: Int = 0, endian: Endian = Endian.Little): UShort {
+ require(this.size >= index + 2) { "Must be 2 bytes from index to get a UInt" }
+ var result: UInt = 0u
+ for (i in 0 until 2) {
+ result = if (endian == Endian.Big) {
+ result or ((this[index + i].toUInt() and 0xFFu) shl (8 * (1 - i)))
+ } else {
+ result or ((this[index + i].toUInt() and 0xFFu) shl (8 * i))
+ }
+ }
+ return result.toUShort()
+}
+
+fun UShort.toByteArray(endian: Endian = Endian.Little): ByteArray {
+ val byteArray = byteArrayOf(0, 0)
+ val asUInt = this.toUInt()
+ for (i in 0 until 2) {
+ if (endian == Endian.Little) {
+ byteArray[i] = ((asUInt shr (8 * i)) and 255u).toByte()
+ } else {
+ byteArray[1 - i] = ((asUInt shr (8 * i)) and 255u).toByte()
+ }
+ }
+ return byteArray
+}
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/companion/card/CompanionImportCardActivity.kt b/app/src/main/java/com/github/nacabaro/vbhelper/companion/card/CompanionImportCardActivity.kt
new file mode 100644
index 0000000..1cc8e08
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/companion/card/CompanionImportCardActivity.kt
@@ -0,0 +1,400 @@
+package com.github.nacabaro.vbhelper.companion.card
+
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.widget.Toast
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.result.ActivityResultLauncher
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.lifecycle.lifecycleScope
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Button
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.github.cfogrady.vb.dim.adventure.AdventureLevels
+import com.github.cfogrady.vb.dim.adventure.AdventureLevels.AdventureLevel
+import com.github.cfogrady.vb.dim.card.Card
+import com.github.cfogrady.vb.dim.card.DimReader
+import com.github.cfogrady.vb.dim.card.DimWriter
+import com.github.cfogrady.vb.dim.character.CharacterStats
+import com.github.cfogrady.vb.dim.character.CharacterStats.CharacterStatsEntry
+import com.github.cfogrady.vb.dim.fusion.AttributeFusions
+import com.github.cfogrady.vb.dim.fusion.SpecificFusions
+import com.github.cfogrady.vb.dim.fusion.SpecificFusions.SpecificFusionEntry
+import com.github.cfogrady.vb.dim.header.DimHeader
+import com.github.cfogrady.vb.dim.transformation.TransformationRequirements
+import com.github.cfogrady.vb.dim.transformation.TransformationRequirements.TransformationRequirementsEntry
+import com.github.nacabaro.vbhelper.R
+import com.github.nacabaro.vbhelper.companion.card.CompanionValidateCardActivity
+import com.github.nacabaro.vbhelper.companion.common.ChannelTypes
+import com.github.nacabaro.vbhelper.companion.ui.CompanionLoading
+import com.github.nacabaro.vbhelper.companion.validation.ValidatedCardManager
+import com.github.nacabaro.vbhelper.di.VBHelper
+import com.google.android.gms.wearable.Wearable
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.tasks.await
+import kotlinx.coroutines.withContext
+import timber.log.Timber
+import java.io.ByteArrayOutputStream
+import java.io.DataOutputStream
+import java.nio.charset.Charset
+
+class CompanionImportCardActivity : ComponentActivity() {
+ enum class ImportState {
+ PickFile,
+ NameOrUnique,
+ LoadFile,
+ UnlockCard,
+ ImportCard,
+ Success,
+ }
+
+ private lateinit var filePickLauncher: ActivityResultLauncher>
+ private lateinit var validationLauncher: ActivityResultLauncher
+ private var uri: Uri? = null
+ private val importState = MutableStateFlow(ImportState.PickFile)
+ private val cardName = MutableStateFlow("")
+ private val uniqueSprites = MutableStateFlow(false)
+ private val convertToBem = MutableStateFlow(false)
+ private val importPercent = MutableStateFlow(0)
+ private val importStatus = MutableStateFlow("Preparing transfer")
+ private lateinit var card: Card, out TransformationRequirements, out AdventureLevels, out AttributeFusions, out SpecificFusions>
+
+ private val validatedCardManager by lazy {
+ (application as VBHelper).validatedCardManager
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ filePickLauncher = buildFilePickLauncher()
+ validationLauncher = buildValidationLauncher()
+ setContent {
+ val state by importState.collectAsState()
+ when (state) {
+ ImportState.PickFile -> {
+ LaunchedEffect(true) {
+ filePickLauncher.launch(arrayOf("*/*"))
+ }
+ CompanionLoading(loadingText = stringResource(R.string.settings_companion_import_card_image_title))
+ }
+ ImportState.NameOrUnique -> NameOrUnique()
+ ImportState.LoadFile -> {
+ CompanionLoading(loadingText = "Loading Card Image")
+ LaunchedEffect(true) {
+ runCatching {
+ loadCard()
+ }.onFailure { error ->
+ Timber.e(error, "Failed to load card image")
+ Toast.makeText(
+ this@CompanionImportCardActivity,
+ "Card load failed: ${error.message ?: "unknown error"}",
+ Toast.LENGTH_LONG,
+ ).show()
+ importState.value = ImportState.NameOrUnique
+ }
+ }
+ }
+ ImportState.UnlockCard -> {
+ CompanionLoading(loadingText = stringResource(R.string.companion_validation_connect_vb))
+ LaunchedEffect(true) {
+ validationLauncher.launch(
+ Intent(applicationContext, CompanionValidateCardActivity::class.java).apply {
+ putExtra(CompanionValidateCardActivity.CARD_VALIDATED_KEY, card.header.dimId)
+ }
+ )
+ }
+ }
+ ImportState.ImportCard -> {
+ val percent by importPercent.collectAsState()
+ val status by importStatus.collectAsState()
+ CompanionLoading(loadingText = "$status ($percent%)")
+ LaunchedEffect(true) {
+ withContext(Dispatchers.IO) {
+ try {
+ val transferred = importCard()
+ importState.value = if (transferred) ImportState.Success else ImportState.NameOrUnique
+ } catch (error: Exception) {
+ Timber.e(error, "Card transfer failed")
+ withContext(Dispatchers.Main) {
+ Toast.makeText(
+ this@CompanionImportCardActivity,
+ "Card transfer failed: ${error.message ?: "unknown error"}",
+ Toast.LENGTH_LONG,
+ ).show()
+ }
+ importState.value = ImportState.NameOrUnique
+ }
+ }
+ }
+ }
+ ImportState.Success -> {
+ LaunchedEffect(true) {
+ Handler(Looper.getMainLooper()).postDelayed({
+ finish()
+ }, 1000)
+ }
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Text(text = stringResource(R.string.companion_card_import_success))
+ }
+ }
+ }
+ }
+ }
+
+ @Composable
+ private fun NameOrUnique() {
+ val selectedUri = uri
+ val filePath = selectedUri?.path ?: "No file selected"
+ val name by cardName.collectAsState()
+ val unique by uniqueSprites.collectAsState()
+ val convert by convertToBem.collectAsState()
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(16.dp),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Button(modifier = Modifier.padding(end = 10.dp), onClick = { importState.value = ImportState.PickFile }) {
+ Text(text = "File")
+ }
+ Text(text = filePath)
+ }
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 16.dp),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(text = "Name:", modifier = Modifier.padding(end = 10.dp))
+ TextField(value = name, onValueChange = { cardName.value = it })
+ }
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 16.dp),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(text = "Unique Sprites:")
+ Checkbox(checked = unique, onCheckedChange = { uniqueSprites.value = it })
+ }
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 16.dp),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(text = "Convert to BeM:")
+ Checkbox(checked = convert, onCheckedChange = { convertToBem.value = it })
+ }
+ Button(
+ modifier = Modifier.padding(top = 16.dp),
+ enabled = selectedUri != null,
+ onClick = { importState.value = ImportState.LoadFile },
+ ) {
+ Text(text = "Import")
+ }
+ }
+ }
+
+ private fun buildFilePickLauncher(): ActivityResultLauncher> {
+ return registerForActivityResult(ActivityResultContracts.OpenDocument()) {
+ if (it == null) {
+ Toast.makeText(
+ this,
+ "Card file selection cancelled.",
+ Toast.LENGTH_SHORT,
+ ).show()
+ importState.value = ImportState.NameOrUnique
+ } else {
+ uri = it
+ val path = it.path ?: ""
+ var name = path.substringAfterLast("/")
+ if (name.contains('.')) {
+ name = name.substringBeforeLast('.')
+ }
+ cardName.value = name
+ importState.value = ImportState.NameOrUnique
+ }
+ }
+ }
+
+ private fun buildValidationLauncher(): ActivityResultLauncher {
+ return registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
+ val validatedCardId = result.data?.extras?.getInt(CompanionValidateCardActivity.CARD_VALIDATED_KEY, -1)
+ if (result.resultCode != RESULT_OK || validatedCardId == null || validatedCardId == -1) {
+ Toast.makeText(
+ this,
+ "Card validation was cancelled.",
+ Toast.LENGTH_SHORT,
+ ).show()
+ importState.value = ImportState.NameOrUnique
+ } else {
+ lifecycleScope.launch(Dispatchers.IO) {
+ validatedCardManager.addValidatedCard(validatedCardId)
+ importState.value = ImportState.ImportCard
+ }
+ }
+ }
+ }
+
+ private suspend fun loadCard() {
+ val selectedUri = uri
+ if (selectedUri == null) {
+ withContext(Dispatchers.Main) {
+ Toast.makeText(
+ this@CompanionImportCardActivity,
+ "No card file selected.",
+ Toast.LENGTH_SHORT,
+ ).show()
+ }
+ importState.value = ImportState.NameOrUnique
+ return
+ }
+ val dimReader = DimReader()
+ val inputStream = contentResolver.openInputStream(selectedUri)
+ if (inputStream == null) {
+ withContext(Dispatchers.Main) {
+ Toast.makeText(
+ this@CompanionImportCardActivity,
+ "Unable to open selected card file.",
+ Toast.LENGTH_LONG,
+ ).show()
+ }
+ importState.value = ImportState.NameOrUnique
+ return
+ }
+ inputStream.use {
+ card = dimReader.readCard(it, false)
+ if (validatedCardManager.isValidatedCard(card.header.dimId)) {
+ importState.value = ImportState.ImportCard
+ } else {
+ importState.value = ImportState.UnlockCard
+ }
+ }
+ }
+
+ private suspend fun importCard(): Boolean {
+ importPercent.value = 0
+ importStatus.value = "Preparing card"
+ val channelClient = Wearable.getChannelClient(this)
+ val nodes = Wearable.getNodeClient(this).connectedNodes.await()
+ Timber.i("Card transfer start: connectedNodes=${nodes.size}, ids=${nodes.joinToString { it.id }}")
+ if (nodes.isEmpty()) {
+ importStatus.value = getString(R.string.companion_firmware_no_watch)
+ withContext(Dispatchers.Main) {
+ Toast.makeText(
+ this@CompanionImportCardActivity,
+ getString(R.string.companion_firmware_no_watch),
+ Toast.LENGTH_LONG,
+ ).show()
+ }
+ return false
+ }
+
+ val cardWriter = DimWriter()
+ val cardPayload = ByteArrayOutputStream().use { outputStream ->
+ cardWriter.writeCard(card, outputStream)
+ outputStream.toByteArray()
+ }
+ val cardNameBytes = cardName.value.toByteArray(Charset.defaultCharset())
+ val metadataLength = cardNameBytes.size + 1 + 1 + 1 + 4
+ val totalBytes = (metadataLength + cardPayload.size).toLong() * nodes.size
+ var transferredBytes = 0L
+ var successfulTransfers = 0
+ val failedNodes = mutableListOf()
+ for (node in nodes) {
+ val nodeName = node.displayName.takeIf { it.isNotBlank() } ?: node.id
+ importStatus.value = "Connecting to $nodeName"
+ try {
+ Timber.i("Opening card channel for nodeId=${node.id}, displayName=${node.displayName}")
+ val channel = channelClient.openChannel(node.id, ChannelTypes.CARD_DATA).await()
+ channelClient.getOutputStream(channel).await().use { os ->
+ val output = DataOutputStream(os)
+ output.write(cardNameBytes)
+ output.writeByte(0)
+ output.writeByte(if (uniqueSprites.value) 1 else 0)
+ output.writeByte(if (convertToBem.value) 1 else 0)
+ output.writeInt(cardPayload.size)
+ transferredBytes += metadataLength
+ val metadataPercent = if (totalBytes == 0L) 0 else ((transferredBytes * 100) / totalBytes).toInt().coerceIn(0, 100)
+ importStatus.value = "Sending card to $nodeName"
+ importPercent.value = metadataPercent
+
+ val buffer = ByteArray(4096)
+ var bytesRead: Int
+ cardPayload.inputStream().use { payloadInput ->
+ while (payloadInput.read(buffer).also { bytesRead = it } >= 0) {
+ output.write(buffer, 0, bytesRead)
+ transferredBytes += bytesRead
+ val transferPercent = if (totalBytes == 0L) 0 else ((transferredBytes * 100) / totalBytes).toInt().coerceIn(0, 100)
+ importPercent.value = transferPercent
+ }
+ }
+ output.flush()
+ }
+ channelClient.close(channel).await()
+ successfulTransfers++
+ Timber.i("Card transfer complete for nodeId=${node.id}, card=${cardName.value}, payloadBytes=${cardPayload.size}")
+ } catch (error: Exception) {
+ failedNodes.add("$nodeName: ${error.message ?: "unknown error"}")
+ Timber.e(error, "Card transfer failed for nodeId=${node.id}, displayName=${node.displayName}")
+ }
+ }
+
+ if (successfulTransfers == 0) {
+ importStatus.value = "Transfer failed"
+ importPercent.value = 0
+ withContext(Dispatchers.Main) {
+ val reason = failedNodes.firstOrNull() ?: "No watch accepted the transfer"
+ Toast.makeText(this@CompanionImportCardActivity, "Card transfer failed: $reason", Toast.LENGTH_LONG).show()
+ }
+ return false
+ }
+
+ importStatus.value = "Transfer complete"
+ importPercent.value = 100
+ if (failedNodes.isNotEmpty()) {
+ withContext(Dispatchers.Main) {
+ Toast.makeText(
+ this@CompanionImportCardActivity,
+ "Sent to $successfulTransfers watch(es); ${failedNodes.size} failed.",
+ Toast.LENGTH_LONG,
+ ).show()
+ }
+ }
+
+ Timber.i("Companion-style card import sent to watch for ${cardName.value}; success=$successfulTransfers failures=${failedNodes.size}")
+ return true
+ }
+}
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/companion/card/CompanionValidateCardActivity.kt b/app/src/main/java/com/github/nacabaro/vbhelper/companion/card/CompanionValidateCardActivity.kt
new file mode 100644
index 0000000..dcae9a5
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/companion/card/CompanionValidateCardActivity.kt
@@ -0,0 +1,162 @@
+package com.github.nacabaro.vbhelper.companion.card
+
+import android.content.Intent
+import android.nfc.NfcAdapter
+import android.nfc.Tag
+import android.nfc.tech.MifareUltralight
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.provider.Settings
+import android.widget.Toast
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.res.stringResource
+import com.github.nacabaro.vbhelper.R
+import com.github.nacabaro.vbhelper.companion.ui.CompanionLoading
+import kotlinx.coroutines.flow.MutableStateFlow
+import timber.log.Timber
+
+class CompanionValidateCardActivity : ComponentActivity(), NfcAdapter.ReaderCallback {
+ companion object {
+ const val CARD_VALIDATED_KEY = "CARD_VALIDATED"
+ }
+
+ enum class CardValidationState {
+ WaitingForVBConnect,
+ ValidateCardOnVB,
+ Success,
+ }
+
+ private lateinit var nfcAdapter: NfcAdapter
+ private val validationStateFlow = MutableStateFlow(CardValidationState.WaitingForVBConnect)
+ private var cardIdToValidate: UShort = 0u
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val maybeNfcAdapter = NfcAdapter.getDefaultAdapter(this)
+ val inputCardId = intent.getIntExtra(CARD_VALIDATED_KEY, -1)
+ if (inputCardId == -1) {
+ Timber.e("CompanionValidateCardActivity called without card number!")
+ finish()
+ return
+ }
+ cardIdToValidate = inputCardId.toUShort()
+ if (maybeNfcAdapter == null) {
+ Toast.makeText(this, getString(R.string.scan_no_nfc_on_device), Toast.LENGTH_SHORT).show()
+ finish()
+ return
+ }
+ nfcAdapter = maybeNfcAdapter
+ setContent {
+ DisplayContent()
+ }
+ }
+
+ @Composable
+ fun DisplayContent() {
+ val validationState by validationStateFlow.collectAsState()
+ when (validationState) {
+ CardValidationState.WaitingForVBConnect -> {
+ CompanionLoading(loadingText = stringResource(R.string.companion_validation_connect_vb))
+ }
+ CardValidationState.ValidateCardOnVB -> {
+ CompanionLoading(loadingText = stringResource(R.string.companion_validation_insert_card))
+ }
+ CardValidationState.Success -> {
+ LaunchedEffect(true) {
+ Handler(Looper.getMainLooper()).postDelayed({
+ val intent = Intent().apply {
+ putExtra(CARD_VALIDATED_KEY, cardIdToValidate.toInt())
+ }
+ setResult(RESULT_OK, intent)
+ finish()
+ }, 1000)
+ }
+ Text(text = stringResource(R.string.companion_validation_success))
+ }
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ if (!nfcAdapter.isEnabled) {
+ showWirelessSettings()
+ } else {
+ nfcAdapter.enableReaderMode(this, this, NfcAdapter.FLAG_READER_NFC_A, Bundle())
+ }
+ }
+
+ override fun onPause() {
+ super.onPause()
+ if (this::nfcAdapter.isInitialized) {
+ nfcAdapter.disableReaderMode(this)
+ }
+ }
+
+ private fun showWirelessSettings() {
+ Toast.makeText(this, getString(R.string.scan_nfc_must_be_enabled), Toast.LENGTH_SHORT).show()
+ startActivity(Intent(Settings.ACTION_WIRELESS_SETTINGS))
+ }
+
+ override fun onTagDiscovered(tag: Tag?) {
+ if (tag == null) {
+ Timber.w("Tag discovery returned null tag")
+ return
+ }
+ when (validationStateFlow.value) {
+ CardValidationState.WaitingForVBConnect -> {
+ val nfcData = MifareUltralight.get(tag)
+ if (nfcData == null) {
+ Timber.w("Unsupported NFC tech for validation")
+ return
+ }
+ val writeSuccessful = runCatching {
+ nfcData.connect()
+ nfcData.use {
+ val vbData = VBNfcData(nfcData)
+ vbData.writeCardCheck(nfcData, cardIdToValidate)
+ }
+ true
+ }.onFailure {
+ Timber.e(it, "Failed during first validation NFC step")
+ runOnUiThread {
+ Toast.makeText(
+ this,
+ "Failed to validate card on bracelet. Please retry.",
+ Toast.LENGTH_SHORT,
+ ).show()
+ }
+ }.getOrDefault(false)
+ if (writeSuccessful) {
+ validationStateFlow.value = CardValidationState.ValidateCardOnVB
+ }
+ }
+ CardValidationState.ValidateCardOnVB -> {
+ val nfcData = MifareUltralight.get(tag)
+ if (nfcData == null) {
+ Timber.w("Unsupported NFC tech for validation")
+ return
+ }
+ runCatching {
+ nfcData.connect()
+ nfcData.use {
+ val vbData = VBNfcData(nfcData)
+ if (vbData.wasCardIdValidated(cardIdToValidate)) {
+ validationStateFlow.value = CardValidationState.Success
+ }
+ }
+ }.onFailure {
+ Timber.e(it, "Failed during second validation NFC step")
+ }
+ }
+ else -> Timber.w("Incorrect state")
+ }
+ }
+}
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/companion/card/VBNfcData.kt b/app/src/main/java/com/github/nacabaro/vbhelper/companion/card/VBNfcData.kt
new file mode 100644
index 0000000..224c084
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/companion/card/VBNfcData.kt
@@ -0,0 +1,48 @@
+package com.github.nacabaro.vbhelper.companion.card
+
+import android.nfc.tech.MifareUltralight
+import kotlin.experimental.and
+import kotlin.experimental.or
+
+class VBNfcData(nfcData: MifareUltralight) {
+ companion object {
+ private const val ITEM_ID_BE: UShort = 4u
+ private const val STATUS_READY_FLAG: Byte = 0b00000001
+ private const val STATUS_DIM_READY_FLAG: Byte = 0b00000010
+ private val STATUS_DIM_IS_READY = STATUS_READY_FLAG or STATUS_DIM_READY_FLAG
+ private const val OPERATION_READY: Byte = 1
+ private const val OPERATION_CHECK_DIM: Byte = 3
+ }
+
+ val itemId: UShort
+ val status: Byte
+ val operation: Byte
+ val dimId: UShort
+
+ init {
+ val readData = nfcData.transceive(byteArrayOf(0x30, 0x04))
+ itemId = readData.getUInt16(4, Endian.Big)
+ status = readData[8]
+ if (itemId == ITEM_ID_BE) {
+ operation = readData[9]
+ dimId = readData.getUInt16(10, Endian.Big)
+ } else {
+ dimId = readData[9].toUShort()
+ operation = readData[10]
+ }
+ }
+
+ fun writeCardCheck(nfcData: MifareUltralight, dimId: UShort) {
+ if (itemId == ITEM_ID_BE) {
+ val dimData = dimId.toByteArray(endian = Endian.Big)
+ nfcData.writePage(6, byteArrayOf(STATUS_READY_FLAG, OPERATION_CHECK_DIM, dimData[0], dimData[1]))
+ } else {
+ nfcData.writePage(6, byteArrayOf(STATUS_READY_FLAG, dimId.toByte(), OPERATION_CHECK_DIM, 0))
+ }
+ }
+
+ fun wasCardIdValidated(dimId: UShort): Boolean {
+ return dimId == this.dimId && operation == OPERATION_READY && (status and STATUS_DIM_IS_READY == STATUS_DIM_IS_READY)
+ }
+}
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/companion/common/ChannelTypes.kt b/app/src/main/java/com/github/nacabaro/vbhelper/companion/common/ChannelTypes.kt
new file mode 100644
index 0000000..3a76ee7
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/companion/common/ChannelTypes.kt
@@ -0,0 +1,10 @@
+package com.github.nacabaro.vbhelper.companion.common
+
+object ChannelTypes {
+ const val CARD_DATA = "com.github.cfogrady.vitalwear/CARD_DATA"
+ const val FIRMWARE_DATA = "com.github.cfogrady.vitalwear/FIRMWARE_DATA"
+ const val CHARACTER_DATA = "com.github.cfogrady.vitalwear/CHARACTER_DATA"
+ const val SEND_LOGS_REQUEST = "SEND_LOGS_REQUEST"
+ const val LOGS_DATA = "LOG_DATA"
+}
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/companion/firmware/CompanionFirmwareImportActivity.kt b/app/src/main/java/com/github/nacabaro/vbhelper/companion/firmware/CompanionFirmwareImportActivity.kt
new file mode 100644
index 0000000..b843189
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/companion/firmware/CompanionFirmwareImportActivity.kt
@@ -0,0 +1,140 @@
+package com.github.nacabaro.vbhelper.companion.firmware
+
+import android.net.Uri
+import android.os.Bundle
+import android.widget.Toast
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.result.ActivityResultLauncher
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.lifecycle.lifecycleScope
+import com.github.nacabaro.vbhelper.R
+import com.github.nacabaro.vbhelper.companion.common.ChannelTypes
+import com.github.nacabaro.vbhelper.companion.ui.CompanionLoading
+import com.google.android.gms.wearable.Wearable
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.tasks.await
+import kotlinx.coroutines.withContext
+import timber.log.Timber
+import java.io.DataOutputStream
+
+class CompanionFirmwareImportActivity : ComponentActivity() {
+ enum class FirmwareImportState {
+ PickFirmware,
+ LoadFirmware,
+ }
+
+ private val importState = MutableStateFlow(FirmwareImportState.PickFirmware)
+ private val transferPercent = MutableStateFlow(0)
+ private val transferStatus = MutableStateFlow("Preparing transfer")
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val firmwareImportActivity = buildFirmwareFilePickLauncher()
+ setContent {
+ BuildScreen(firmwareImportActivity)
+ }
+ }
+
+ @Composable
+ fun BuildScreen(firmwareImportActivity: ActivityResultLauncher>) {
+ val state by importState.collectAsState()
+ val percent by transferPercent.collectAsState()
+ val status by transferStatus.collectAsState()
+ if (state == FirmwareImportState.PickFirmware) {
+ LaunchedEffect(true) {
+ firmwareImportActivity.launch(arrayOf("*/*"))
+ }
+ }
+ val loadingText = if (state == FirmwareImportState.PickFirmware) {
+ getString(R.string.companion_loading_select_firmware)
+ } else {
+ "$status ($percent%)"
+ }
+ CompanionLoading(loadingText = loadingText)
+ }
+
+ private fun buildFirmwareFilePickLauncher(): ActivityResultLauncher> {
+ return registerForActivityResult(ActivityResultContracts.OpenDocument()) {
+ importState.value = FirmwareImportState.LoadFirmware
+ if (it == null) {
+ finish()
+ } else {
+ Timber.i("Path: ${it.path}")
+ importFirmware(it)
+ }
+ }
+ }
+
+ private fun importFirmware(uri: Uri) {
+ transferPercent.value = 0
+ transferStatus.value = "Reading firmware file"
+ val channelClient = Wearable.getChannelClient(this)
+ val nodeListTask = Wearable.getNodeClient(this).connectedNodes
+ lifecycleScope.launch(Dispatchers.IO) {
+ try {
+ val nodes = nodeListTask.await()
+ if (nodes.isEmpty()) {
+ transferStatus.value = getString(R.string.companion_firmware_no_watch)
+ withContext(Dispatchers.Main) {
+ Toast.makeText(this@CompanionFirmwareImportActivity, getString(R.string.companion_firmware_no_watch), Toast.LENGTH_LONG).show()
+ }
+ importState.value = FirmwareImportState.PickFirmware
+ return@launch
+ }
+
+ val firmware = contentResolver.openInputStream(uri)?.use { input ->
+ input.readBytes()
+ } ?: throw IllegalStateException("Unable to read selected firmware file")
+
+ transferPercent.value = 5
+ transferStatus.value = "Sending firmware"
+ val totalBytes = (firmware.size + 4).toLong() * nodes.size
+ var transferredBytes = 0L
+ for (node in nodes) {
+ val channel = channelClient.openChannel(node.id, ChannelTypes.FIRMWARE_DATA).await()
+ try {
+ channelClient.getOutputStream(channel).await().use {
+ val output = DataOutputStream(it)
+ output.writeInt(firmware.size)
+ transferredBytes += 4
+ transferPercent.value = if (totalBytes == 0L) 0 else ((transferredBytes * 100) / totalBytes).toInt().coerceIn(0, 100)
+ val buffer = ByteArray(4096)
+ var bytesRead: Int
+ firmware.inputStream().use { firmwareInput ->
+ while (firmwareInput.read(buffer).also { bytesRead = it } >= 0) {
+ output.write(buffer, 0, bytesRead)
+ transferredBytes += bytesRead
+ transferPercent.value = if (totalBytes == 0L) 0 else ((transferredBytes * 100) / totalBytes).toInt().coerceIn(0, 100)
+ }
+ }
+ output.flush()
+ }
+ } finally {
+ runCatching { channelClient.close(channel).await() }
+ }
+ }
+ transferStatus.value = "Transfer complete"
+ transferPercent.value = 100
+ withContext(Dispatchers.Main) {
+ Toast.makeText(this@CompanionFirmwareImportActivity, getString(R.string.companion_firmware_sent_success), Toast.LENGTH_SHORT).show()
+ finish()
+ }
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to send firmware to watch")
+ transferStatus.value = "Transfer failed: ${e.message ?: "unknown"}"
+ withContext(Dispatchers.Main) {
+ Toast.makeText(this@CompanionFirmwareImportActivity, "Firmware transfer failed: ${e.message ?: "unknown error"}", Toast.LENGTH_LONG).show()
+ }
+ importState.value = FirmwareImportState.PickFirmware
+ }
+ }
+ }
+}
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/companion/logging/TinyLogTree.kt b/app/src/main/java/com/github/nacabaro/vbhelper/companion/logging/TinyLogTree.kt
new file mode 100644
index 0000000..579c593
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/companion/logging/TinyLogTree.kt
@@ -0,0 +1,77 @@
+package com.github.nacabaro.vbhelper.companion.logging
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.util.Log
+import org.tinylog.Logger
+import org.tinylog.configuration.Configuration
+import org.tinylog.provider.ProviderRegistry
+import timber.log.Timber
+import java.io.File
+
+@SuppressLint("LogNotTimber")
+class TinyLogTree(context: Context) : Timber.DebugTree() {
+ companion object {
+ private val logFileRegexp = Regex("""log_(\d{4}-\d{2}-\d{2})_(\d+)\.txt""")
+ private const val START_DATE = "0000-00-00"
+
+ fun shutdown() {
+ ProviderRegistry.getLoggingProvider().shutdown()
+ }
+
+ private fun setupRollingLogs(logsDir: String) {
+ Configuration.set("writer", "rolling file")
+ Configuration.set("writer.level", "info")
+ Configuration.set("writer.backups", "3")
+ Configuration.set("writer.format", "{date: yyyy-MM-dd HH:mm:ss.SSS}{pipe}{tag}{pipe}{level}{pipe}{message}")
+ Configuration.set("writer.file", "$logsDir/log_{date:yyyy-MM-dd}_{count}.txt")
+ Configuration.set("writer.policies", "daily, size: 10mb")
+ Configuration.set("writer.buffered", "true")
+ }
+
+ private fun getLogsDir(context: Context): File = File(context.filesDir, "logs")
+
+ fun getMostRecentLogFile(context: Context): File? {
+ val logsDir = getLogsDir(context)
+ val files = logsDir.listFiles() ?: return null
+ return getMostRecentLogFileFromFiles(files)
+ }
+
+ internal fun getMostRecentLogFileFromFiles(files: Array): File? {
+ var date = START_DATE
+ var count = 0
+ var mostRecentFile: File? = null
+ for (child in files) {
+ val matchResult = logFileRegexp.find(child.name) ?: continue
+ val childDate = matchResult.groups[1]?.value ?: START_DATE
+ val childCount = matchResult.groups[2]?.value?.toInt() ?: 0
+ if (childDate > date || (childDate == date && childCount > count)) {
+ mostRecentFile = child
+ date = childDate
+ count = childCount
+ }
+ }
+ Log.i("TinyLogTree", "Most Recent Log: $mostRecentFile")
+ return mostRecentFile
+ }
+ }
+
+ init {
+ val logsDir = getLogsDir(context).absolutePath
+ setupRollingLogs(logsDir)
+ Configuration.set("writingthread", "true")
+ Log.i("TinyLogTree", "Log Dir: $logsDir")
+ }
+
+ override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
+ val taggedLogger = Logger.tag(tag)
+ when (priority) {
+ Log.VERBOSE, Log.DEBUG -> taggedLogger.debug { message }
+ Log.INFO -> taggedLogger.info { message }
+ Log.WARN -> taggedLogger.warn { message }
+ Log.ERROR, Log.ASSERT -> taggedLogger.error { message }
+ else -> taggedLogger.error { "Invalid priority $priority For Message! $message" }
+ }
+ }
+}
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/companion/logs/CompanionLogService.kt b/app/src/main/java/com/github/nacabaro/vbhelper/companion/logs/CompanionLogService.kt
new file mode 100644
index 0000000..ff4e694
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/companion/logs/CompanionLogService.kt
@@ -0,0 +1,122 @@
+package com.github.nacabaro.vbhelper.companion.logs
+
+import android.annotation.SuppressLint
+import android.app.Activity
+import android.content.ClipData
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.util.Log
+import android.widget.Toast
+import androidx.core.content.FileProvider
+import com.github.nacabaro.vbhelper.R
+import com.github.nacabaro.vbhelper.companion.common.ChannelTypes
+import com.google.android.gms.wearable.CapabilityClient
+import com.google.android.gms.wearable.ChannelClient
+import com.google.android.gms.wearable.ChannelClient.ChannelCallback
+import com.google.android.gms.wearable.Wearable
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.tasks.await
+import timber.log.Timber
+import java.io.File
+
+class CompanionLogService {
+ companion object {
+ private const val TAG = "CompanionLogService"
+ }
+
+ var initializedActivity: Activity? = null
+
+ fun sendLogFile(context: Context, file: File) {
+ initializedActivity?.let {
+ sendLogFile(context, file, it)
+ initializedActivity?.finish()
+ }
+ initializedActivity = null
+ }
+
+ @SuppressLint("LogNotTimber")
+ fun sendLogFile(context: Context, file: File, activity: Activity) {
+ val emailIntent = Intent(Intent.ACTION_SENDTO).apply {
+ data = Uri.parse("mailto:")
+ putExtra(Intent.EXTRA_EMAIL, arrayOf("vitalwear-developers@googlegroups.com", "taveonnelsonn@gmail.com"))
+ putExtra(Intent.EXTRA_SUBJECT, "VBHelper Log")
+ putExtra(Intent.EXTRA_TEXT, "Please take a look at these logs to help diagnose the below issue:\n")
+ }
+ val fileUri = FileProvider.getUriForFile(context, "${context.packageName}.provider", file)
+ emailIntent.putExtra(Intent.EXTRA_STREAM, fileUri)
+ emailIntent.clipData = ClipData.newRawUri("", fileUri)
+ emailIntent.flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
+ Log.i(TAG, "Starting activity to send email")
+ activity.startActivity(Intent.createChooser(emailIntent, "Select Email Client"))
+ }
+
+ fun receiveFile(context: Context, channel: ChannelClient.Channel) {
+ val channelClient = Wearable.getChannelClient(context)
+ val logs = File(context.filesDir, "logs")
+ if (!logs.exists()) {
+ logs.mkdirs()
+ }
+ val file = File(logs, "watch_log.txt")
+ channelClient.registerChannelCallback(channel, object : ChannelCallback() {
+ override fun onInputClosed(channel: ChannelClient.Channel, closeReason: Int, appErrorCode: Int) {
+ super.onInputClosed(channel, closeReason, appErrorCode)
+ if (closeReason != ChannelCallback.CLOSE_REASON_NORMAL) {
+ Timber.w("Failed to receive file. Close Reason: ${closeReasonString(closeReason)}")
+ } else {
+ Timber.i("Successfully downloaded log file. Input closed.")
+ sendLogFile(context, file)
+ }
+ channelClient.close(channel)
+ }
+ })
+ channelClient.receiveFile(channel, Uri.fromFile(file), false).apply {
+ addOnFailureListener {
+ Toast.makeText(context, context.getString(R.string.companion_logs_receive_failed), Toast.LENGTH_SHORT).show()
+ initializedActivity?.finish()
+ initializedActivity = null
+ }
+ }
+ }
+
+ fun fetchWatchLogs(callingActivity: Activity) {
+ val messageClient = Wearable.getMessageClient(callingActivity)
+ val capabilityInfoTask = Wearable.getCapabilityClient(callingActivity)
+ .getCapability(ChannelTypes.SEND_LOGS_REQUEST, CapabilityClient.FILTER_REACHABLE)
+ CoroutineScope(Dispatchers.IO).launch {
+ val capabilityInfo = capabilityInfoTask.await()
+ if (capabilityInfo.nodes.isEmpty()) {
+ CoroutineScope(Dispatchers.Main).launch {
+ Toast.makeText(callingActivity, callingActivity.getString(R.string.companion_firmware_no_watch), Toast.LENGTH_SHORT).show()
+ callingActivity.finish()
+ }
+ return@launch
+ }
+ for (node in capabilityInfo.nodes) {
+ messageClient.sendMessage(node.id, ChannelTypes.SEND_LOGS_REQUEST, null).apply {
+ addOnFailureListener {
+ Toast.makeText(callingActivity, callingActivity.getString(R.string.companion_logs_failed_request), Toast.LENGTH_SHORT).show()
+ callingActivity.finish()
+ }
+ addOnSuccessListener {
+ initializedActivity = callingActivity
+ }
+ }
+ }
+ }
+ }
+
+ private fun closeReasonString(closeReason: Int): String {
+ return when (closeReason) {
+ ChannelCallback.CLOSE_REASON_NORMAL -> "Normal"
+ ChannelCallback.CLOSE_REASON_LOCAL_CLOSE -> "Closed Locally"
+ ChannelCallback.CLOSE_REASON_REMOTE_CLOSE -> "Closed Remotely"
+ ChannelCallback.CLOSE_REASON_DISCONNECTED -> "Disconnected"
+ ChannelCallback.CLOSE_REASON_CONNECTION_TIMEOUT -> "Timeout"
+ else -> "Unknown"
+ }
+ }
+}
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/companion/logs/CompanionWatchLogsActivity.kt b/app/src/main/java/com/github/nacabaro/vbhelper/companion/logs/CompanionWatchLogsActivity.kt
new file mode 100644
index 0000000..3a43336
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/companion/logs/CompanionWatchLogsActivity.kt
@@ -0,0 +1,41 @@
+package com.github.nacabaro.vbhelper.companion.logs
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.res.stringResource
+import com.github.nacabaro.vbhelper.R
+import com.github.nacabaro.vbhelper.companion.ui.CompanionConfirmation
+import com.github.nacabaro.vbhelper.companion.ui.CompanionLoading
+import com.github.nacabaro.vbhelper.di.VBHelper
+
+class CompanionWatchLogsActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val activity = this
+ setContent {
+ var accepted by remember { mutableStateOf(false) }
+ if (!accepted) {
+ CompanionConfirmation(prompt = stringResource(R.string.companion_logs_warning)) {
+ if (it) {
+ accepted = true
+ } else {
+ finish()
+ }
+ }
+ } else {
+ CompanionLoading(loadingText = stringResource(R.string.settings_companion_send_watch_logs_title))
+ LaunchedEffect(true) {
+ (application as VBHelper).companionLogService.fetchWatchLogs(activity)
+ }
+ }
+ }
+ }
+}
+
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/companion/logs/WatchCommunicationService.kt b/app/src/main/java/com/github/nacabaro/vbhelper/companion/logs/WatchCommunicationService.kt
new file mode 100644
index 0000000..d19bfa9
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/companion/logs/WatchCommunicationService.kt
@@ -0,0 +1,21 @@
+package com.github.nacabaro.vbhelper.companion.logs
+
+import com.github.nacabaro.vbhelper.companion.common.ChannelTypes
+import com.github.nacabaro.vbhelper.di.VBHelper
+import com.google.android.gms.wearable.ChannelClient
+import com.google.android.gms.wearable.WearableListenerService
+import timber.log.Timber
+
+class WatchCommunicationService : WearableListenerService() {
+ override fun onChannelOpened(channel: ChannelClient.Channel) {
+ super.onChannelOpened(channel)
+ when (channel.path) {
+ ChannelTypes.LOGS_DATA -> {
+ val logService = (application as VBHelper).companionLogService
+ logService.receiveFile(this, channel)
+ }
+ else -> Timber.i("Unknown channel: ${channel.path}")
+ }
+ }
+}
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/companion/transfer/CompanionCharacterImportActivity.kt b/app/src/main/java/com/github/nacabaro/vbhelper/companion/transfer/CompanionCharacterImportActivity.kt
new file mode 100644
index 0000000..18f6a6e
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/companion/transfer/CompanionCharacterImportActivity.kt
@@ -0,0 +1,23 @@
+package com.github.nacabaro.vbhelper.companion.transfer
+
+import android.os.Bundle
+import android.widget.Toast
+import androidx.activity.ComponentActivity
+import com.github.nacabaro.vbhelper.R
+
+/**
+ * Compatibility endpoint kept for legacy links/intents from older companion flows.
+ * Character transfer remains watch-driven and is not supported from phone companion UI.
+ */
+class CompanionCharacterImportActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ Toast.makeText(
+ this,
+ getString(R.string.companion_transfer_disabled_message),
+ Toast.LENGTH_LONG,
+ ).show()
+ finish()
+ }
+}
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/companion/ui/CompanionConfirmation.kt b/app/src/main/java/com/github/nacabaro/vbhelper/companion/ui/CompanionConfirmation.kt
new file mode 100644
index 0000000..4fbd602
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/companion/ui/CompanionConfirmation.kt
@@ -0,0 +1,42 @@
+package com.github.nacabaro.vbhelper.companion.ui
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Button
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun CompanionConfirmation(
+ prompt: String,
+ onResult: (Boolean) -> Unit,
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(24.dp),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(text = prompt, modifier = Modifier.padding(bottom = 24.dp))
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceEvenly,
+ ) {
+ Button(onClick = { onResult(true) }) {
+ Text(text = "Ok")
+ }
+ Button(onClick = { onResult(false) }) {
+ Text(text = "Cancel")
+ }
+ }
+ }
+}
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/companion/ui/CompanionLoading.kt b/app/src/main/java/com/github/nacabaro/vbhelper/companion/ui/CompanionLoading.kt
new file mode 100644
index 0000000..8326013
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/companion/ui/CompanionLoading.kt
@@ -0,0 +1,32 @@
+package com.github.nacabaro.vbhelper.companion.ui
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun CompanionLoading(
+ loadingText: String,
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(24.dp),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ CircularProgressIndicator()
+ Text(
+ text = loadingText,
+ modifier = Modifier.padding(top = 16.dp),
+ )
+ }
+}
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/companion/validation/CompanionValidatedDatabase.kt b/app/src/main/java/com/github/nacabaro/vbhelper/companion/validation/CompanionValidatedDatabase.kt
new file mode 100644
index 0000000..ac12102
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/companion/validation/CompanionValidatedDatabase.kt
@@ -0,0 +1,14 @@
+package com.github.nacabaro.vbhelper.companion.validation
+
+import androidx.room.Database
+import androidx.room.RoomDatabase
+
+@Database(
+ entities = [ValidatedCardEntity::class],
+ version = 1,
+ exportSchema = false,
+)
+abstract class CompanionValidatedDatabase : RoomDatabase() {
+ abstract fun validatedCardDao(): ValidatedCardDao
+}
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/companion/validation/ValidatedCardDao.kt b/app/src/main/java/com/github/nacabaro/vbhelper/companion/validation/ValidatedCardDao.kt
new file mode 100644
index 0000000..39fb0a4
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/companion/validation/ValidatedCardDao.kt
@@ -0,0 +1,16 @@
+package com.github.nacabaro.vbhelper.companion.validation
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+
+@Dao
+interface ValidatedCardDao {
+ @Query("select cardId from ${ValidatedCardEntity.TABLE}")
+ suspend fun getIds(): List
+
+ @Insert(onConflict = OnConflictStrategy.IGNORE)
+ suspend fun insert(validatedCardEntity: ValidatedCardEntity)
+}
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/companion/validation/ValidatedCardEntity.kt b/app/src/main/java/com/github/nacabaro/vbhelper/companion/validation/ValidatedCardEntity.kt
new file mode 100644
index 0000000..b794d67
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/companion/validation/ValidatedCardEntity.kt
@@ -0,0 +1,13 @@
+package com.github.nacabaro.vbhelper.companion.validation
+
+import androidx.room.Entity
+
+@Entity(tableName = ValidatedCardEntity.TABLE, primaryKeys = ["cardId"])
+data class ValidatedCardEntity(
+ val cardId: Int,
+) {
+ companion object {
+ const val TABLE = "validated"
+ }
+}
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/companion/validation/ValidatedCardManager.kt b/app/src/main/java/com/github/nacabaro/vbhelper/companion/validation/ValidatedCardManager.kt
new file mode 100644
index 0000000..fbb178f
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/companion/validation/ValidatedCardManager.kt
@@ -0,0 +1,35 @@
+package com.github.nacabaro.vbhelper.companion.validation
+
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+
+class ValidatedCardManager(
+ private val validatedCardDao: ValidatedCardDao,
+) {
+ private val mutex = Mutex()
+ private var validatedIds: Set? = null
+
+ private suspend fun ensureLoaded(): Set {
+ validatedIds?.let { return it }
+ return mutex.withLock {
+ validatedIds ?: validatedCardDao.getIds().toSet().also { validatedIds = it }
+ }
+ }
+
+ suspend fun addValidatedCard(cardId: Int) {
+ mutex.withLock {
+ val current = validatedIds ?: validatedCardDao.getIds().toSet()
+ if (!current.contains(cardId)) {
+ validatedCardDao.insert(ValidatedCardEntity(cardId))
+ validatedIds = current + cardId
+ } else {
+ validatedIds = current
+ }
+ }
+ }
+
+ suspend fun isValidatedCard(cardId: Int): Boolean {
+ return ensureLoaded().contains(cardId)
+ }
+}
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/daos/AdventureDao.kt b/app/src/main/java/com/github/nacabaro/vbhelper/daos/AdventureDao.kt
index 7167b97..8114a8b 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/daos/AdventureDao.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/daos/AdventureDao.kt
@@ -2,6 +2,7 @@ package com.github.nacabaro.vbhelper.daos
import androidx.room.Dao
import androidx.room.Query
+import androidx.room.RewriteQueriesToDropUnusedColumns
import com.github.nacabaro.vbhelper.dtos.CharacterDtos
import kotlinx.coroutines.flow.Flow
@@ -19,6 +20,7 @@ interface AdventureDao {
""")
fun getAdventureCount(): Int
+ @RewriteQueriesToDropUnusedColumns
@Query(
"""
SELECT
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/daos/CardFusionsDao.kt b/app/src/main/java/com/github/nacabaro/vbhelper/daos/CardFusionsDao.kt
index 86d6a9a..4382448 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/daos/CardFusionsDao.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/daos/CardFusionsDao.kt
@@ -2,6 +2,7 @@ package com.github.nacabaro.vbhelper.daos
import androidx.room.Dao
import androidx.room.Query
+import androidx.room.RewriteQueriesToDropUnusedColumns
import com.github.cfogrady.vbnfc.data.NfcCharacter
import com.github.nacabaro.vbhelper.dtos.CharacterDtos
import kotlinx.coroutines.flow.Flow
@@ -27,12 +28,12 @@ interface CardFusionsDao {
toCharaId: Int
)
+ @RewriteQueriesToDropUnusedColumns
@Query("""
SELECT
cf.toCharaId as charaId,
cf.fromCharaId as fromCharaId,
s.spriteIdle1 as spriteIdle,
- cc.attribute as attribute,
s.width as spriteWidth,
s.height as spriteHeight,
d.discoveredOn as discoveredOn,
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/daos/CharacterDao.kt b/app/src/main/java/com/github/nacabaro/vbhelper/daos/CharacterDao.kt
index 378e92f..e8f0de4 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/daos/CharacterDao.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/daos/CharacterDao.kt
@@ -13,9 +13,15 @@ interface CharacterDao {
@Insert
suspend fun insertCharacter(vararg characterData: CardCharacter)
+ @Query("SELECT * FROM CardCharacter WHERE id = :id LIMIT 1")
+ fun getCharacterById(id: Long): CardCharacter?
+
@Query("SELECT * FROM CardCharacter WHERE charaIndex = :monIndex AND cardId = :dimId LIMIT 1")
fun getCharacterByMonIndex(monIndex: Int, dimId: Long): CardCharacter
+ @Query("SELECT * FROM CardCharacter WHERE charaIndex = :monIndex AND cardId = :dimId LIMIT 1")
+ fun getCharacterByMonIndexOrNull(monIndex: Int, dimId: Long): CardCharacter?
+
@Insert
suspend fun insertSprite(vararg sprite: Sprite)
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/daos/SpriteDao.kt b/app/src/main/java/com/github/nacabaro/vbhelper/daos/SpriteDao.kt
index 4d8d6e6..62943a5 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/daos/SpriteDao.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/daos/SpriteDao.kt
@@ -11,6 +11,9 @@ interface SpriteDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertSprite(sprite: Sprite): Long
+ @Query("SELECT * FROM Sprite WHERE id = :id LIMIT 1")
+ fun getSpriteById(id: Long): Sprite?
+
@Query("SELECT * FROM Sprite")
suspend fun getAllSprites(): List
}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/daos/UserCharacterDao.kt b/app/src/main/java/com/github/nacabaro/vbhelper/daos/UserCharacterDao.kt
index 218191c..b8bd138 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/daos/UserCharacterDao.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/daos/UserCharacterDao.kt
@@ -4,6 +4,7 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
+import androidx.room.RewriteQueriesToDropUnusedColumns
import androidx.room.Upsert
import com.github.nacabaro.vbhelper.domain.card.CardCharacter
import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter
@@ -38,6 +39,7 @@ interface UserCharacterDao {
@Upsert
fun insertSpecialMissions(vararg specialMissions: SpecialMissions)
+ @RewriteQueriesToDropUnusedColumns
@Query(
"""
SELECT
@@ -55,6 +57,7 @@ interface UserCharacterDao {
)
fun getTransformationHistory(monId: Long): Flow>
+ @RewriteQueriesToDropUnusedColumns
@Query(
"""
SELECT
@@ -71,6 +74,7 @@ interface UserCharacterDao {
)
suspend fun getTransformationHistoryForExport(monId: Long): List
+ @RewriteQueriesToDropUnusedColumns
@Query(
"""
SELECT
@@ -96,6 +100,7 @@ interface UserCharacterDao {
)
fun getAllCharacters(): Flow>
+ @RewriteQueriesToDropUnusedColumns
@Query(
"""
SELECT
@@ -137,9 +142,13 @@ interface UserCharacterDao {
@Query("SELECT * FROM VBCharacterData WHERE id = :id")
suspend fun getVbDataOrNull(id: Long): VBCharacterData?
+ @Query("DELETE FROM BECharacterData WHERE id = :id")
+ suspend fun deleteBECharacterData(id: Long)
+
@Query("SELECT * FROM SpecialMissions WHERE characterId = :id")
fun getSpecialMissions(id: Long): Flow>
+ @RewriteQueriesToDropUnusedColumns
@Query(
"""
SELECT
@@ -205,6 +214,7 @@ interface UserCharacterDao {
@Query("""SELECT * FROM VitalsHistory WHERE charId = :charId ORDER BY id ASC""")
suspend fun getVitalsHistory(charId: Long): List
+ @RewriteQueriesToDropUnusedColumns
@Query(
"""
SELECT
@@ -231,6 +241,7 @@ interface UserCharacterDao {
)
suspend fun getBECharacters(): List
+ @RewriteQueriesToDropUnusedColumns
@Query(
"""
SELECT
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/database/AppDatabase.kt b/app/src/main/java/com/github/nacabaro/vbhelper/database/AppDatabase.kt
index 06b77b8..566f828 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/database/AppDatabase.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/database/AppDatabase.kt
@@ -7,7 +7,6 @@ import androidx.sqlite.db.SupportSQLiteDatabase
import com.github.nacabaro.vbhelper.daos.AdventureDao
import com.github.nacabaro.vbhelper.daos.CardAdventureDao
import com.github.nacabaro.vbhelper.daos.CharacterDao
-import com.github.nacabaro.vbhelper.daos.CharacterTransferPolicyDao
import com.github.nacabaro.vbhelper.daos.DexDao
import com.github.nacabaro.vbhelper.daos.CardDao
import com.github.nacabaro.vbhelper.daos.CardFusionsDao
@@ -28,7 +27,6 @@ import com.github.nacabaro.vbhelper.domain.characters.Sprite
import com.github.nacabaro.vbhelper.domain.characters.Adventure
import com.github.nacabaro.vbhelper.domain.characters.Dex
import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData
-import com.github.nacabaro.vbhelper.domain.device_data.CharacterTransferPolicy
import com.github.nacabaro.vbhelper.domain.device_data.SpecialMissions
import com.github.nacabaro.vbhelper.domain.device_data.TransformationHistory
import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter
@@ -39,6 +37,7 @@ import com.github.nacabaro.vbhelper.domain.items.Items
@Database(
version = 6,
+ exportSchema = false,
entities = [
Card::class,
CardProgress::class,
@@ -52,7 +51,6 @@ import com.github.nacabaro.vbhelper.domain.items.Items
SpecialMissions::class,
TransformationHistory::class,
VitalsHistory::class,
- CharacterTransferPolicy::class,
Dex::class,
Items::class,
Adventure::class,
@@ -74,7 +72,6 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun cardAdventureDao(): CardAdventureDao
abstract fun cardFusionsDao(): CardFusionsDao
abstract fun vitalWearSettingsDao(): VitalWearSettingsDao
- abstract fun characterTransferPolicyDao(): CharacterTransferPolicyDao
companion object {
val MIGRATION_1_2 = object : Migration(1, 2) {
@@ -145,23 +142,19 @@ abstract class AppDatabase : RoomDatabase() {
val MIGRATION_5_6 = object : Migration(5, 6) {
override fun migrate(db: SupportSQLiteDatabase) {
- db.execSQL(
- """
- CREATE TABLE IF NOT EXISTS `CharacterTransferPolicy` (
- `characterId` INTEGER NOT NULL,
- `nativeDeviceType` TEXT NOT NULL,
- `preferredHceExportFormat` TEXT NOT NULL,
- `preferredNfcaExportFormat` TEXT NOT NULL,
- `lastObservedImportFormat` TEXT,
- `lastTransferTransport` TEXT,
- `lastTransferTarget` TEXT,
- `preserveVbRoundTrip` INTEGER NOT NULL,
- `preserveBeRoundTrip` INTEGER NOT NULL,
- PRIMARY KEY(`characterId`),
- FOREIGN KEY(`characterId`) REFERENCES `UserCharacter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE
- )
- """.trimIndent()
- )
+ db.execSQL("CREATE INDEX IF NOT EXISTS `index_CardCharacter_spriteId` ON `CardCharacter` (`spriteId`)")
+ db.execSQL("CREATE INDEX IF NOT EXISTS `index_CardAdventure_cardId` ON `CardAdventure` (`cardId`)")
+ db.execSQL("CREATE INDEX IF NOT EXISTS `index_CardAdventure_characterId` ON `CardAdventure` (`characterId`)")
+ db.execSQL("CREATE INDEX IF NOT EXISTS `index_CardFusions_fromCharaId` ON `CardFusions` (`fromCharaId`)")
+ db.execSQL("CREATE INDEX IF NOT EXISTS `index_CardFusions_toCharaId` ON `CardFusions` (`toCharaId`)")
+ db.execSQL("CREATE INDEX IF NOT EXISTS `index_Background_cardId` ON `Background` (`cardId`)")
+ db.execSQL("CREATE INDEX IF NOT EXISTS `index_PossibleTransformations_charaId` ON `PossibleTransformations` (`charaId`)")
+ db.execSQL("CREATE INDEX IF NOT EXISTS `index_PossibleTransformations_toCharaId` ON `PossibleTransformations` (`toCharaId`)")
+ db.execSQL("CREATE INDEX IF NOT EXISTS `index_UserCharacter_charId` ON `UserCharacter` (`charId`)")
+ db.execSQL("CREATE INDEX IF NOT EXISTS `index_SpecialMissions_characterId` ON `SpecialMissions` (`characterId`)")
+ db.execSQL("CREATE INDEX IF NOT EXISTS `index_TransformationHistory_monId` ON `TransformationHistory` (`monId`)")
+ db.execSQL("CREATE INDEX IF NOT EXISTS `index_TransformationHistory_stageId` ON `TransformationHistory` (`stageId`)")
+ db.execSQL("CREATE INDEX IF NOT EXISTS `index_VitalsHistory_charId` ON `VitalsHistory` (`charId`)")
}
}
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/di/AppContainer.kt b/app/src/main/java/com/github/nacabaro/vbhelper/di/AppContainer.kt
index 5034497..ff6d8fc 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/di/AppContainer.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/di/AppContainer.kt
@@ -2,6 +2,7 @@ package com.github.nacabaro.vbhelper.di
import com.github.cfogrady.vitalwear.common.data.SharedTransferSeenDao
import com.github.nacabaro.vbhelper.database.AppDatabase
+import com.github.nacabaro.vbhelper.source.CardSettingsRepository
import com.github.nacabaro.vbhelper.source.CurrencyRepository
import com.github.nacabaro.vbhelper.source.DataStoreSecretsRepository
@@ -10,4 +11,5 @@ interface AppContainer {
val transferSeenDao: SharedTransferSeenDao
val dataStoreSecretsRepository: DataStoreSecretsRepository
val currencyRepository: CurrencyRepository
+ val cardSettingsRepository: CardSettingsRepository
}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/di/DefaultAppContainer.kt b/app/src/main/java/com/github/nacabaro/vbhelper/di/DefaultAppContainer.kt
index 49d0542..c949f86 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/di/DefaultAppContainer.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/di/DefaultAppContainer.kt
@@ -8,6 +8,7 @@ import com.github.cfogrady.vitalwear.common.data.SharedDatabaseFactory
import com.github.cfogrady.vitalwear.common.data.SharedTransferSeenDao
import com.github.nacabaro.vbhelper.di.AppContainer
import com.github.nacabaro.vbhelper.database.AppDatabase
+import com.github.nacabaro.vbhelper.source.CardSettingsRepository
import com.github.nacabaro.vbhelper.source.CurrencyRepository
import com.github.nacabaro.vbhelper.source.DataStoreSecretsRepository
import com.github.nacabaro.vbhelper.source.SecretsSerializer
@@ -37,6 +38,7 @@ class DefaultAppContainer(private val context: Context) : AppContainer {
.addMigrations(AppDatabase.MIGRATION_2_3)
.addMigrations(AppDatabase.MIGRATION_3_5)
.addMigrations(AppDatabase.MIGRATION_4_5)
+ .addMigrations(AppDatabase.MIGRATION_5_6)
.build()
}
@@ -47,5 +49,7 @@ class DefaultAppContainer(private val context: Context) : AppContainer {
override val dataStoreSecretsRepository = DataStoreSecretsRepository(context.secretsStore)
override val currencyRepository = CurrencyRepository(context.currencyStore)
+
+ override val cardSettingsRepository = CardSettingsRepository(context.currencyStore)
}
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/di/VBHelper.kt b/app/src/main/java/com/github/nacabaro/vbhelper/di/VBHelper.kt
index 0c66c99..b6e6c07 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/di/VBHelper.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/di/VBHelper.kt
@@ -2,12 +2,37 @@ package com.github.nacabaro.vbhelper.di
import DefaultAppContainer
import android.app.Application
+import androidx.room.Room
+import com.github.nacabaro.vbhelper.companion.logging.TinyLogTree
+import com.github.nacabaro.vbhelper.companion.logs.CompanionLogService
+import com.github.nacabaro.vbhelper.companion.validation.CompanionValidatedDatabase
+import com.github.nacabaro.vbhelper.companion.validation.ValidatedCardManager
+import timber.log.Timber
class VBHelper : Application() {
lateinit var container: DefaultAppContainer
+ lateinit var validatedCardDatabase: CompanionValidatedDatabase
+ lateinit var validatedCardManager: ValidatedCardManager
+ val companionLogService = CompanionLogService()
override fun onCreate() {
super.onCreate()
container = DefaultAppContainer(applicationContext)
+
+ Timber.plant(Timber.DebugTree())
+ Timber.plant(TinyLogTree(this))
+ val originalExceptionHandler = Thread.getDefaultUncaughtExceptionHandler()
+ Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
+ Timber.e(throwable, "VBHelper crashed on thread ${thread.name}")
+ TinyLogTree.shutdown()
+ originalExceptionHandler?.uncaughtException(thread, throwable)
+ }
+
+ validatedCardDatabase = Room.databaseBuilder(
+ applicationContext,
+ CompanionValidatedDatabase::class.java,
+ "vbhelper_companion_tools"
+ ).build()
+ validatedCardManager = ValidatedCardManager(validatedCardDatabase.validatedCardDao())
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/domain/card/Background.kt b/app/src/main/java/com/github/nacabaro/vbhelper/domain/card/Background.kt
index 23766d4..d49c49a 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/domain/card/Background.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/domain/card/Background.kt
@@ -2,9 +2,11 @@ package com.github.nacabaro.vbhelper.domain.card
import androidx.room.Entity
import androidx.room.ForeignKey
+import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
+ indices = [Index(value = ["cardId"])],
foreignKeys = [
ForeignKey(
entity = Card::class,
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/domain/card/CardAdventure.kt b/app/src/main/java/com/github/nacabaro/vbhelper/domain/card/CardAdventure.kt
index b8f4f56..5fe0e55 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/domain/card/CardAdventure.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/domain/card/CardAdventure.kt
@@ -2,9 +2,14 @@ package com.github.nacabaro.vbhelper.domain.card
import androidx.room.Entity
import androidx.room.ForeignKey
+import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
+ indices = [
+ Index(value = ["cardId"]),
+ Index(value = ["characterId"])
+ ],
foreignKeys = [
ForeignKey(
entity = CardCharacter::class,
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/domain/card/CardCharacter.kt b/app/src/main/java/com/github/nacabaro/vbhelper/domain/card/CardCharacter.kt
index d59b5fd..bfed547 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/domain/card/CardCharacter.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/domain/card/CardCharacter.kt
@@ -9,7 +9,8 @@ import com.github.nacabaro.vbhelper.domain.characters.Sprite
@Entity(
indices = [
- Index(value = ["cardId", "charaIndex"], unique = true)
+ Index(value = ["cardId", "charaIndex"], unique = true),
+ Index(value = ["spriteId"])
],
foreignKeys = [
ForeignKey(
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/domain/card/CardFusions.kt b/app/src/main/java/com/github/nacabaro/vbhelper/domain/card/CardFusions.kt
index 1769eda..ecc4e41 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/domain/card/CardFusions.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/domain/card/CardFusions.kt
@@ -2,10 +2,15 @@ package com.github.nacabaro.vbhelper.domain.card
import androidx.room.Entity
import androidx.room.ForeignKey
+import androidx.room.Index
import androidx.room.PrimaryKey
import com.github.cfogrady.vbnfc.data.NfcCharacter
@Entity(
+ indices = [
+ Index(value = ["fromCharaId"]),
+ Index(value = ["toCharaId"])
+ ],
foreignKeys = [
ForeignKey(
entity = CardCharacter::class,
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/domain/card/PossibleTransformations.kt b/app/src/main/java/com/github/nacabaro/vbhelper/domain/card/PossibleTransformations.kt
index 2dc2e8b..5312070 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/domain/card/PossibleTransformations.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/domain/card/PossibleTransformations.kt
@@ -2,9 +2,14 @@ package com.github.nacabaro.vbhelper.domain.card
import androidx.room.Entity
import androidx.room.ForeignKey
+import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
+ indices = [
+ Index(value = ["charaId"]),
+ Index(value = ["toCharaId"])
+ ],
foreignKeys = [
ForeignKey(
entity = CardCharacter::class,
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/SpecialMissions.kt b/app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/SpecialMissions.kt
index 13b092d..ef2e3dd 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/SpecialMissions.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/SpecialMissions.kt
@@ -2,10 +2,12 @@ package com.github.nacabaro.vbhelper.domain.device_data
import androidx.room.Entity
import androidx.room.ForeignKey
+import androidx.room.Index
import androidx.room.PrimaryKey
import com.github.cfogrady.vbnfc.vb.SpecialMission
@Entity(
+ indices = [Index(value = ["characterId"])],
foreignKeys = [
ForeignKey(
entity = UserCharacter::class,
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/TransformationHistory.kt b/app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/TransformationHistory.kt
index 0a560cc..436af2b 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/TransformationHistory.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/TransformationHistory.kt
@@ -2,10 +2,15 @@ package com.github.nacabaro.vbhelper.domain.device_data
import androidx.room.Entity
import androidx.room.ForeignKey
+import androidx.room.Index
import androidx.room.PrimaryKey
import com.github.nacabaro.vbhelper.domain.card.CardCharacter
@Entity(
+ indices = [
+ Index(value = ["monId"]),
+ Index(value = ["stageId"])
+ ],
foreignKeys = [
ForeignKey(
entity = UserCharacter::class,
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/UnifiedCharacter.kt b/app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/UnifiedCharacter.kt
new file mode 100644
index 0000000..0acb709
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/UnifiedCharacter.kt
@@ -0,0 +1,31 @@
+package com.github.nacabaro.vbhelper.domain.device_data
+
+import com.github.nacabaro.vbhelper.utils.DeviceType
+
+/**
+ * UnifiedCharacter provides a single access point for common character data
+ * and bridges the specific data for VB and BE characters.
+ */
+data class UnifiedCharacter(
+ val userCharacter: UserCharacter,
+ val vbData: VBCharacterData? = null,
+ val beData: BECharacterData? = null
+) {
+ val characterType: DeviceType get() = userCharacter.characterType
+
+ fun isVB() = characterType == DeviceType.VBDevice
+ fun isBE() = characterType == DeviceType.BEDevice
+
+ // Unified accessors for core stats
+ val trophies: Int get() = userCharacter.trophies
+ val ageInDays: Int get() = userCharacter.ageInDays
+ val mood: Int get() = userCharacter.mood
+ val vitalPoints: Int get() = userCharacter.vitalPoints
+ val injuryStatus = userCharacter.injuryStatus
+
+ // BE specific accessors with defaults
+ val trainingHp: Int get() = beData?.trainingHp ?: 0
+ val trainingAp: Int get() = beData?.trainingAp ?: 0
+ val trainingBp: Int get() = beData?.trainingBp ?: 0
+ val abilityRarity = beData?.abilityRarity
+}
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/UserCharacter.kt b/app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/UserCharacter.kt
index e34b57d..3a68aa4 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/UserCharacter.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/UserCharacter.kt
@@ -2,12 +2,14 @@ package com.github.nacabaro.vbhelper.domain.device_data
import androidx.room.Entity
import androidx.room.ForeignKey
+import androidx.room.Index
import androidx.room.PrimaryKey
import com.github.cfogrady.vbnfc.data.NfcCharacter
import com.github.nacabaro.vbhelper.utils.DeviceType
import com.github.nacabaro.vbhelper.domain.card.CardCharacter
@Entity(
+ indices = [Index(value = ["charId"])],
foreignKeys = [
ForeignKey(
entity = CardCharacter::class,
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/VitalsHistory.kt b/app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/VitalsHistory.kt
index 313e7c1..163b44e 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/VitalsHistory.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/VitalsHistory.kt
@@ -2,9 +2,11 @@ package com.github.nacabaro.vbhelper.domain.device_data
import androidx.room.Entity
import androidx.room.ForeignKey
+import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
+ indices = [Index(value = ["charId"])],
foreignKeys = [
ForeignKey(
entity = UserCharacter::class,
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/navigation/AppNavigation.kt b/app/src/main/java/com/github/nacabaro/vbhelper/navigation/AppNavigation.kt
index 2b3d047..22f43be 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/navigation/AppNavigation.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/navigation/AppNavigation.kt
@@ -15,6 +15,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.navigation.compose.NavHost
+import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.github.nacabaro.vbhelper.di.VBHelper
@@ -40,7 +41,6 @@ import com.github.nacabaro.vbhelper.screens.settingsScreen.CreditsScreen
import com.github.nacabaro.vbhelper.screens.spriteViewer.SpriteViewerControllerImpl
import com.github.nacabaro.vbhelper.screens.storageScreen.StorageScreenControllerImpl
import com.github.nacabaro.vbhelper.source.StorageRepository
-import kotlinx.coroutines.flow.Flow
data class AppNavigationHandlers(
val settingsScreenController: SettingsScreenControllerImpl,
@@ -56,20 +56,22 @@ data class AppNavigationHandlers(
@Composable
fun AppNavigation(
applicationNavigationHandlers: AppNavigationHandlers,
- initialRoute: String? = null,
- navigationEvents: Flow? = null,
+ initialRoute: String? = null
) {
val navController = rememberNavController()
- LaunchedEffect(navController, navigationEvents) {
- navigationEvents?.collect { route ->
- navController.navigate(route) {
- launchSingleTop = true
- restoreState = true
- popUpTo(navController.graph.startDestinationId) {
- saveState = true
- }
+ LaunchedEffect(initialRoute) {
+ val route = initialRoute ?: return@LaunchedEffect
+ if (navController.currentBackStackEntry?.destination?.route == route) {
+ return@LaunchedEffect
+ }
+ navController.navigate(route) {
+ popUpTo(navController.graph.findStartDestination().id) {
+ inclusive = false
+ saveState = false
}
+ launchSingleTop = true
+ restoreState = false
}
}
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/navigation/BottomNavigationBar.kt b/app/src/main/java/com/github/nacabaro/vbhelper/navigation/BottomNavigationBar.kt
index 672ffd7..0118833 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/navigation/BottomNavigationBar.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/navigation/BottomNavigationBar.kt
@@ -7,6 +7,7 @@ import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.painterResource
import androidx.navigation.NavController
+import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.compose.ui.res.stringResource
@@ -30,40 +31,14 @@ fun BottomNavigationBar(navController: NavController) {
label = { Text(text = stringResource(item.label)) },
selected = currentRoute == item.route,
onClick = {
- if (item == NavigationItems.Home) {
- // Home should always show the Home root, not a restored nested route
- // like Settings that was opened from Home previously.
- val poppedToHome = navController.popBackStack(
- NavigationItems.Home.route,
- inclusive = false,
- )
- if (!poppedToHome) {
- navController.navigate(NavigationItems.Home.route) {
- popUpTo(navController.graph.startDestinationId) { inclusive = false }
- launchSingleTop = true
- restoreState = false
- }
- }
- } else if (item == NavigationItems.Storage) {
- // Adventure is launched from Storage; tapping Storage again should always
- // bring the user back to the Storage root instead of appearing stuck.
- val poppedToStorage = navController.popBackStack(
- NavigationItems.Storage.route,
- inclusive = false,
- )
- if (!poppedToStorage) {
- navController.navigate(NavigationItems.Storage.route) {
- popUpTo(navController.graph.startDestinationId) { saveState = true }
- launchSingleTop = true
- restoreState = true
- }
- }
- } else {
- navController.navigate(item.route) {
- popUpTo(navController.graph.startDestinationId) { saveState = true }
- launchSingleTop = true
- restoreState = true
+ navController.navigate(item.route) {
+ // Always route tab clicks to each tab's root screen.
+ popUpTo(navController.graph.findStartDestination().id) {
+ inclusive = false
+ saveState = false
}
+ launchSingleTop = true
+ restoreState = false
}
}
)
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/navigation/NavigationExtensions.kt b/app/src/main/java/com/github/nacabaro/vbhelper/navigation/NavigationExtensions.kt
new file mode 100644
index 0000000..e69de29
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/navigation/NavigationItems.kt b/app/src/main/java/com/github/nacabaro/vbhelper/navigation/NavigationItems.kt
index 77b9b91..27af488 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/navigation/NavigationItems.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/navigation/NavigationItems.kt
@@ -6,8 +6,8 @@ import androidx.annotation.StringRes
sealed class NavigationItems(
val route: String,
- @DrawableRes val icon: Int,
- @StringRes val label: Int
+ @param:DrawableRes val icon: Int,
+ @param:StringRes val label: Int
) {
object Scan : NavigationItems(
"Scan/{characterId}",
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/BattlesScreen.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/BattlesScreen.kt
index dad71ac..179776b 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/BattlesScreen.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/BattlesScreen.kt
@@ -51,6 +51,7 @@ import android.content.Intent
import android.net.Uri
import android.media.MediaPlayer
import android.os.Environment
+import android.widget.Toast
//import androidx.compose.animation.core.animateFloatAsState
//import androidx.compose.animation.core.tween
import kotlinx.coroutines.delay
@@ -64,6 +65,8 @@ import com.github.nacabaro.vbhelper.battle.APIBattleCharacter
import android.util.Log
import com.github.nacabaro.vbhelper.components.TopBanner
import com.github.nacabaro.vbhelper.battle.RetrofitHelper
+import com.github.nacabaro.vbhelper.battle.extractSessionToken
+import com.github.nacabaro.vbhelper.battle.extractUserId
import com.github.nacabaro.vbhelper.battle.AttackSpriteImage
import com.github.nacabaro.vbhelper.battle.SpriteFileManager
import com.github.nacabaro.vbhelper.battle.ArenaBattleSystem
@@ -71,6 +74,7 @@ import com.github.nacabaro.vbhelper.battle.DigimonAnimationType
import com.github.nacabaro.vbhelper.battle.AnimatedSpriteImage
import com.github.nacabaro.vbhelper.battle.HitEffectOverlay
import com.github.nacabaro.vbhelper.battle.BattleAuthContainer
+import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.collect
import androidx.compose.foundation.lazy.LazyColumn
@@ -899,7 +903,6 @@ fun MiddleBattleView(
y = enemyVerticalOffset + 40.dp
),
contentScale = ContentScale.Fit,
- reloadMappings = false,
animationOffset = 375L // Offset enemy animation by half the idle duration
)
@@ -986,7 +989,6 @@ fun MiddleBattleView(
y = playerVerticalOffset - 40.dp
),
contentScale = ContentScale.Fit,
- reloadMappings = false,
animationOffset = 0L // Player animation starts immediately
)
@@ -1072,19 +1074,30 @@ fun MiddleBattleView(
// Capture userId for use in this lambda
val playerUserId = userId
-
+ if (playerUserId == null) {
+ context?.let {
+ Toast.makeText(it, "Session missing user id. Please re-authenticate.", Toast.LENGTH_SHORT).show()
+ }
+ battleSystem.resetAttackState()
+ battleSystem.enableAttackButton()
+ return@Button
+ }
+
// Get crit bar progress as float (0.0f to 100.0f)
val critBarProgressFloat = battleSystem.critBarProgress.toFloat()
// Determine player and opponent stages
+ // activeCharacter.stage is DB stage (2=rookie, 3=champion, 4=ultimate, 5=mega)
+ // API expects stage 0-3
val playerStage = when (activeCharacter?.stage) {
- 0 -> 0 // rookie
- 1 -> 1 // champion
- 2 -> 2 // ultimate
- 3 -> 3 // mega
+ 2 -> 0 // rookie
+ 3 -> 1 // champion
+ 4 -> 2 // ultimate
+ 5 -> 3 // mega
else -> 0
}
+ // opponentCharacter.stage comes from the API and is already 0-3
val opponentStage = when (opponentCharacter?.stage) {
0 -> 0 // rookie
1 -> 1 // champion
@@ -1101,11 +1114,11 @@ fun MiddleBattleView(
RetrofitHelper().getPVPWinner(
ctx,
1,
- playerUserId ?: 2L,
- activeCharacter?.name ?: "Player",
- playerStage,
- opponentStage,
- opponentCharacter?.name ?: "Opponent",
+ playerUserId,
+ activeCharacter?.charaId ?: "dim011_mon01",
+ playerStage,
+ battleSystem.critBarProgress,
+ opponentCharacter?.charaId ?: "dim011_mon01",
opponentStage
) { apiResult ->
// Handle API response here
@@ -1309,7 +1322,6 @@ fun PlayerBattleView(
y = verticalOffset
),
contentScale = ContentScale.Fit,
- reloadMappings = false,
animationOffset = 0L // Player animation starts immediately
)
@@ -1491,7 +1503,6 @@ fun EnemyBattleView(
y = verticalOffset
),
contentScale = ContentScale.Fit,
- reloadMappings = false,
animationOffset = 375L // Offset enemy animation by half the idle duration
)
@@ -1641,8 +1652,63 @@ fun BattlesScreen() {
// Track processed tokens to prevent duplicate API calls
// Use rememberSaveable to persist across configuration changes (screen rotation)
var processedTokens by rememberSaveable { mutableStateOf>(emptySet()) }
+ var isProcessingAuthCallback by rememberSaveable { mutableStateOf(false) }
+ var authBrowserLaunchInFlight by rememberSaveable { mutableStateOf(false) }
var opponentsList by remember { mutableStateOf(ArrayList()) }
+ var isLoadingOpponents by remember { mutableStateOf(false) }
+ var authReadyToken by remember { mutableStateOf(null) }
+
+ LaunchedEffect(Unit) {
+ battleAuthContainer.authRepository.sessionToken
+ .combine(battleAuthContainer.authRepository.authToken) { sessionToken, nacatechToken ->
+ sessionToken?.takeIf { it.isNotBlank() }
+ ?: nacatechToken?.takeIf { it.isNotBlank() }
+ }
+ .collect { token ->
+ authReadyToken = token
+ if (token.isNullOrBlank() && opponentsList.isNotEmpty()) {
+ opponentsList = ArrayList()
+ }
+ }
+ }
+
+ val openBattleAuthPage: () -> Unit = openAuth@{
+ if (isAuthenticated && !authReadyToken.isNullOrBlank()) {
+ println("BATTLESCREEN: Skipping auth browser launch because session is already ready")
+ return@openAuth
+ }
+ if (isProcessingAuthCallback) {
+ println("BATTLESCREEN: Skipping auth browser launch while callback is being processed")
+ return@openAuth
+ }
+ if (authBrowserLaunchInFlight) {
+ println("BATTLESCREEN: Auth browser launch already in flight, skipping duplicate request")
+ return@openAuth
+ }
+
+ val authUrl = "http://auth.nacatech.es/begin?app=443654920&redirect_uri=vbhelper://auth?token="
+ authBrowserLaunchInFlight = true
+ try {
+ val customTabsIntent = androidx.browser.customtabs.CustomTabsIntent.Builder()
+ .setShowTitle(true)
+ .build()
+ customTabsIntent.launchUrl(context, Uri.parse(authUrl))
+ println("BATTLESCREEN: Opened auth URL in Custom Tab: $authUrl")
+ } catch (e: Exception) {
+ authBrowserLaunchInFlight = false
+ println("BATTLESCREEN: Failed to open auth URL in Custom Tab: ${e.message}")
+ // Fall back to external browser if Custom Tabs is unavailable
+ try {
+ val fallbackIntent = Intent(Intent.ACTION_VIEW, Uri.parse(authUrl))
+ context.startActivity(fallbackIntent)
+ println("BATTLESCREEN: Fallback to external browser succeeded")
+ } catch (e2: Exception) {
+ println("BATTLESCREEN: Fallback browser also failed: ${e2.message}")
+ e2.printStackTrace()
+ }
+ }
+ }
var activeCharacter by remember { mutableStateOf(null) }
var selectedOpponent by remember { mutableStateOf(null) }
@@ -1724,39 +1790,85 @@ fun BattlesScreen() {
// Determine if player can battle based on stage (derived from activeUserCharacter)
val canBattle = activeUserCharacter?.stage?.let { it >= 2 } ?: false
- // Load opponents automatically based on player's stage
- // Only load if authenticated and character is ready
- LaunchedEffect(activeUserCharacter, isAuthenticated) {
- // Wait for authentication to complete before loading opponents
- if (!isAuthenticated) {
- return@LaunchedEffect
- }
-
+ val loadOpponentsIfReady: () -> Unit = loadOpponents@{
val currentCharacter = activeUserCharacter
- if (currentCharacter != null && canBattle && playerBattleType != null) {
- try {
- RetrofitHelper().getOpponents(context, playerBattleType!!) { opponents ->
- try {
- // Create a new list to trigger UI recomposition
- opponentsList = ArrayList(opponents.opponentsList)
- } catch (e: Exception) {
- Log.d(TAG, "Error processing opponents data: ${e.message}")
- e.printStackTrace()
- }
- }
- } catch (e: Exception) {
- Log.d(TAG,"Error calling getOpponents: ${e.message}")
- e.printStackTrace()
- }
- } else {
- println("BATTLESCREEN: Cannot load opponents - activeUserCharacter: $currentCharacter")
- println("BATTLESCREEN: canBattle: $canBattle")
- println("BATTLESCREEN: playerBattleType: $playerBattleType")
- println("BATTLESCREEN: currentCharacter != null: ${currentCharacter != null}")
- if (currentCharacter != null) {
- println("BATTLESCREEN: currentCharacter.stage: ${currentCharacter.stage}")
- println("BATTLESCREEN: currentCharacter.stage >= 2: ${currentCharacter.stage >= 2}")
+ val readyToken = authReadyToken
+
+ if (!isAuthenticated) {
+ if (opponentsList.isNotEmpty()) {
+ opponentsList = ArrayList()
}
+ return@loadOpponents
+ }
+
+ if (readyToken.isNullOrBlank()) {
+ println("BATTLESCREEN: Skipping opponent load - auth token not persisted yet")
+ return@loadOpponents
+ }
+
+ if (isLoadingOpponents) {
+ println("BATTLESCREEN: Opponents load already in flight, skipping duplicate request")
+ return@loadOpponents
+ }
+
+ if (currentCharacter != null && currentCharacter.stage >= 2) {
+ val battleType = when (currentCharacter.stage) {
+ 2 -> "rookie"
+ 3 -> "champion"
+ 4 -> "ultimate"
+ 5 -> "mega"
+ else -> null
+ }
+ if (battleType == null) {
+ println("BATTLESCREEN: Stage ${currentCharacter.stage} has no battle type, skipping")
+ return@loadOpponents
+ }
+ try {
+ isLoadingOpponents = true
+ RetrofitHelper().getOpponents(context, battleType, onComplete = {
+ isLoadingOpponents = false
+ }) { opponents ->
+ try {
+ opponentsList = ArrayList(opponents.opponentsList)
+ println("BATTLESCREEN: Loaded ${opponents.opponentsList.size} opponents for stage $battleType")
+ } catch (e: Exception) {
+ Log.d(TAG, "Error processing opponents data: ${e.message}")
+ e.printStackTrace()
+ }
+ }
+ } catch (e: Exception) {
+ isLoadingOpponents = false
+ Log.d(TAG, "Error calling getOpponents: ${e.message}")
+ e.printStackTrace()
+ }
+ } else {
+ if (opponentsList.isNotEmpty()) {
+ opponentsList = ArrayList()
+ }
+ println("BATTLESCREEN: Cannot load opponents - stage ${currentCharacter?.stage}, must be >= 2")
+ }
+ }
+
+ // Load opponents automatically based on player's stage once auth is truly ready.
+ LaunchedEffect(activeUserCharacter, isAuthenticated, authReadyToken, playerBattleType) {
+ loadOpponentsIfReady()
+ }
+
+ // Re-fetch opponents when returning from browser (resume)
+ DisposableEffect(context, isAuthenticated, activeUserCharacter, canBattle, playerBattleType, authReadyToken) {
+ val activity = context as? ComponentActivity
+ val lifecycleOwner = activity as? LifecycleOwner
+
+ val observer = LifecycleEventObserver { _, event ->
+ if (event == Lifecycle.Event.ON_RESUME) {
+ if (isAuthenticated && !authReadyToken.isNullOrBlank()) {
+ loadOpponentsIfReady()
+ }
+ }
+ }
+ lifecycleOwner?.lifecycle?.addObserver(observer)
+ onDispose {
+ lifecycleOwner?.lifecycle?.removeObserver(observer)
}
}
@@ -1775,17 +1887,19 @@ fun BattlesScreen() {
if (!processedTokens.contains(token)) {
// Mark token as being processed IMMEDIATELY to prevent duplicate API calls
processedTokens = processedTokens + token
-
+ isProcessingAuthCallback = true
+ authBrowserLaunchInFlight = false
+
// Exchange token with battle server
RetrofitHelper().authenticate(context, token) { response ->
if (response.success) {
// Extract userId and sessionToken from response
- val extractedUserId = response.userInfo?.userId?.toLongOrNull()
- val sessionToken = response.sessionToken
-
+ val extractedUserId = response.extractUserId()
+ val sessionToken = response.extractSessionToken()
+
println("BATTLESCREEN: Authentication successful, userId: $extractedUserId, sessionToken: ${if (sessionToken != null) "present" else "missing"}")
- // Store both nacatech token (for re-auth) and sessionToken (for API calls)
+ // Persist auth first so follow-up API calls can immediately use the token.
kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch {
battleAuthContainer.authRepository.setAuthenticated(
isAuthenticated = true,
@@ -1793,14 +1907,16 @@ fun BattlesScreen() {
sessionToken = sessionToken,
userId = extractedUserId
)
- }
- // Update UI state on main thread
- kotlinx.coroutines.CoroutineScope(Dispatchers.Main).launch {
- isAuthenticated = true
- isCheckingAuth = false
- userId = extractedUserId
- println("BATTLESCREEN: Authentication successful, userId: $extractedUserId")
- android.widget.Toast.makeText(context, "Authentication successful!", android.widget.Toast.LENGTH_SHORT).show()
+
+ withContext(Dispatchers.Main) {
+ isAuthenticated = true
+ isCheckingAuth = false
+ userId = extractedUserId
+ isProcessingAuthCallback = false
+ authBrowserLaunchInFlight = false
+ println("BATTLESCREEN: Authentication successful, userId: $extractedUserId")
+ android.widget.Toast.makeText(context, "Authentication successful!", android.widget.Toast.LENGTH_SHORT).show()
+ }
}
} else {
println("BATTLESCREEN: Authentication failed: ${response.message}")
@@ -1815,23 +1931,16 @@ fun BattlesScreen() {
kotlinx.coroutines.CoroutineScope(Dispatchers.Main).launch {
isAuthenticated = false
isCheckingAuth = false
- // Small delay to ensure state is updated
- kotlinx.coroutines.delay(100)
- // Open auth URL to get a fresh token
- val authUrl = "http://auth.nacatech.es/begin?app=443654920&redirect_uri=vbhelper://auth?token="
- val authIntent = Intent(Intent.ACTION_VIEW, Uri.parse(authUrl))
- try {
- context.startActivity(authIntent)
- println("BATTLESCREEN: Opened auth URL after token expiration: $authUrl")
- } catch (e: Exception) {
- println("BATTLESCREEN: Failed to open auth URL: ${e.message}")
- e.printStackTrace()
- }
- }
+ isProcessingAuthCallback = false
+ authBrowserLaunchInFlight = false
+ println("BATTLESCREEN: Auth callback token was already used; avoiding automatic browser relaunch loop")
+ }
} else {
// For other errors, remove from processed set to allow retry with a new token
println("BATTLESCREEN: Authentication failed, removing token from processed set to allow retry")
processedTokens = processedTokens - token
+ isProcessingAuthCallback = false
+ authBrowserLaunchInFlight = false
}
// Show toast on main thread
kotlinx.coroutines.CoroutineScope(Dispatchers.Main).launch {
@@ -1839,12 +1948,47 @@ fun BattlesScreen() {
}
}
}
+ } else {
+ println("BATTLESCREEN: Ignoring already processed auth token")
}
} else {
println("BATTLESCREEN: No token found in URI: $uri (checked 'c' and 'token' parameters)")
}
}
-
+
+ val consumeAuthCallbackIntent: () -> Boolean = consume@{
+ val currentActivity = context as? ComponentActivity ?: return@consume false
+ val incomingIntent = currentActivity.intent ?: return@consume false
+ if (incomingIntent.action != Intent.ACTION_VIEW) return@consume false
+
+ val uri = incomingIntent.data ?: return@consume false
+ val isAppAuthCallback = uri.scheme == "vbhelper" && uri.host == "auth"
+ val isLocalhostAuthCallback =
+ (uri.scheme == "http" || uri.scheme == "https") &&
+ (uri.host == "localhost" || uri.host == "127.0.0.1") &&
+ uri.path?.startsWith("/authenticate") == true
+ val hasAuthToken = !uri.getQueryParameter("c").isNullOrEmpty() ||
+ !uri.getQueryParameter("token").isNullOrEmpty()
+
+ if (!hasAuthToken) {
+ return@consume false
+ }
+
+ if (!isAppAuthCallback && !isLocalhostAuthCallback) {
+ return@consume false
+ }
+
+ handleTokenFromUri(uri)
+
+ // Clear callback intent so reopening Battles does not replay stale deep links.
+ currentActivity.setIntent(Intent(incomingIntent).apply {
+ action = Intent.ACTION_MAIN
+ data = null
+ })
+
+ true
+ }
+
// Check authentication status on load
LaunchedEffect(Unit) {
try {
@@ -1852,7 +1996,8 @@ fun BattlesScreen() {
val localAuthState = authRepository.isAuthenticated.first()
val storedToken = authRepository.authToken.first()
val storedUserId = authRepository.userId.first()
-
+ val storedSessionToken = authRepository.sessionToken.first()
+
// Load stored userId if available
if (storedUserId != null) {
userId = storedUserId
@@ -1867,41 +2012,36 @@ fun BattlesScreen() {
userId = storedUserId
}
- // Only check for token in intent if it's a fresh deep link (ACTION_VIEW intent)
- // This prevents processing stale tokens from previous sessions
- val activity = context as? ComponentActivity
- val intent = activity?.intent
- if (intent?.action == Intent.ACTION_VIEW) {
- intent.data?.let { uri ->
- if (uri.getQueryParameter("c") != null || uri.getQueryParameter("token") != null) {
- handleTokenFromUri(uri)
- return@LaunchedEffect // Don't open auth URL if we're processing a token
- }
- }
+ if (consumeAuthCallbackIntent()) {
+ return@LaunchedEffect
}
// If we have a stored token and userId, assume session is still active
// The single-use token can't be re-validated, but the server maintains a session after initial validation
// We'll only re-authenticate if API calls fail with authentication errors
- if (localAuthState && storedToken != null && storedToken.isNotEmpty() && storedUserId != null) {
+ if (localAuthState && storedToken != null && storedToken.isNotEmpty() && (storedUserId != null || !storedSessionToken.isNullOrEmpty())) {
// Session appears to be active - user is already authenticated
// No need to re-validate the single-use token, just restore the session
- println("BATTLESCREEN: Restoring active session (userId: $storedUserId)")
+ println("BATTLESCREEN: Restoring active session (userId: $storedUserId, sessionTokenPresent=${!storedSessionToken.isNullOrEmpty()})")
isAuthenticated = true
isCheckingAuth = false
userId = storedUserId
// Session is restored, no need to validate token again
- } else if (localAuthState && storedToken != null && storedToken.isNotEmpty()) {
+ } else if (localAuthState && storedToken != null && storedToken.isNotEmpty() && storedSessionToken.isNullOrEmpty()) {
// We have a token but no userId - try to validate once to get userId
// This should only happen on first login or if userId was lost
println("BATTLESCREEN: Have token but no userId, validating once to get userId...")
- RetrofitHelper().authenticate(context, storedToken) { response ->
+ RetrofitHelper().authenticate(
+ context = context,
+ token = storedToken,
+ showUserFacingErrors = false,
+ ) { response ->
// Update UI on main thread
kotlinx.coroutines.CoroutineScope(Dispatchers.Main).launch {
if (response.success) {
- val extractedUserId = response.userInfo?.userId?.toLongOrNull()
- val sessionToken = response.sessionToken
-
+ val extractedUserId = response.extractUserId()
+ val sessionToken = response.extractSessionToken()
+
// Update stored userId and sessionToken
if (extractedUserId != null) {
kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch {
@@ -1924,8 +2064,22 @@ fun BattlesScreen() {
response.message?.contains("nonce") == true ||
response.message?.contains("invalid") == true ||
response.message?.contains("expired") == true
-
+ val latestSessionToken = withContext(Dispatchers.IO) {
+ authRepository.sessionToken.first()
+ }
+ val latestUserId = withContext(Dispatchers.IO) {
+ authRepository.userId.first()
+ }
+
if (isCriticalError) {
+ // A parallel callback auth can complete before this fallback check returns.
+ if (!latestSessionToken.isNullOrEmpty() || latestUserId != null) {
+ println("BATTLESCREEN: Ignoring nonce validation failure because a valid session already exists")
+ isAuthenticated = true
+ isCheckingAuth = false
+ userId = latestUserId ?: userId
+ return@launch
+ }
// Critical error - token is invalid, need to re-authenticate
println("BATTLESCREEN: Critical authentication error, clearing state and redirecting")
kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch {
@@ -1933,11 +2087,7 @@ fun BattlesScreen() {
}
isAuthenticated = false
isCheckingAuth = false
- // Open auth URL
- val authUrl = "http://auth.nacatech.es/begin?app=443654920&redirect_uri=vbhelper://auth?token="
- val authIntent = Intent(Intent.ACTION_VIEW, Uri.parse(authUrl))
- context.startActivity(authIntent)
- println("BATTLESCREEN: Opened auth URL after critical validation failure: $authUrl")
+ openBattleAuthPage()
} else {
// Non-critical error (e.g., network issue) - keep authenticated state
println("BATTLESCREEN: Non-critical validation error, keeping authenticated state")
@@ -1952,10 +2102,7 @@ fun BattlesScreen() {
isAuthenticated = false
isCheckingAuth = false
// If not authenticated and no fresh token in intent, open auth URL
- val authUrl = "http://auth.nacatech.es/begin?app=443654920&redirect_uri=vbhelper://auth?token="
- val authIntent = Intent(Intent.ACTION_VIEW, Uri.parse(authUrl))
- context.startActivity(authIntent)
- println("BATTLESCREEN: Opened auth URL: $authUrl")
+ openBattleAuthPage()
}
} catch (e: Exception) {
println("BATTLESCREEN: Error checking authentication status: ${e.message}")
@@ -1964,42 +2111,11 @@ fun BattlesScreen() {
}
}
- // Handle deep link callback to get token
- // Check intent data on initial load - handle both vbhelper:// and http://localhost:8080/authenticate?c=
- // Only process if it's a fresh ACTION_VIEW intent (deep link)
+ // Handle deep link callback once on initial load and consume it.
LaunchedEffect(Unit) {
// Small delay to ensure activity is fully initialized
kotlinx.coroutines.delay(100)
-
- val activity = context as? ComponentActivity
- val intent = activity?.intent
-
- // Only process if this is a fresh deep link (ACTION_VIEW)
- if (intent?.action == Intent.ACTION_VIEW) {
- val uri = intent.data
- if (uri != null) {
-
- // Handle vbhelper://auth?token= or vbhelper://auth?c= deep link
- if (uri.scheme == "vbhelper" && uri.host == "auth") {
- handleTokenFromUri(uri)
- }
- // Handle http://localhost:8080/authenticate?c= redirect
- else if ((uri.scheme == "http" || uri.scheme == "https") &&
- (uri.host == "localhost" || uri.host == "127.0.0.1" || uri.host?.contains("8080") == true)) {
- handleTokenFromUri(uri)
- }
- // Also check if there's a 'c' or 'token' parameter in any URL
- else if (uri.getQueryParameter("c") != null || uri.getQueryParameter("token") != null) {
- handleTokenFromUri(uri)
- } else {
- println("BATTLESCREEN: URI found but no token parameter detected")
- }
- } else {
- println("BATTLESCREEN: ACTION_VIEW intent but no URI found")
- }
- } else {
- println("BATTLESCREEN: Not an ACTION_VIEW intent, skipping deep link processing")
- }
+ consumeAuthCallbackIntent()
}
// Check intent when screen becomes visible or when authentication state changes
@@ -2009,15 +2125,10 @@ fun BattlesScreen() {
val lifecycleOwner = activity as? LifecycleOwner
val observer = LifecycleEventObserver { _, event ->
- if (event == Lifecycle.Event.ON_RESUME && !isAuthenticated) {
- // Check intent data when activity resumes - only if it's a fresh ACTION_VIEW intent
- val intent = activity?.intent
- if (intent?.action == Intent.ACTION_VIEW) {
- intent.data?.let { uri ->
- if (uri.getQueryParameter("c") != null || uri.getQueryParameter("token") != null) {
- handleTokenFromUri(uri)
- }
- }
+ if (event == Lifecycle.Event.ON_RESUME) {
+ authBrowserLaunchInFlight = false
+ if (!isAuthenticated) {
+ consumeAuthCallbackIntent()
}
}
}
@@ -2038,45 +2149,16 @@ fun BattlesScreen() {
isAuthenticated = false
isCheckingAuth = false
// Open auth URL to get a fresh token
- val authUrl = "http://auth.nacatech.es/begin?app=443654920&redirect_uri=vbhelper://auth?token="
- val authIntent = Intent(Intent.ACTION_VIEW, Uri.parse(authUrl))
- try {
- context.startActivity(authIntent)
- println("BATTLESCREEN: Opened auth URL after token expiration: $authUrl")
- } catch (e: Exception) {
- println("BATTLESCREEN: Failed to open auth URL: ${e.message}")
- e.printStackTrace()
- }
+ openBattleAuthPage()
}
}
}
- // Also check intent when authentication state changes
- // Only process if it's a fresh ACTION_VIEW intent (deep link)
+ // Also check for callback when auth state changes.
LaunchedEffect(isAuthenticated) {
if (!isAuthenticated) {
kotlinx.coroutines.delay(200) // Small delay to ensure intent is available
- val activity = context as? ComponentActivity
- val intent = activity?.intent
- // Only process if this is a fresh deep link (ACTION_VIEW)
- if (intent?.action == Intent.ACTION_VIEW) {
- intent.data?.let { uri ->
- println("BATTLESCREEN: Re-checking ACTION_VIEW intent data - URI: $uri, scheme: ${uri.scheme}, host: ${uri.host}")
- // Handle vbhelper://auth?token= or vbhelper://auth?c= deep link
- if (uri.scheme == "vbhelper" && uri.host == "auth") {
- handleTokenFromUri(uri)
- }
- // Handle http://localhost:8080/authenticate?c= redirect
- else if ((uri.scheme == "http" || uri.scheme == "https") &&
- (uri.host == "localhost" || uri.host == "127.0.0.1" || uri.host?.contains("8080") == true)) {
- handleTokenFromUri(uri)
- }
- // Also check if there's a 'c' or 'token' parameter in any URL
- else if (uri.getQueryParameter("c") != null || uri.getQueryParameter("token") != null) {
- handleTokenFromUri(uri)
- }
- }
- }
+ consumeAuthCallbackIntent()
}
}
@@ -2084,9 +2166,8 @@ fun BattlesScreen() {
// Only check if permission is granted
LaunchedEffect(hasStoragePermission) {
if (hasStoragePermission) {
- val spriteFileManager = SpriteFileManager(context)
- if (spriteFileManager.checkSpriteFilesExist()) {
- } else {
+ val spriteFileManager = SpriteFileManager()
+ if (!spriteFileManager.checkSpriteFilesExist()) {
println("BATTLESCREEN: Sprite files not found in external storage")
}
} else {
@@ -2233,6 +2314,10 @@ fun BattlesScreen() {
color = Color.Gray,
textAlign = TextAlign.Center
)
+ Spacer(modifier = Modifier.height(16.dp))
+ Button(onClick = { openBattleAuthPage() }) {
+ Text("Open Login")
+ }
}
}
}
@@ -2267,6 +2352,12 @@ fun BattlesScreen() {
items(opponentsList) { opponent ->
Button(
onClick = {
+ val currentUserId = userId
+ if (currentUserId == null) {
+ Toast.makeText(context, "Session missing user id. Please re-authenticate.", Toast.LENGTH_SHORT).show()
+ return@Button
+ }
+
activeCardId?.let { cardId ->
selectedOpponent = opponent
// Randomly select background set (0, 1, or 2)
@@ -2281,7 +2372,7 @@ fun BattlesScreen() {
else -> 0
}
- RetrofitHelper().getPVPWinner(context, 0, userId ?: 2L, cardId, apiStage, 0, opponent.charaId, apiStage) { apiResult ->
+ RetrofitHelper().getPVPWinner(context, 0, currentUserId, cardId, apiStage, 0, opponent.charaId, apiStage) { apiResult ->
// Check if there's an existing match
when {
apiResult.status.contains("Existing match found", ignoreCase = true) -> {
@@ -2382,14 +2473,17 @@ fun BattlesScreen() {
LaunchedEffect(Unit) {
// Determine player and opponent stages
+ // activeCharacter.stage is DB stage (2=rookie, 3=champion, 4=ultimate, 5=mega)
+ // API expects stage 0-3
val playerStage = when (activeCharacter?.stage) {
- 0 -> 0 // rookie
- 1 -> 1 // champion
- 2 -> 2 // ultimate
- 3 -> 3 // mega
+ 2 -> 0 // rookie
+ 3 -> 1 // champion
+ 4 -> 2 // ultimate
+ 5 -> 3 // mega
else -> 0
}
+ // selectedOpponent.stage comes from the API and is already 0-3
val opponentStage = when (selectedOpponent?.stage) {
0 -> 0 // rookie
1 -> 1 // champion
@@ -2402,11 +2496,16 @@ fun BattlesScreen() {
RetrofitHelper().getPVPWinner(
context,
1,
- userId ?: 2L,
- activeCharacter?.name ?: "Player",
- playerStage,
- opponentStage,
- selectedOpponent?.name ?: "Opponent",
+ userId ?: run {
+ Toast.makeText(context, "Session missing user id. Please re-authenticate.", Toast.LENGTH_SHORT).show()
+ isWinnerLoaded = true
+ winnerName = "Unknown"
+ return@LaunchedEffect
+ },
+ activeCharacter?.charaId ?: "dim011_mon01",
+ playerStage,
+ 0,
+ selectedOpponent?.charaId ?: "dim011_mon01",
opponentStage
) { apiResult ->
// Winner might be empty in first call, but we can check HP values
@@ -2415,7 +2514,7 @@ fun BattlesScreen() {
// Also check winner field if it's not empty
val playerWonFromWinner = activeCardId?.let { cardId ->
- val winner = apiResult.winner ?: ""
+ val winner = apiResult.winner
if (winner.isNotEmpty()) {
if (winner.contains("|")) {
// Pipe-separated format: first part is the winner
@@ -2442,18 +2541,21 @@ fun BattlesScreen() {
println("BATTLESCREEN: Battle result (first call) - winner: '${apiResult.winner}', playerHP: ${apiResult.playerHP}, opponentHP: ${apiResult.opponentHP}, playerWonFromHP: $playerWonFromHP, playerWonFromWinner: $playerWonFromWinner, final playerWon: $playerWon")
// Store winner name for display (will be updated in cleanup call if available)
- winnerName = apiResult.winner ?: if (playerWon) "You" else "Opponent"
+ winnerName = if (apiResult.winner.isNotEmpty()) apiResult.winner else if (playerWon) "You" else "Opponent"
isWinnerLoaded = true
// Then send the cleanup call - this will have the actual winner name
RetrofitHelper().getPVPWinner(
context,
2,
- userId ?: 2L,
- activeCharacter?.name ?: "Player",
+ userId ?: run {
+ Toast.makeText(context, "Session missing user id. Please re-authenticate.", Toast.LENGTH_SHORT).show()
+ return@getPVPWinner
+ },
+ activeCharacter?.charaId ?: "dim011_mon01",
playerStage,
- opponentStage,
- selectedOpponent?.name ?: "Opponent",
+ 0,
+ selectedOpponent?.charaId ?: "dim011_mon01",
opponentStage
) { cleanupResult ->
// Update winner name from cleanup call if available
@@ -2465,7 +2567,7 @@ fun BattlesScreen() {
// Primary method: Check HP values (opponentHP <= 0 means opponent lost = player won)
// Secondary method: Check winner name (if winner doesn't match opponent, player won)
val opponentName = selectedOpponent?.name ?: ""
- val winner = cleanupResult.winner ?: ""
+ val winner = cleanupResult.winner
// Primary: HP-based determination (most reliable)
// If opponentHP <= 0, opponent is dead = player won
@@ -2602,8 +2704,13 @@ fun BattlesScreen() {
RetrofitHelper().getPVPWinner(
context,
0,
- userId ?: 2L,
- cardId,
+ userId ?: run {
+ Toast.makeText(context, "Session missing user id. Please re-authenticate.", Toast.LENGTH_SHORT).show()
+ showResumeDialog = false
+ existingMatchState = null
+ return@let
+ },
+ cardId,
apiStage,
0,
clickedOpponent.charaId,
@@ -2727,8 +2834,13 @@ fun BattlesScreen() {
RetrofitHelper().getPVPWinner(
context,
0,
- userId ?: 2L,
- cardId,
+ userId ?: run {
+ Toast.makeText(context, "Session missing user id. Please re-authenticate.", Toast.LENGTH_SHORT).show()
+ showResumeDialog = false
+ existingMatchState = null
+ return@let
+ },
+ cardId,
apiStage,
0,
opponent.charaId,
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/homeScreens/HomeScreen.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/homeScreens/HomeScreen.kt
index 6ece429..a970896 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/homeScreens/HomeScreen.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/homeScreens/HomeScreen.kt
@@ -33,8 +33,6 @@ import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData
import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData
import com.github.nacabaro.vbhelper.dtos.ItemDtos
import com.github.nacabaro.vbhelper.navigation.NavigationItems
-import com.github.nacabaro.vbhelper.screens.homeScreens.screens.BEBEmHomeScreen
-import com.github.nacabaro.vbhelper.screens.homeScreens.screens.BEDiMHomeScreen
import com.github.nacabaro.vbhelper.screens.homeScreens.screens.VBDiMHomeScreen
import com.github.nacabaro.vbhelper.screens.itemsScreen.ObtainedItemDialog
import com.github.nacabaro.vbhelper.source.StorageRepository
@@ -74,9 +72,8 @@ fun HomeScreen(
?: flowOf(emptyList())
).collectAsState(initial = emptyList())
- val vbSpecialMissions by (
+ val specialMissions by (
activeMon
- ?.takeIf { it.characterType == DeviceType.VBDevice }
?.let { chara ->
storageRepository.getSpecialMissions(chara.id)
}
@@ -144,29 +141,20 @@ fun HomeScreen(
height = cardIconData!!.cardIconHeight
)
- if (activeMon!!.isBemCard && beData != null) {
- BEBEmHomeScreen(
- activeMon = activeMon!!,
- beData = beData!!,
- transformationHistory = transformationHistory,
- contentPadding = PaddingValues(0.dp),
- cardIcon = cardIcon
- )
- } else if (!activeMon!!.isBemCard && activeMon!!.characterType == DeviceType.BEDevice && beData != null) {
- BEDiMHomeScreen(
- activeMon = activeMon!!,
- beData = beData!!,
- transformationHistory = transformationHistory,
- contentPadding = PaddingValues(0.dp),
- cardIcon = cardIcon
- )
- } else if (vbData != null) {
+ val activeCharacter = activeMon!!
+ val displaySpecialMissions = specialMissions
+
+ if (activeCharacter.characterType == DeviceType.BEDevice || vbData != null) {
VBDiMHomeScreen(
- activeMon = activeMon!!,
- vbData = vbData!!,
+ activeMon = activeCharacter,
+ vbData = vbData ?: VBCharacterData(
+ id = activeCharacter.id,
+ generation = 0,
+ totalTrophies = activeCharacter.trophies,
+ ),
transformationHistory = transformationHistory,
contentPadding = PaddingValues(0.dp),
- specialMissions = vbSpecialMissions,
+ specialMissions = displaySpecialMissions,
homeScreenController = homeScreenController,
onClickCollect = { item, currency ->
collectedItem = item
@@ -225,3 +213,5 @@ fun HomeScreen(
}
}
}
+
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/homeScreens/screens/UnifiedHomeScreen.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/homeScreens/screens/UnifiedHomeScreen.kt
new file mode 100644
index 0000000..e7db1bf
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/homeScreens/screens/UnifiedHomeScreen.kt
@@ -0,0 +1,241 @@
+package com.github.nacabaro.vbhelper.screens.homeScreens.screens
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.res.stringResource
+import com.github.nacabaro.vbhelper.R
+import com.github.nacabaro.vbhelper.screens.homeScreens.dialogs.DeleteSpecialMissionDialog
+import com.github.nacabaro.vbhelper.components.CharacterEntry
+import com.github.nacabaro.vbhelper.components.ItemDisplay
+import com.github.nacabaro.vbhelper.components.SpecialMissionsEntry
+import com.github.nacabaro.vbhelper.components.TransformationHistoryCard
+import com.github.nacabaro.vbhelper.domain.device_data.SpecialMissions
+import com.github.nacabaro.vbhelper.domain.device_data.UnifiedCharacter
+import com.github.nacabaro.vbhelper.dtos.CharacterDtos
+import com.github.nacabaro.vbhelper.dtos.ItemDtos
+import com.github.nacabaro.vbhelper.screens.homeScreens.HomeScreenControllerImpl
+import com.github.nacabaro.vbhelper.screens.itemsScreen.getIconResource
+import com.github.nacabaro.vbhelper.screens.itemsScreen.ItemsScreenControllerImpl
+import com.github.nacabaro.vbhelper.utils.BitmapData
+import java.util.Locale
+
+@Composable
+fun UnifiedHomeScreen(
+ activeMon: CharacterDtos.CharacterWithSprites,
+ cardIcon: BitmapData,
+ unifiedChar: UnifiedCharacter,
+ specialMissions: List,
+ transformationHistory: List,
+ contentPadding: PaddingValues,
+ homeScreenController: HomeScreenControllerImpl,
+ onClickCollect: (ItemDtos.PurchasedItem?, Int?) -> Unit
+) {
+ var selectedSpecialMissionId by remember { mutableStateOf(-1) }
+
+ Column(
+ modifier = Modifier
+ .padding(top = contentPadding.calculateTopPadding())
+ .verticalScroll(state = rememberScrollState())
+ ) {
+ // CORE SLOT: Always the same for VB and BE
+ Row(modifier = Modifier.fillMaxWidth()) {
+ CharacterEntry(
+ icon = BitmapData(
+ bitmap = activeMon.spriteIdle,
+ width = activeMon.spriteWidth,
+ height = activeMon.spriteHeight
+ ),
+ cardIcon = cardIcon,
+ multiplier = 8,
+ shape = androidx.compose.material.MaterialTheme.shapes.small,
+ modifier = Modifier.weight(1f).aspectRatio(1f)
+ )
+ Column(modifier = Modifier.weight(0.5f).aspectRatio(0.5f)) {
+ ItemDisplay(
+ icon = R.drawable.baseline_vitals_24,
+ textValue = activeMon.vitalPoints.toString(),
+ definition = stringResource(R.string.home_vbdim_vitals),
+ modifier = Modifier.weight(0.5f).aspectRatio(1f).padding(8.dp)
+ )
+ ItemDisplay(
+ icon = R.drawable.baseline_trophy_24,
+ textValue = activeMon.trophies.toString(),
+ definition = stringResource(R.string.home_vbdim_trophies),
+ modifier = Modifier.weight(0.5f).aspectRatio(1f).padding(8.dp)
+ )
+ }
+ }
+
+ // STATS ROW 1: mood and timing (VB timer vs BE training limit)
+ Row(modifier = Modifier.fillMaxWidth()) {
+ ItemDisplay(
+ icon = R.drawable.baseline_mood_24,
+ textValue = activeMon.mood.toString(),
+ definition = stringResource(R.string.home_vbdim_mood),
+ modifier = Modifier.weight(1f).aspectRatio(1f).padding(8.dp)
+ )
+
+ if (unifiedChar.isVB()) {
+ val transformationCountdownInHours = activeMon.transformationCountdown / 60
+ ItemDisplay(
+ icon = R.drawable.baseline_next_24,
+ textValue = when (transformationCountdownInHours) {
+ 0 -> "${activeMon.transformationCountdown} m"
+ else -> "$transformationCountdownInHours h"
+ },
+ definition = stringResource(R.string.home_vbdim_next_timer),
+ modifier = Modifier.weight(1f).aspectRatio(1f).padding(8.dp)
+ )
+ } else {
+ val timeInHours = (unifiedChar.beData?.remainingTrainingTimeInMinutes ?: 0) / 60
+ ItemDisplay(
+ icon = R.drawable.baseline_timer_24,
+ textValue = "$timeInHours h",
+ definition = "Training limit",
+ modifier = Modifier.weight(1f).aspectRatio(1f).padding(8.dp)
+ )
+ }
+
+ if (unifiedChar.isBE()) {
+ ItemDisplay(
+ icon = R.drawable.baseline_rank_24,
+ textValue = (unifiedChar.beData?.rank ?: 0).toString(),
+ definition = "Rank",
+ modifier = Modifier.weight(1f).aspectRatio(1f).padding(8.dp)
+ )
+ } else {
+ // For VB, maybe put a spacer or other stat here to keep row balance
+ }
+ }
+
+ // STATS ROW 2: Battle stats and Items
+ Row(modifier = Modifier.fillMaxWidth()) {
+ val transformationCountdownInHours = activeMon.transformationCountdown / 60
+ ItemDisplay(
+ icon = R.drawable.baseline_next_24,
+ textValue = when (transformationCountdownInHours) {
+ 0 -> "${activeMon.transformationCountdown} m"
+ else -> "$transformationCountdownInHours h"
+ },
+ definition = "Next timer",
+ modifier = Modifier.weight(1f).aspectRatio(1f).padding(8.dp)
+ )
+
+ // Battle stats (Common)
+ ItemDisplay(
+ icon = R.drawable.baseline_swords_24,
+ textValue = calculateWinPercent(activeMon.totalBattlesWon, activeMon.totalBattlesLost),
+ definition = "Total battle win %",
+ modifier = Modifier.weight(1f).aspectRatio(1f).padding(8.dp)
+ )
+ ItemDisplay(
+ icon = R.drawable.baseline_swords_24,
+ textValue = calculateWinPercent(activeMon.currentPhaseBattlesWon, activeMon.currentPhaseBattlesLost),
+ definition = "Current phase win %",
+ modifier = Modifier.weight(1f).aspectRatio(1f).padding(8.dp)
+ )
+
+ if (unifiedChar.isBE()) {
+ val beData = unifiedChar.beData
+ if (beData != null && beData.itemRemainingTime != 0) {
+ ItemDisplay(
+ icon = getIconResource(beData.itemType),
+ textValue = "${beData.itemRemainingTime} m",
+ definition = getItemTypeName(beData.itemType),
+ modifier = Modifier.weight(1f).aspectRatio(1f).padding(8.dp)
+ )
+ }
+ }
+ }
+
+ // HISTORY SLOT: Common
+ Row(modifier = Modifier.fillMaxWidth()) {
+ TransformationHistoryCard(
+ transformationHistory = transformationHistory,
+ modifier = Modifier.weight(1f).padding(8.dp)
+ )
+ }
+
+ // CONTEXTUAL BOTTOM SLOT
+ if (unifiedChar.isVB()) {
+ Row(modifier = Modifier.padding(16.dp)) {
+ Text(text = stringResource(R.string.home_vbdim_special_missions), fontSize = 24.sp)
+ }
+ for (mission in specialMissions) {
+ Row(modifier = Modifier.fillMaxWidth()) {
+ SpecialMissionsEntry(
+ specialMission = mission,
+ modifier = Modifier.weight(1f).padding(8.dp),
+ onClickMission = { missionId -> selectedSpecialMissionId = missionId },
+ onClickCollect = { homeScreenController.clearSpecialMission(selectedSpecialMissionId, onClickCollect) }
+ )
+ }
+ }
+ } else if (unifiedChar.isBE()) {
+ Row(modifier = Modifier.fillMaxWidth()) {
+ val beData = unifiedChar.beData ?: return@Column
+ ItemDisplay(
+ icon = R.drawable.baseline_health_24,
+ textValue = "+${beData.trainingHp}",
+ definition = "Training HP",
+ modifier = Modifier.weight(1f).aspectRatio(1f).padding(8.dp)
+ )
+ ItemDisplay(
+ icon = R.drawable.baseline_agility_24,
+ textValue = "+${beData.trainingBp}",
+ definition = "Training BP",
+ modifier = Modifier.weight(1f).aspectRatio(1f).padding(8.dp)
+ )
+ ItemDisplay(
+ icon = R.drawable.baseline_attack_24,
+ textValue = "+${beData.trainingAp}",
+ definition = "Training AP",
+ modifier = Modifier.weight(1f).aspectRatio(1f).padding(8.dp)
+ )
+ }
+ }
+ }
+
+ if (selectedSpecialMissionId.toInt() != -1 && unifiedChar.isVB()) {
+ DeleteSpecialMissionDialog(
+ onClickDismiss = { selectedSpecialMissionId = -1 },
+ onClickDelete = {
+ homeScreenController.clearSpecialMission(selectedSpecialMissionId, onClickCollect)
+ selectedSpecialMissionId = -1
+ }
+ )
+ }
+}
+
+private fun calculateWinPercent(won: Int, lost: Int): String {
+ return if (lost == 0) "0.00 %" else {
+ val percentage = won.toFloat() / (won + lost).toFloat()
+ String.format(Locale.getDefault(), "%.2f", percentage * 100) + " %"
+ }
+}
+
+private fun getItemTypeName(itemId: Int): String {
+ return when (itemId) {
+ ItemsScreenControllerImpl.ItemTypes.PPTraining.id -> "PP Training"
+ ItemsScreenControllerImpl.ItemTypes.HPTraining.id -> "HP Training"
+ ItemsScreenControllerImpl.ItemTypes.APTraining.id -> "AP Training"
+ ItemsScreenControllerImpl.ItemTypes.BPTraining.id -> "BP Training"
+ ItemsScreenControllerImpl.ItemTypes.AllTraining.id -> "All Training"
+ else -> ""
+ }
+}
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/itemsScreen/ItemElement.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/itemsScreen/ItemElement.kt
index e08effa..1aafa84 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/itemsScreen/ItemElement.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/itemsScreen/ItemElement.kt
@@ -8,12 +8,17 @@ import androidx.compose.foundation.layout.size
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.dp
import com.github.nacabaro.vbhelper.dtos.ItemDtos
+import com.github.nacabaro.vbhelper.domain.items.ItemType
import com.github.nacabaro.vbhelper.R
@Composable
@@ -28,6 +33,12 @@ fun ItemElement(
.aspectRatio(1f)
) {
Box(modifier = Modifier.fillMaxSize()) {
+ ItemCompatibilityBadge(
+ itemType = item.itemType,
+ modifier = Modifier
+ .align(Alignment.TopEnd)
+ .padding(8.dp)
+ )
Icon(
painter = painterResource(id = getIconResource(item.itemIcon)),
contentDescription = null,
@@ -48,3 +59,48 @@ fun ItemElement(
}
}
}
+
+@Composable
+private fun ItemCompatibilityBadge(
+ itemType: ItemType,
+ modifier: Modifier = Modifier,
+) {
+ val (label, containerColor, contentColor) = when (itemType) {
+ ItemType.VBITEM -> Triple(
+ "VB",
+ MaterialTheme.colorScheme.primaryContainer,
+ MaterialTheme.colorScheme.onPrimaryContainer,
+ )
+ ItemType.BEITEM -> Triple(
+ "BE",
+ MaterialTheme.colorScheme.tertiaryContainer,
+ MaterialTheme.colorScheme.onTertiaryContainer,
+ )
+ ItemType.UNIVERSAL -> Triple(
+ "VB+BE",
+ MaterialTheme.colorScheme.secondaryContainer,
+ MaterialTheme.colorScheme.onSecondaryContainer,
+ )
+ ItemType.SPECIALMISSION -> Triple(
+ "MISSION",
+ MaterialTheme.colorScheme.errorContainer,
+ MaterialTheme.colorScheme.onErrorContainer,
+ )
+ }
+
+ Surface(
+ modifier = modifier,
+ shape = RoundedCornerShape(12.dp),
+ color = containerColor,
+ tonalElevation = 2.dp,
+ shadowElevation = 1.dp,
+ ) {
+ Text(
+ text = label,
+ color = contentColor,
+ fontSize = 10.sp,
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
+ )
+ }
+}
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/itemsScreen/ItemsScreen.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/itemsScreen/ItemsScreen.kt
index 63ca4c5..c233134 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/itemsScreen/ItemsScreen.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/itemsScreen/ItemsScreen.kt
@@ -6,7 +6,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Tab
-import androidx.compose.material3.TabRow
+import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -34,7 +34,7 @@ fun ItemsScreen(
topBar = {
Column {
TopBanner(text = stringResource(R.string.items_title))
- TabRow(
+ PrimaryTabRow(
selectedTabIndex = selectedTabItem,
modifier = Modifier
) {
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/itemsScreen/ItemsScreenControllerImpl.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/itemsScreen/ItemsScreenControllerImpl.kt
index eb3f477..45847f3 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/itemsScreen/ItemsScreenControllerImpl.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/itemsScreen/ItemsScreenControllerImpl.kt
@@ -7,7 +7,6 @@ import com.github.nacabaro.vbhelper.domain.device_data.SpecialMissions
import com.github.nacabaro.vbhelper.database.AppDatabase
import com.github.nacabaro.vbhelper.di.VBHelper
import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData
-import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData
import com.github.nacabaro.vbhelper.dtos.ItemDtos
import com.github.nacabaro.vbhelper.utils.DeviceType
import kotlinx.coroutines.Dispatchers
@@ -51,19 +50,12 @@ class ItemsScreenControllerImpl (
val item = getItem(itemId)
val characterData = database.userCharacterDao().getCharacter(characterId)
var beCharacterData: BECharacterData? = null
- var vbCharacterData: VBCharacterData? = null
if (characterData.characterType == DeviceType.BEDevice) {
beCharacterData = database
.userCharacterDao()
.getBeData(characterId)
.firstOrNull()
-
- } else if (characterData.characterType == DeviceType.VBDevice) {
- vbCharacterData = database
- .userCharacterDao()
- .getVbData(characterId)
- .firstOrNull()
}
if (
@@ -122,8 +114,7 @@ class ItemsScreenControllerImpl (
.updateCharacter(characterData)
} else if (item.itemIcon in ItemTypes.Step8k.id .. ItemTypes.Win4.id &&
- characterData.characterType == DeviceType.VBDevice &&
- vbCharacterData != null
+ (characterData.characterType == DeviceType.VBDevice || characterData.characterType == DeviceType.BEDevice)
) {
applySpecialMission(item.itemIcon, item.itemLength, characterId)
}
@@ -180,12 +171,12 @@ class ItemsScreenControllerImpl (
.getSpecialMissions(characterId)
.first()
- var newSpecialMission = availableSpecialMissions[specialMissionSlot]
- newSpecialMission = SpecialMissions(
- id = newSpecialMission.id,
- characterId = newSpecialMission.characterId,
+ val existingMission = availableSpecialMissions.firstOrNull { it.watchId == specialMissionSlot }
+ val newSpecialMission = SpecialMissions(
+ id = existingMission?.id ?: 0,
+ characterId = characterId,
goal = specialMissionGoal,
- watchId = newSpecialMission.watchId,
+ watchId = specialMissionSlot,
progress = 0,
status = SpecialMission.Status.AVAILABLE,
timeElapsedInMinutes = 0,
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/ChooseConnectionScreen.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/ChooseConnectionScreen.kt
index d5fe878..03de44f 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/ChooseConnectionScreen.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/ChooseConnectionScreen.kt
@@ -43,7 +43,7 @@ fun ChooseConnectOption(
.padding(contentPadding)
) {
Text(
- text = "Bandai Toys = Vital Bracelet, Vital Hero, BE Bracelet",
+ text = "VitalWear/VBH",
fontSize = 14.sp,
modifier = Modifier.padding(bottom = 24.dp)
)
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/ScanScreen.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/ScanScreen.kt
index 18aab56..7ec6b52 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/ScanScreen.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/ScanScreen.kt
@@ -144,7 +144,12 @@ fun ScanScreenPreview() {
override fun flushCharacter(cardId: Long) {}
override fun onClickRead(secrets: Secrets, onComplete: ()->Unit, onMultipleCards: (List) -> Unit) {}
override fun onClickCheckCard(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: () -> Unit) {}
- override fun onClickWrite(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: (ScanScreenController.WriteResult) -> Unit) {}
+ override fun onClickWrite(
+ secrets: Secrets,
+ nfcCharacter: NfcCharacter,
+ characterId: Long?,
+ onComplete: (ScanScreenController.WriteResult) -> Unit
+ ) {}
override fun cancelRead() {}
override fun characterFromNfc(nfcCharacter: NfcCharacter, onMultipleCards: (List, NfcCharacter) -> Unit): String { return "" }
override suspend fun characterToNfc(characterId: Long): NfcCharacter? { return null }
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/ScanScreenController.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/ScanScreenController.kt
index e1d0286..23d97e1 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/ScanScreenController.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/ScanScreenController.kt
@@ -20,7 +20,12 @@ interface ScanScreenController {
fun onClickRead(secrets: Secrets, onComplete: ()->Unit, onMultipleCards: (List) -> Unit)
fun onClickCheckCard(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: () -> Unit)
- fun onClickWrite(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: (WriteResult) -> Unit)
+ fun onClickWrite(
+ secrets: Secrets,
+ nfcCharacter: NfcCharacter,
+ characterId: Long? = null,
+ onComplete: (WriteResult) -> Unit
+ )
fun cancelRead()
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/ScanScreenControllerImpl.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/ScanScreenControllerImpl.kt
index ff3506f..5f1de65 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/ScanScreenControllerImpl.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/ScanScreenControllerImpl.kt
@@ -12,8 +12,10 @@ import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.lifecycle.lifecycleScope
import com.github.cfogrady.vbnfc.TagCommunicator
+import com.github.cfogrady.vitalwear.protos.Character
import com.github.cfogrady.vbnfc.be.BENfcCharacter
import com.github.cfogrady.vbnfc.data.NfcCharacter
+import com.github.cfogrady.vbnfc.data.DeviceType as NfcDeviceTypeId
import com.github.cfogrady.vbnfc.vb.VBNfcCharacter
import com.github.nacabaro.vbhelper.ActivityLifecycleListener
import com.github.nacabaro.vbhelper.domain.card.Card
@@ -22,13 +24,11 @@ import com.github.nacabaro.vbhelper.screens.scanScreen.converters.FromNfcConvert
import com.github.nacabaro.vbhelper.screens.scanScreen.converters.ToNfcConverter
import com.github.nacabaro.vbhelper.source.VitalWearCharacterExporter
import com.github.nacabaro.vbhelper.source.VitalWearCharacterImporter
-import com.github.nacabaro.vbhelper.transfer.ExportFormat
import com.github.nacabaro.vbhelper.source.getCryptographicTransformerMap
import com.github.nacabaro.vbhelper.source.isMissingSecrets
import com.github.nacabaro.vbhelper.source.proto.Secrets
-import com.github.nacabaro.vbhelper.transfer.TransferTarget
-import com.github.nacabaro.vbhelper.transfer.TransferTransport
import com.github.nacabaro.vbhelper.transfer.hce.VitalWearHceReaderClient
+import com.github.nacabaro.vbhelper.utils.DeviceType
import com.github.nacabaro.vbhelper.R
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
@@ -57,6 +57,7 @@ class ScanScreenControllerImpl(
private val _transferStatusFlow = MutableStateFlow(null)
private var lastScannedCharacter: NfcCharacter? = null
private var lastRequestedCharacterId: Long? = null
+ private var lastWriteCharacterId: Long? = null
private val nfcAdapter: NfcAdapter
private val isHandlingTag = AtomicBoolean(false)
private var lastTagId: ByteArray? = null
@@ -122,7 +123,6 @@ class ScanScreenControllerImpl(
val options = Bundle()
options.putInt(NfcAdapter.EXTRA_READER_PRESENCE_CHECK_DELAY, 250)
val flags = NfcAdapter.FLAG_READER_NFC_A or
- NfcAdapter.FLAG_READER_NFC_B or
NfcAdapter.FLAG_READER_NO_PLATFORM_SOUNDS or
NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK
runCatching {
@@ -162,177 +162,153 @@ class ScanScreenControllerImpl(
componentActivity.getString(R.string.scan_error_generic)
}
},
- isoDepHandler = { isoDep ->
- val application = componentActivity.applicationContext as VBHelper
- val importer = VitalWearCharacterImporter(application.container.db, application.container.transferSeenDao)
- try {
- var importResult: VitalWearCharacterImporter.ImportResult? = null
- val moved = VitalWearHceReaderClient(isoDep).moveCharacterFromWatch { character ->
- val result = importer.importCharacter(character)
- importResult = result
- result.success
- }
- onComplete.invoke()
- if (moved) {
- importResult?.message ?: componentActivity.getString(R.string.scan_sent_character_success)
- } else {
- importResult?.message
- ?: "VitalWear import was rejected. Source character remains on the watch."
- }
- } catch (readError: Exception) {
- Log.e("NFC_READ", "HCE read failed; watch may be armed as destination", readError)
- onComplete.invoke()
- "No source character detected on watch. If the watch is waiting to receive, use VBH to Watch."
- }
- }
+ isoDepHandler = { isoDep ->
+ val application = componentActivity.applicationContext as VBHelper
+ val importer = VitalWearCharacterImporter(application.container.db, application.container.transferSeenDao)
+ try {
+ // ISO-DEP/HCE route: VitalWear characters are always imported as BE device type.
+ var importResult: VitalWearCharacterImporter.ImportResult? = null
+ val moved = VitalWearHceReaderClient(isoDep).moveCharacterFromWatch { character ->
+ // Force imported device type to BE since source is VitalWear HCE (BE only).
+ val result = importer.importCharacter(character, forcedDeviceType = DeviceType.BEDevice)
+ importResult = result
+ result.success
+ }
+ onComplete.invoke()
+ if (moved) {
+ importResult?.message ?: componentActivity.getString(R.string.scan_sent_character_success)
+ } else {
+ importResult?.message
+ ?: "VitalWear import was rejected. Source character remains on the watch."
+ }
+ } catch (readError: Exception) {
+ Log.e("NFC_READ", "HCE read failed; watch may be armed as destination", readError)
+ onComplete.invoke()
+ "No source character detected on watch. If the watch is waiting to receive, use VBH to Watch."
+ }
+ }
)
}
// ---- Write (phone sends character TO watch or bracelet) ------------------------
- override fun onClickWrite(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: (ScanScreenController.WriteResult) -> Unit) {
+ override fun onClickWrite(
+ secrets: Secrets,
+ nfcCharacter: NfcCharacter,
+ characterId: Long?,
+ onComplete: (ScanScreenController.WriteResult) -> Unit
+ ) {
_detectedTransportFlow.value = DetectedTransport.UNKNOWN
setTransferStatus(R.string.scan_transfer_waiting_tap)
handleTag(
secrets,
- nfcAHandler = { tagCommunicator ->
- try {
- val initialSlotState = readNfcASlotState(tagCommunicator)
- if (initialSlotState.isFull()) {
- onComplete.invoke(ScanScreenController.WriteResult.BLOCKED_DEVICE_FULL)
- return@handleTag componentActivity.getString(R.string.scan_target_device_full)
- }
+ nfcAHandler = { tagCommunicator ->
+ try {
+ val targetHeader = runCatching { tagCommunicator.readTargetHeader() }.getOrNull()
+ val normalizedDeviceType = when (targetHeader?.deviceTypeId) {
+ 512u.toUShort() -> NfcDeviceTypeId.VitalSeriesDeviceType
+ 768u.toUShort() -> NfcDeviceTypeId.VitalCharactersDeviceType
+ 1024u.toUShort() -> NfcDeviceTypeId.VitalBraceletBEDeviceType
+ else -> targetHeader?.deviceTypeId
+ }
+ val forcedProfile = when (normalizedDeviceType) {
+ NfcDeviceTypeId.VitalBraceletBEDeviceType -> DeviceType.BEDevice
+ else -> DeviceType.VBDevice
+ }
+ Log.i(
+ "NFC_WRITE_A",
+ "Target NFC-A header rawDeviceType=${targetHeader?.deviceTypeId}, normalizedDeviceType=$normalizedDeviceType, dimId=${targetHeader?.getDimId()}, forcedProfile=$forcedProfile"
+ )
- val migrationCheck = verifyActiveToBackupMigration(tagCommunicator, initialSlotState)
- if (!migrationCheck.canProceed) {
- onComplete.invoke(ScanScreenController.WriteResult.COPIED)
- return@handleTag migrationCheck.message
- }
+ // NFC-A route: choose export profile by target bracelet type.
+ // - Vital Series / Vital Characters => VB profile
+ // - Vital Bracelet BE => BE profile
+ val nfcACharacter = if (characterId != null) {
+ runBlocking {
+ ToNfcConverter(componentActivity = componentActivity)
+ .characterToNfc(characterId, forcedProfile)
+ }
+ } else {
+ nfcCharacter
+ }
- when (nfcCharacter) {
- is VBNfcCharacter -> tagCommunicator.sendCharacter(nfcCharacter)
- is BENfcCharacter -> tagCommunicator.sendCharacter(nfcCharacter)
- }
- onComplete.invoke(ScanScreenController.WriteResult.MOVE_CONFIRMED)
- componentActivity.getString(R.string.scan_sent_character_success)
- } catch (writeError: Throwable) {
- Log.e("NFC_WRITE_A", "NFC-A write failed; trying alternate payload format", writeError)
- val characterId = lastRequestedCharacterId
- val alternateFormat = when (nfcCharacter) {
- is BENfcCharacter -> ExportFormat.VB
- is VBNfcCharacter -> ExportFormat.BE
- else -> null
- }
+ val initialSlotState = readNfcASlotState(tagCommunicator)
+ if (initialSlotState.isFull()) {
+ onComplete.invoke(ScanScreenController.WriteResult.BLOCKED_DEVICE_FULL)
+ return@handleTag componentActivity.getString(R.string.scan_target_device_full)
+ }
- val alternateSent = if (characterId != null && alternateFormat != null) {
- runCatching {
- val alternateCharacter = runBlocking {
- ToNfcConverter(componentActivity = componentActivity).characterToNfc(
- characterId = characterId,
- target = TransferTarget.REAL_BRACELET,
- transport = TransferTransport.NFCA,
- forcedFormat = alternateFormat,
- )
- }
- when (alternateCharacter) {
- is VBNfcCharacter -> tagCommunicator.sendCharacter(alternateCharacter)
- is BENfcCharacter -> tagCommunicator.sendCharacter(alternateCharacter)
- }
- true
- }.getOrElse { alternateError ->
- Log.e("NFC_WRITE_A", "Alternate payload write failed", alternateError)
- false
- }
- } else {
- false
- }
+ val migrationCheck = verifyActiveToBackupMigration(tagCommunicator, initialSlotState)
+ if (!migrationCheck.canProceed) {
+ onComplete.invoke(ScanScreenController.WriteResult.COPIED)
+ return@handleTag migrationCheck.message
+ }
- if (alternateSent) {
- onComplete.invoke(ScanScreenController.WriteResult.MOVE_CONFIRMED)
- "Sent character after converting payload for the tapped bracelet."
- } else {
- Log.e("NFC_WRITE_A", "NFC-A write failed; attempting opposite direction fallback", writeError)
- runCatching {
- val receivedCharacter = tagCommunicator.receiveCharacter()
- val fallbackMessage = importCharacterFromNfcAFallback(receivedCharacter)
- onComplete.invoke(ScanScreenController.WriteResult.COPIED)
- fallbackMessage
- }.getOrElse { readFallbackError ->
- Log.e("NFC_WRITE_A", "NFC-A opposite-direction fallback failed", readFallbackError)
- componentActivity.getString(R.string.scan_error_generic)
- }
- }
- }
- },
- isoDepHandler = { isoDep ->
- val characterId = lastRequestedCharacterId
- ?: throw IllegalStateException("No character id available for VitalWear HCE write")
- val application = componentActivity.applicationContext as VBHelper
- val hceClient = VitalWearHceReaderClient(isoDep)
- try {
- val proto = runBlocking {
- VitalWearCharacterExporter(application.container.db)
- .buildCharacterProto(characterId)
- }
- hceClient.sendCharacterToWatch(proto)
- onComplete.invoke(ScanScreenController.WriteResult.MOVE_CONFIRMED)
- componentActivity.getString(R.string.scan_sent_character_success)
- } catch (writeError: Throwable) {
- Log.e("NFC_WRITE_HCE", "HCE write failed; attempting opposite direction", writeError)
- runCatching {
- var importResult: VitalWearCharacterImporter.ImportResult? = null
- val moved = hceClient.moveCharacterFromWatch { character ->
- val result = VitalWearCharacterImporter(application.container.db, application.container.transferSeenDao).importCharacter(character)
- importResult = result
- result.success
- }
- onComplete.invoke(ScanScreenController.WriteResult.COPIED)
- if (moved) {
- importResult?.message
- ?: "Detected source on watch and imported it instead of sending."
- } else {
- importResult?.message
- ?: "Watch is in source mode, but import was rejected."
- }
- }.getOrElse { readFallbackError ->
- Log.e("NFC_WRITE_HCE", "HCE opposite-direction fallback failed", readFallbackError)
- componentActivity.getString(R.string.scan_error_generic)
- }
- }
- }
+ // Send with VB profile (which was forced above). Real bracelets only accept VBNfcCharacter.
+ return@handleTag try {
+ when (nfcACharacter) {
+ is VBNfcCharacter -> tagCommunicator.sendCharacter(nfcACharacter)
+ is BENfcCharacter -> {
+ // This should not happen since we forced VBDevice above, but fail if it does.
+ Log.e("NFC_WRITE_A", "BENfcCharacter sent to NFC-A (real bracelet) — protocol error")
+ throw IllegalStateException("NFC-A forced VBDevice but received BENfcCharacter")
+ }
+ }
+ onComplete.invoke(ScanScreenController.WriteResult.MOVE_CONFIRMED)
+ componentActivity.getString(R.string.scan_sent_character_success)
+ } catch (deviceTypeMismatch: Exception) {
+ // Device type mismatch on NFC-A is terminal for this tap.
+ if (deviceTypeMismatch.message?.contains("Character doesn't match device type") == true) {
+ Log.e("NFC_WRITE_A", "Device type mismatch on NFC-A with selected transfer profile", deviceTypeMismatch)
+ onComplete.invoke(ScanScreenController.WriteResult.COPIED)
+ "Transfer failed: bracelet rejected character profile for this tap. Retry and keep bracelet steady."
+ } else {
+ Log.e("NFC_WRITE_A", "NFC-A write failed", deviceTypeMismatch)
+ onComplete.invoke(ScanScreenController.WriteResult.COPIED)
+ throw deviceTypeMismatch
+ }
+ }
+ } catch (writeError: Throwable) {
+ Log.e("NFC_WRITE_A", "NFC-A setup failed", writeError)
+ onComplete.invoke(ScanScreenController.WriteResult.COPIED)
+ throw writeError
+ }
+ },
+ isoDepHandler = { isoDep ->
+ val characterId = lastRequestedCharacterId
+ ?: throw IllegalStateException("No character id available for VitalWear HCE write")
+ val application = componentActivity.applicationContext as VBHelper
+ val hceClient = VitalWearHceReaderClient(isoDep)
+ try {
+ // ISO-DEP/HCE route: VitalWear always uses BE profile (enforced).
+ // Create protobuf with forced BE device type for correct serialization.
+ val proto = runBlocking {
+ VitalWearCharacterExporter(application.container.db)
+ .buildCharacterProto(characterId, forcedTransferProfile = DeviceType.BEDevice)
+ }
+ if (proto.characterStats.deviceType != Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_BE) {
+ throw IllegalStateException("VitalWear HCE export must use BE transfer profile")
+ }
+ // Send with status confirmation: only mark MOVE_CONFIRMED after watch confirms import.
+ val confirmedMove = hceClient.sendCharacterToWatchAndConfirm(proto)
+ if (confirmedMove) {
+ onComplete.invoke(ScanScreenController.WriteResult.MOVE_CONFIRMED)
+ componentActivity.getString(R.string.scan_sent_character_success)
+ } else {
+ // Transfer acknowledging but watch import failed or did not confirm.
+ onComplete.invoke(ScanScreenController.WriteResult.COPIED)
+ "Transfer sent but watch did not confirm import. Source was kept in VBH."
+ }
+ } catch (writeError: Throwable) {
+ Log.e("NFC_WRITE_HCE", "HCE write failed", writeError)
+ onComplete.invoke(ScanScreenController.WriteResult.COPIED)
+ throw writeError
+ }
+ }
)
}
- private fun importCharacterFromNfcAFallback(receivedCharacter: NfcCharacter): String {
- val application = componentActivity.applicationContext as VBHelper
- val fromNfcConverter = FromNfcConverter(componentActivity)
- val candidateCards = application.container.db
- .cardDao()
- .getCardByCardId(receivedCharacter.dimId.toInt())
- if (candidateCards.isEmpty()) {
- return "Detected source character on bracelet, but matching card is not imported in VBHelper."
- }
-
- val preferredCardId = receivedCharacter.appReserved2
- .firstOrNull()
- ?.toLong()
- ?.takeIf { it > 0L }
- val preferredCard = preferredCardId?.let { id ->
- candidateCards.firstOrNull { it.id == id }
- }
-
- val selectedCard = when {
- preferredCard != null -> preferredCard
- candidateCards.size == 1 -> candidateCards.single()
- else -> {
- lastScannedCharacter = receivedCharacter
- return "Detected source character, but multiple matching cards exist. Use Read and select the card."
- }
- }
-
- return fromNfcConverter.addCharacterUsingCard(receivedCharacter, selectedCard.id)
- }
// ---- Check card (NFC-A only; ISO-DEP watches don't need a DIM prep) -----------
@@ -407,7 +383,6 @@ class ScanScreenControllerImpl(
// Work around for some broken Nfc firmware implementations that poll the card too fast
options.putInt(NfcAdapter.EXTRA_READER_PRESENCE_CHECK_DELAY, 250)
val flags = NfcAdapter.FLAG_READER_NFC_A or
- NfcAdapter.FLAG_READER_NFC_B or
NfcAdapter.FLAG_READER_NO_PLATFORM_SOUNDS or
NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK
val sessionId = readerSessionCounter.incrementAndGet()
@@ -450,18 +425,19 @@ class ScanScreenControllerImpl(
setTransferStatus(R.string.scan_transfer_detected_keep_tap)
// Detect transport once per tap and lock to that route for this transfer.
+ // Real Bandai bracelets route through NFC-A; VitalWear routes through ISO-DEP/HCE.
val isoDep = IsoDep.get(tag)
- val hasIsoDepRoute = isoDep != null && isoDepHandler != null
val hasNfcARoute = NfcA.get(tag) != null
+ val hasIsoDepHandler = isoDep != null && isoDepHandler != null
+ val confirmedVitalWear = if (hasIsoDepHandler) {
+ runCatching { isVitalWearHceTarget(isoDep) }.getOrDefault(false)
+ } else {
+ false
+ }
try {
- // Prefer ISO-DEP whenever present so HCE sessions do not accidentally fall through to NFC-A.
- if (hasIsoDepRoute) {
+ if (hasIsoDepHandler && confirmedVitalWear) {
val isoDepTarget = isoDep
val isoDepAction = isoDepHandler
- val confirmedVitalWear = runCatching { isVitalWearHceTarget(isoDepTarget) }.getOrDefault(false)
- if (!confirmedVitalWear) {
- Log.w("NFC_HCE", "IsoDep tag did not confirm VitalWear AID; attempting ISO-DEP handler without NFC-A fallback")
- }
try {
_detectedTransportFlow.value = DetectedTransport.ISO_DEP
isoDepTarget.connect()
@@ -480,7 +456,19 @@ class ScanScreenControllerImpl(
setTransferStatus(R.string.scan_transfer_failed_try_again)
}
}
+ } else if (hasIsoDepHandler && !confirmedVitalWear && !hasNfcARoute) {
+ // Hard-fail only when IsoDep is present without any NFC-A fallback.
+ // This keeps HCE safety while allowing real bracelets to route via NFC-A.
+ Log.w("NFC_ROUTE", "IsoDep present but VitalWear AID not confirmed and no NFC-A route; cancelling transfer")
+ _detectedTransportFlow.value = DetectedTransport.UNKNOWN
+ componentActivity.runOnUiThread {
+ Toast.makeText(componentActivity, "VitalWear HCE not detected. Transfer cancelled.", Toast.LENGTH_SHORT).show()
+ setTransferStatus(R.string.scan_transfer_failed_try_again)
+ }
} else if (hasNfcARoute) {
+ if (hasIsoDepHandler && !confirmedVitalWear) {
+ Log.i("NFC_ROUTE", "IsoDep present but VitalWear AID not confirmed; routing to NFC-A fallback")
+ }
if (!handleNfcATag(tag, secrets, nfcAHandler)) {
componentActivity.runOnUiThread {
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_tag_not_vb), Toast.LENGTH_SHORT).show()
@@ -488,6 +476,9 @@ class ScanScreenControllerImpl(
}
}
} else {
+ if (hasIsoDepHandler && !confirmedVitalWear) {
+ Log.w("NFC_ROUTE", "IsoDep tag does not expose VitalWear HCE AID and has no NFC-A fallback")
+ }
_detectedTransportFlow.value = DetectedTransport.UNKNOWN
componentActivity.runOnUiThread {
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_tag_not_vb), Toast.LENGTH_SHORT).show()
@@ -677,10 +668,10 @@ class ScanScreenControllerImpl(
val migrationVerified = afterState.backupPresent == true ||
(beforeState.count != null && afterState.count != null && afterState.count >= beforeState.count)
if (!migrationVerified) {
- return SlotMigrationCheck(
- canProceed = false,
- message = "Transfer blocked. Backup slot could not be verified after migration."
- )
+ // Some real bracelets do not expose stable occupancy signals through the current
+ // reflection-based probing. In that case, prefer compatibility over false blocks.
+ Log.w("NFC_A", "Could not verify active->backup migration from slot signals; proceeding with write")
+ return SlotMigrationCheck(canProceed = true, message = "")
}
return SlotMigrationCheck(canProceed = true, message = "")
@@ -746,11 +737,7 @@ class ScanScreenControllerImpl(
override suspend fun characterToNfc(characterId: Long): NfcCharacter {
lastRequestedCharacterId = characterId
- val character = ToNfcConverter(componentActivity = componentActivity).characterToNfc(
- characterId = characterId,
- target = TransferTarget.REAL_BRACELET,
- transport = TransferTransport.NFCA,
- )
+ val character = ToNfcConverter(componentActivity = componentActivity).characterToNfc(characterId)
Log.d("CharacterType", character.toString())
return character
}
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/TransferHaptics.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/TransferHaptics.kt
new file mode 100644
index 0000000..85ac89f
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/TransferHaptics.kt
@@ -0,0 +1,51 @@
+package com.github.nacabaro.vbhelper.screens.scanScreen
+
+import android.content.Context
+import android.os.Build
+import android.os.VibrationEffect
+import android.os.Vibrator
+import android.os.VibratorManager
+import timber.log.Timber
+
+class TransferHaptics(context: Context) {
+ private val vibrator: Vibrator? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ val manager = context.getSystemService(VibratorManager::class.java)
+ manager?.defaultVibrator
+ } else {
+ @Suppress("DEPRECATION")
+ context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
+ }
+
+ fun onTransferStart() {
+ vibrate(VibrationEffect.createOneShot(100L, VibrationEffect.DEFAULT_AMPLITUDE))
+ }
+
+ fun onTransferProgress() {
+ vibrate(VibrationEffect.createOneShot(50L, VibrationEffect.DEFAULT_AMPLITUDE))
+ }
+
+ fun onTransferComplete() {
+ vibrate(VibrationEffect.createWaveform(longArrayOf(0L, 100L, 80L, 150L), -1))
+ }
+
+ fun onTransferFailed() {
+ vibrate(VibrationEffect.createOneShot(250L, VibrationEffect.DEFAULT_AMPLITUDE))
+ }
+
+ fun onDeviceDetected() {
+ vibrate(VibrationEffect.createOneShot(80L, VibrationEffect.DEFAULT_AMPLITUDE))
+ }
+
+ private fun vibrate(effect: VibrationEffect) {
+ val deviceVibrator = vibrator ?: return
+ if (!deviceVibrator.hasVibrator()) {
+ return
+ }
+ runCatching {
+ deviceVibrator.vibrate(effect)
+ }.onFailure { error ->
+ Timber.w(error, "Transfer haptics vibration skipped")
+ }
+ }
+}
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/converters/FromNfcConverter.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/converters/FromNfcConverter.kt
index 6f20581..6f85764 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/converters/FromNfcConverter.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/converters/FromNfcConverter.kt
@@ -11,8 +11,6 @@ import com.github.nacabaro.vbhelper.domain.device_data.SpecialMissions
import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter
import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData
import com.github.nacabaro.vbhelper.domain.device_data.VitalsHistory
-import com.github.nacabaro.vbhelper.transfer.CharacterTransferPolicyResolver
-import com.github.nacabaro.vbhelper.transfer.ExportFormat
import com.github.nacabaro.vbhelper.utils.DeviceType
import java.util.GregorianCalendar
@@ -22,7 +20,6 @@ class FromNfcConverter (
private val application = componentActivity.applicationContext as VBHelper
private val database = application.container.db
private val transferSeenDao = application.container.transferSeenDao
- private val transferPolicyResolver = CharacterTransferPolicyResolver(database.characterTransferPolicyDao())
fun addCharacterUsingCard(
@@ -130,6 +127,8 @@ class FromNfcConverter (
characterId = characterId,
nfcCharacter = nfcCharacter
)
+ // Keep a VB profile alongside BE stats for later VB-target exports.
+ addVbCharacterProfileFromBe(characterId, nfcCharacter)
} else if (nfcCharacter is VBNfcCharacter) {
addVbCharacterToDatabase(
characterId = characterId,
@@ -137,18 +136,6 @@ class FromNfcConverter (
)
}
- database
- .characterTransferPolicyDao()
- .upsert(
- transferPolicyResolver.policyForNfcaImport(
- characterId = characterId,
- observedFormat = when (nfcCharacter) {
- is BENfcCharacter -> ExportFormat.BE
- else -> ExportFormat.VB
- }
- )
- )
-
addTransformationHistoryToDatabase(
characterId = characterId,
nfcCharacter = nfcCharacter,
@@ -248,8 +235,8 @@ class FromNfcConverter (
itemType = nfcCharacter.itemType.toInt(),
itemMultiplier = nfcCharacter.itemMultiplier.toInt(),
itemRemainingTime = nfcCharacter.itemRemainingTime.toInt(),
- otp0 = "", //nfcCharacter.value!!.otp0.toString(),
- otp1 = "", //nfcCharacter.value!!.otp1.toString(),
+ otp0 = nfcCharacter.getOtp0().joinToString("") { "%02x".format(it) },
+ otp1 = nfcCharacter.getOtp1().joinToString("") { "%02x".format(it) },
minorVersion = nfcCharacter.characterCreationFirmwareVersion.minorVersion.toInt(),
majorVersion = nfcCharacter.characterCreationFirmwareVersion.majorVersion.toInt(),
)
@@ -259,6 +246,42 @@ class FromNfcConverter (
.insertBECharacterData(extraCharacterData)
}
+ private fun addVbCharacterProfileFromBe(
+ characterId: Long,
+ nfcCharacter: BENfcCharacter,
+ ) {
+ val historyCount = nfcCharacter.transformationHistory.count { it.toCharIndex.toInt() != 255 }
+ val vbData = VBCharacterData(
+ id = characterId,
+ generation = (historyCount - 1).coerceAtLeast(0),
+ totalTrophies = nfcCharacter.trophies.toInt(),
+ )
+ database.userCharacterDao().insertVBCharacterData(vbData)
+
+ // Populate empty mission slots for BE characters for UI consistency
+ addEmptyMissionSlotsForBe(characterId)
+ }
+
+ private fun addEmptyMissionSlotsForBe(characterId: Long) {
+ // Create 4 empty mission slots (one for each watch position 0-3)
+ val emptyMissions = (0..3).map { watchId ->
+ SpecialMissions(
+ characterId = characterId,
+ watchId = watchId,
+ missionType = com.github.cfogrady.vbnfc.data.MissionType.NONE,
+ status = com.github.cfogrady.vbnfc.data.MissionStatus.UNAVAILABLE,
+ goal = 0,
+ progress = 0,
+ timeElapsedInMinutes = 0,
+ timeLimitInMinutes = 0,
+ )
+ }
+
+ database
+ .userCharacterDao()
+ .insertSpecialMissions(*emptyMissions.toTypedArray())
+ }
+
private fun addVitalsHistoryToDatabase(
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/converters/ToNfcConverter.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/converters/ToNfcConverter.kt
index 86d3d52..bb105dd 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/converters/ToNfcConverter.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/converters/ToNfcConverter.kt
@@ -15,10 +15,6 @@ import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData
import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter
import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData
import com.github.nacabaro.vbhelper.dtos.CharacterDtos
-import com.github.nacabaro.vbhelper.transfer.CharacterTransferPolicyResolver
-import com.github.nacabaro.vbhelper.transfer.ExportFormat
-import com.github.nacabaro.vbhelper.transfer.TransferTarget
-import com.github.nacabaro.vbhelper.transfer.TransferTransport
import com.github.nacabaro.vbhelper.utils.DeviceType
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
@@ -30,19 +26,27 @@ class ToNfcConverter(
companion object {
private const val MIN_NFC_TRANSFORMATION_YEAR = 2021
private const val MAX_NFC_TRANSFORMATION_YEAR = 2035
+
+ internal fun shouldEncodeAsBem(
+ forcedProfile: DeviceType?,
+ storedDeviceType: DeviceType,
+ ): Boolean {
+ return when (forcedProfile) {
+ DeviceType.BEDevice -> true
+ DeviceType.VBDevice -> false
+ else -> storedDeviceType == DeviceType.BEDevice
+ }
+ }
}
private val application: VBHelper = componentActivity.applicationContext as VBHelper
private val database: AppDatabase = application.container.db
- private val transferPolicyResolver = CharacterTransferPolicyResolver(database.characterTransferPolicyDao())
suspend fun characterToNfc(
characterId: Long,
- target: TransferTarget = TransferTarget.REAL_BRACELET,
- transport: TransferTransport = TransferTransport.NFCA,
- forcedFormat: ExportFormat? = null,
+ forcedProfile: DeviceType? = null,
): NfcCharacter {
val app = componentActivity.applicationContext as VBHelper
val database = app.container.db
@@ -54,20 +58,14 @@ class ToNfcConverter(
val characterInfo = database
.characterDao()
.getCharacterInfo(userCharacter.charId)
- val card = database
- .cardDao()
- .getCardByCharacterIdSync(characterId)
- ?: error("Card not found for character $characterId")
- val exportFormat = forcedFormat ?: transferPolicyResolver.resolveExportFormat(
- characterId = characterId,
- characterType = userCharacter.characterType,
- transport = transport,
- target = target,
- isBemCard = card.isBEm,
- )
+ // Forced profile overrides stored device type for transfer semantics:
+ // - NFC-A route forces VBDevice (real bracelets only understand VB)
+ // - HCE route forces BEDevice (VitalWear only sends/accepts BE)
+ // Otherwise, use the character's own stored type.
+ val shouldEncodeAsBem = shouldEncodeAsBem(forcedProfile, userCharacter.characterType)
- return if (exportFormat == ExportFormat.BE)
+ return if (shouldEncodeAsBem)
nfcToBENfc(characterId, characterInfo, userCharacter)
else
nfcToVBNfc(characterId, characterInfo, userCharacter)
@@ -238,6 +236,18 @@ class ToNfcConverter(
val paddedTransformationArray = generateTransformationHistory(characterId)
+ // Convert hex-encoded OTP strings back to ByteArrays; use empty arrays if not stored
+ val otp0ByteArray = if (beData.otp0.isNotEmpty()) {
+ beData.otp0.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
+ } else {
+ ByteArray(8) // Default to 8-byte zero array if not available
+ }
+ val otp1ByteArray = if (beData.otp1.isNotEmpty()) {
+ beData.otp1.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
+ } else {
+ ByteArray(8) // Default to 8-byte zero array if not available
+ }
+
val nfcData = BENfcCharacter(
dimId = characterInfo.cardId.toUShort(),
charIndex = characterInfo.charId.toUShort(),
@@ -278,8 +288,8 @@ class ToNfcConverter(
itemType = beData.itemType.toByte(),
itemMultiplier = beData.itemMultiplier.toByte(),
itemRemainingTime = beData.itemRemainingTime.toByte(),
- otp0 = byteArrayOf(8),
- otp1 = byteArrayOf(8),
+ otp0 = otp0ByteArray,
+ otp1 = otp1ByteArray,
characterCreationFirmwareVersion = FirmwareVersion(
minorVersion = beData.minorVersion.toByte(),
majorVersion = beData.majorVersion.toByte()
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/screens/ActionScreen.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/screens/ActionScreen.kt
index 8d58f95..8f2a1f5 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/screens/ActionScreen.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/screens/ActionScreen.kt
@@ -1,54 +1,54 @@
package com.github.nacabaro.vbhelper.screens.scanScreen.screens
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.Button
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
-import com.github.nacabaro.vbhelper.components.TopBanner
-import com.github.nacabaro.vbhelper.R
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.platform.LocalContext
+import com.github.nacabaro.vbhelper.screens.scanScreen.TransferHaptics
+import com.github.nacabaro.vbhelper.utils.ImageBitmapData
@Composable
fun ActionScreen(
topBannerText: String,
+ detectedTransportMessage: String?,
transferStatusMessage: String?,
+ characterPreview: ImageBitmapData? = null,
onClickCancel: () -> Unit,
+ isTransferring: Boolean = true,
) {
- Scaffold (
- topBar = {
- TopBanner(
- text = topBannerText,
- onBackClick = onClickCancel
- )
- }
- ) { innerPadding ->
- Column (
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.Center,
- modifier = Modifier
- .padding(innerPadding)
- .fillMaxSize()
- ) {
- Text(stringResource(R.string.action_place_near_reader))
- if (!transferStatusMessage.isNullOrBlank()) {
- Text(
- text = transferStatusMessage,
- modifier = Modifier.padding(top = 8.dp, start = 16.dp, end = 16.dp)
- )
- }
- Button(
- onClick = onClickCancel,
- modifier = Modifier.padding(16.dp)
- ) {
- Text(stringResource(R.string.action_cancel))
- }
+ val context = LocalContext.current
+ val transferHaptics = remember(context) { TransferHaptics(context) }
+
+ LaunchedEffect(transferStatusMessage) {
+ if (transferStatusMessage != null && transferStatusMessage.contains("progress", ignoreCase = true)) {
+ transferHaptics.onTransferProgress()
}
}
+
+ if (transferStatusMessage?.contains("complete", ignoreCase = true) == true) {
+ TransferCompleteScreen(
+ topBannerText = topBannerText,
+ resultMessage = transferStatusMessage,
+ onClickOk = onClickCancel,
+ isSuccess = !transferStatusMessage.contains("fail", ignoreCase = true)
+ )
+ } else if (isTransferring) {
+ TransferAnimationScreen(
+ topBannerText = topBannerText,
+ detectedTransportMessage = detectedTransportMessage,
+ transferStatusMessage = transferStatusMessage,
+ characterPreview = characterPreview,
+ onClickCancel = onClickCancel,
+ isTransferring = true
+ )
+ } else {
+ TransferAnimationScreen(
+ topBannerText = topBannerText,
+ detectedTransportMessage = detectedTransportMessage,
+ transferStatusMessage = transferStatusMessage,
+ characterPreview = characterPreview,
+ onClickCancel = onClickCancel,
+ isTransferring = false
+ )
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/screens/ReadingScreen.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/screens/ReadingScreen.kt
index 3dd2d67..3b08d90 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/screens/ReadingScreen.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/screens/ReadingScreen.kt
@@ -3,17 +3,21 @@ package com.github.nacabaro.vbhelper.screens.scanScreen.screens
import android.util.Log
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import com.github.nacabaro.vbhelper.ActivityLifecycleListener
import com.github.nacabaro.vbhelper.domain.card.Card
import com.github.nacabaro.vbhelper.screens.cardScreen.ChooseCard
+import com.github.nacabaro.vbhelper.screens.scanScreen.DetectedTransport
import com.github.nacabaro.vbhelper.screens.scanScreen.SCAN_SCREEN_ACTIVITY_LIFECYCLE_LISTENER
import com.github.nacabaro.vbhelper.screens.scanScreen.ScanScreenController
+import com.github.nacabaro.vbhelper.screens.scanScreen.TransferHaptics
import com.github.nacabaro.vbhelper.R
private const val TAG = "ReadingScreen"
@@ -24,8 +28,17 @@ fun ReadingScreen(
onCancel: () -> Unit,
onComplete: () -> Unit
) {
+ val context = LocalContext.current
+ val transferHaptics = remember(context) { TransferHaptics(context) }
val secrets by scanScreenController.secretsFlow.collectAsState(null)
val transferStatus by scanScreenController.transferStatusFlow.collectAsState(null)
+ val detectedTransport by scanScreenController.detectedTransportFlow.collectAsState()
+
+ val transportMessage = when (detectedTransport) {
+ DetectedTransport.NFC_A -> stringResource(R.string.scan_transport_bandai_bracelet)
+ DetectedTransport.ISO_DEP -> stringResource(R.string.scan_transport_vitalwear_hce)
+ DetectedTransport.UNKNOWN -> null
+ }
var cardsRead by remember { mutableStateOf?>(null) }
@@ -33,6 +46,12 @@ fun ReadingScreen(
var isDoneReadingCharacter by remember { mutableStateOf(false) }
var cardSelectScreen by remember { mutableStateOf(false) }
+ LaunchedEffect(readingScreen) {
+ if (readingScreen) {
+ transferHaptics.onTransferStart()
+ }
+ }
+
fun startReadIfNeeded() {
val availableSecrets = secrets
if (availableSecrets == null) {
@@ -52,11 +71,13 @@ fun ReadingScreen(
secrets = availableSecrets,
onComplete = {
Log.d(TAG, "onClickRead.onComplete: marking read complete")
+ transferHaptics.onTransferComplete()
readingScreen = false
isDoneReadingCharacter = true
},
onMultipleCards = { cards ->
Log.d(TAG, "onClickRead.onMultipleCards: showing card select (count=${cards.size})")
+ transferHaptics.onTransferProgress()
cardsRead = cards
readingScreen = false
cardSelectScreen = true
@@ -119,13 +140,15 @@ fun ReadingScreen(
if (readingScreen) {
ActionScreen(
topBannerText = stringResource(R.string.reading_character_title),
+ detectedTransportMessage = transportMessage,
transferStatusMessage = transferStatus,
- ) {
+ onClickCancel = {
Log.d(TAG, "ActionScreen.onCancel: cancelRead + onCancel")
readingScreen = false
scanScreenController.cancelRead()
onCancel()
- }
+ }
+ )
} else if (cardSelectScreen) {
ChooseCard(
cards = cardsRead!!,
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/screens/TransferAnimationScreen.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/screens/TransferAnimationScreen.kt
new file mode 100644
index 0000000..137478b
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/screens/TransferAnimationScreen.kt
@@ -0,0 +1,267 @@
+package com.github.nacabaro.vbhelper.screens.scanScreen.screens
+
+import androidx.compose.animation.core.FastOutLinearInEasing
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.animateIntAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Card
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.scale
+import androidx.compose.ui.graphics.FilterQuality
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.github.nacabaro.vbhelper.components.TopBanner
+import com.github.nacabaro.vbhelper.R
+import com.github.nacabaro.vbhelper.utils.ImageBitmapData
+import kotlinx.coroutines.delay
+
+@Composable
+fun TransferAnimationScreen(
+ topBannerText: String,
+ detectedTransportMessage: String?,
+ transferStatusMessage: String?,
+ characterPreview: ImageBitmapData? = null,
+ onClickCancel: () -> Unit,
+ isTransferring: Boolean = true,
+) {
+ var pulseScale by remember { mutableStateOf(1f) }
+ var animationProgress by remember { mutableStateOf(0) }
+
+ val scale by animateFloatAsState(
+ targetValue = pulseScale,
+ animationSpec = tween(durationMillis = 800),
+ label = "pulse_scale"
+ )
+
+ val progress by animateIntAsState(
+ targetValue = animationProgress,
+ animationSpec = tween(durationMillis = 100, easing = FastOutLinearInEasing),
+ label = "progress"
+ )
+
+ LaunchedEffect(isTransferring) {
+ if (isTransferring) {
+ while (true) {
+ pulseScale = 1.2f
+ delay(400)
+ pulseScale = 1f
+ delay(400)
+ }
+ }
+ }
+
+ LaunchedEffect(isTransferring) {
+ if (isTransferring) {
+ while (animationProgress < 100) {
+ delay(50)
+ animationProgress += 2
+ }
+ }
+ }
+
+ Scaffold(
+ topBar = {
+ TopBanner(
+ text = topBannerText,
+ onBackClick = onClickCancel
+ )
+ }
+ ) { innerPadding ->
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ modifier = Modifier
+ .padding(innerPadding)
+ .fillMaxSize()
+ ) {
+ characterPreview?.let { preview ->
+ Card(
+ modifier = Modifier.padding(bottom = 24.dp)
+ ) {
+ Image(
+ bitmap = preview.imageBitmap,
+ contentDescription = "Transferred Digimon sprite",
+ modifier = Modifier
+ .size(preview.dpWidth, preview.dpHeight)
+ .padding(8.dp),
+ filterQuality = FilterQuality.None
+ )
+ }
+ }
+
+ // Animated pulsing circle indicator
+ Box(
+ modifier = Modifier
+ .size(120.dp)
+ .scale(scale)
+ .background(
+ color = Color(0xFF6366F1).copy(alpha = 0.2f),
+ shape = CircleShape
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ Box(
+ modifier = Modifier
+ .size(80.dp)
+ .background(
+ color = Color(0xFF6366F1),
+ shape = CircleShape
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = "🔄",
+ fontSize = 40.sp,
+ modifier = Modifier.scale(if (scale > 1f) 1.1f else 1f)
+ )
+ }
+ }
+
+ Text(
+ text = stringResource(R.string.action_place_near_reader),
+ fontSize = 16.sp,
+ modifier = Modifier.padding(top = 24.dp),
+ fontWeight = FontWeight.Medium
+ )
+
+ if (!detectedTransportMessage.isNullOrBlank()) {
+ Text(
+ text = detectedTransportMessage,
+ modifier = Modifier.padding(top = 12.dp, start = 16.dp, end = 16.dp),
+ fontSize = 14.sp
+ )
+ }
+
+ if (!transferStatusMessage.isNullOrBlank()) {
+ Text(
+ text = transferStatusMessage,
+ modifier = Modifier.padding(top = 12.dp, start = 16.dp, end = 16.dp),
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Bold,
+ color = Color(0xFF6366F1)
+ )
+ }
+
+ // Progress ring
+ if (isTransferring) {
+ CircularProgressIndicator(
+ progress = { progress / 100f },
+ modifier = Modifier
+ .size(100.dp)
+ .padding(top = 24.dp),
+ strokeWidth = 4.dp
+ )
+
+ Text(
+ text = "$progress%",
+ modifier = Modifier.padding(top = 8.dp),
+ fontSize = 12.sp,
+ color = Color.Gray
+ )
+ }
+
+ Button(
+ onClick = onClickCancel,
+ modifier = Modifier.padding(top = 32.dp, bottom = 16.dp)
+ ) {
+ Text(stringResource(R.string.action_cancel))
+ }
+ }
+ }
+}
+
+@Composable
+fun TransferCompleteScreen(
+ topBannerText: String,
+ resultMessage: String,
+ onClickOk: () -> Unit,
+ isSuccess: Boolean = true,
+) {
+ var showCheckmark by remember { mutableStateOf(false) }
+
+ val scale by animateFloatAsState(
+ targetValue = if (showCheckmark) 1f else 0f,
+ animationSpec = tween(durationMillis = 600, easing = FastOutLinearInEasing),
+ label = "checkmark_scale"
+ )
+
+ LaunchedEffect(Unit) {
+ delay(300)
+ showCheckmark = true
+ }
+
+ Scaffold(
+ topBar = {
+ TopBanner(
+ text = topBannerText,
+ onBackClick = {}
+ )
+ }
+ ) { innerPadding ->
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ modifier = Modifier
+ .padding(innerPadding)
+ .fillMaxSize()
+ ) {
+ Box(
+ modifier = Modifier
+ .size(120.dp)
+ .scale(scale)
+ .background(
+ color = if (isSuccess) Color(0xFF10B981) else Color(0xFFEF4444),
+ shape = CircleShape
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = if (isSuccess) "✓" else "✕",
+ fontSize = 60.sp,
+ color = Color.White,
+ fontWeight = FontWeight.Bold
+ )
+ }
+
+ Text(
+ text = resultMessage,
+ fontSize = 16.sp,
+ modifier = Modifier.padding(top = 24.dp, start = 16.dp, end = 16.dp),
+ fontWeight = FontWeight.Medium,
+ textAlign = TextAlign.Center
+ )
+
+ Button(
+ onClick = onClickOk,
+ modifier = Modifier.padding(top = 32.dp, bottom = 16.dp)
+ ) {
+ Text("OK")
+ }
+ }
+ }
+}
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/screens/WritingScreen.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/screens/WritingScreen.kt
index 53265bf..f4a8141 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/screens/WritingScreen.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/screens/WritingScreen.kt
@@ -7,15 +7,21 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.produceState
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import com.github.cfogrady.vbnfc.data.NfcCharacter
import com.github.nacabaro.vbhelper.ActivityLifecycleListener
import com.github.nacabaro.vbhelper.di.VBHelper
+import com.github.nacabaro.vbhelper.screens.scanScreen.DetectedTransport
import com.github.nacabaro.vbhelper.screens.scanScreen.SCAN_SCREEN_ACTIVITY_LIFECYCLE_LISTENER
import com.github.nacabaro.vbhelper.screens.scanScreen.ScanScreenController
import com.github.nacabaro.vbhelper.screens.scanScreen.ScanScreenController.WriteResult
+import com.github.nacabaro.vbhelper.screens.scanScreen.TransferHaptics
import com.github.nacabaro.vbhelper.source.StorageRepository
+import com.github.nacabaro.vbhelper.utils.BitmapData
+import com.github.nacabaro.vbhelper.utils.ImageBitmapData
+import com.github.nacabaro.vbhelper.utils.getImageBitmap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import androidx.compose.ui.res.stringResource
@@ -31,9 +37,39 @@ fun WritingScreen(
) {
val secrets by scanScreenController.secretsFlow.collectAsState(null)
val transferStatus by scanScreenController.transferStatusFlow.collectAsState(null)
+ val detectedTransport by scanScreenController.detectedTransportFlow.collectAsState()
+
+ val transportMessage = when (detectedTransport) {
+ DetectedTransport.NFC_A -> stringResource(R.string.scan_transport_bandai_bracelet)
+ DetectedTransport.ISO_DEP -> stringResource(R.string.scan_transport_vitalwear_hce)
+ DetectedTransport.UNKNOWN -> null
+ }
val application = LocalContext.current.applicationContext as VBHelper
val storageRepository = StorageRepository(application.container.db)
+ val context = LocalContext.current
+
+ val characterPreview by produceState(initialValue = null, key1 = characterId) {
+ value = withContext(Dispatchers.IO) {
+ runCatching {
+ storageRepository.getSingleCharacter(characterId)
+ }.getOrNull()?.let { character ->
+ if (character.spriteWidth > 0 && character.spriteHeight > 0 && character.spriteIdle.isNotEmpty()) {
+ BitmapData(
+ bitmap = character.spriteIdle,
+ width = character.spriteWidth,
+ height = character.spriteHeight
+ ).getImageBitmap(
+ context = context,
+ multiplier = 4,
+ obscure = false
+ )
+ } else {
+ null
+ }
+ }
+ }
+ }
var writing by remember { mutableStateOf(false) }
var writingScreen by remember { mutableStateOf(false) }
@@ -44,6 +80,9 @@ fun WritingScreen(
DisposableEffect(writing) {
if (writing) {
+ val transferHaptics = TransferHaptics(application)
+ transferHaptics.onTransferStart()
+
scanScreenController.registerActivityLifecycleListener(
SCAN_SCREEN_ACTIVITY_LIFECYCLE_LISTENER,
object : ActivityLifecycleListener {
@@ -55,11 +94,13 @@ fun WritingScreen(
if (!isDoneSendingCard) {
scanScreenController.onClickCheckCard(secrets!!, nfcCharacter) {
isDoneSendingCard = true
+ transferHaptics.onTransferProgress()
}
} else if (!isDoneWritingCharacter) {
- scanScreenController.onClickWrite(secrets!!, nfcCharacter) { result ->
+ scanScreenController.onClickWrite(secrets!!, nfcCharacter, characterId) { result ->
writeResult = result
isDoneWritingCharacter = true
+ transferHaptics.onTransferProgress()
}
}
}
@@ -70,11 +111,13 @@ fun WritingScreen(
if (!isDoneSendingCard) {
scanScreenController.onClickCheckCard(secrets!!, nfcCharacter) {
isDoneSendingCard = true
+ transferHaptics.onTransferProgress()
}
} else if (!isDoneWritingCharacter) {
- scanScreenController.onClickWrite(secrets!!, nfcCharacter) { result ->
+ scanScreenController.onClickWrite(secrets!!, nfcCharacter, characterId) { result ->
writeResult = result
isDoneWritingCharacter = true
+ transferHaptics.onTransferProgress()
}
}
}
@@ -106,11 +149,14 @@ fun WritingScreen(
writing = true
ActionScreen(
topBannerText = stringResource(R.string.sending_card_title),
+ detectedTransportMessage = transportMessage,
transferStatusMessage = transferStatus,
- ) {
+ characterPreview = characterPreview,
+ onClickCancel = {
scanScreenController.cancelRead()
onCancel()
- }
+ }
+ )
} else if (!writingConfirmScreen) {
writing = false
WriteCharacterScreen (
@@ -127,12 +173,15 @@ fun WritingScreen(
writing = true
ActionScreen(
topBannerText = stringResource(R.string.writing_character_action_title),
+ detectedTransportMessage = transportMessage,
transferStatusMessage = transferStatus,
- ) {
+ characterPreview = characterPreview,
+ onClickCancel = {
isDoneSendingCard = false
scanScreenController.cancelRead()
onCancel()
- }
+ }
+ )
}
var completedWriting by remember { mutableStateOf(false) }
@@ -140,6 +189,8 @@ fun WritingScreen(
LaunchedEffect(isDoneSendingCard, isDoneWritingCharacter) {
withContext(Dispatchers.IO) {
if (isDoneSendingCard && isDoneWritingCharacter) {
+ // Only delete source character if transfer was confirmed as move.
+ // COPIED means transfer was sent but not confirmed, so keep source.
if (writeResult == WriteResult.MOVE_CONFIRMED) {
storageRepository.deleteCharacter(characterId)
}
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/SettingsScreen.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/SettingsScreen.kt
index 6a71765..0b4bfc9 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/SettingsScreen.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/SettingsScreen.kt
@@ -5,6 +5,7 @@ import android.net.Uri
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -12,8 +13,12 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
@@ -67,6 +72,7 @@ fun SettingsScreen(
settingsScreenController.onClickImportCard()
}
+
SettingsSection(title = stringResource(R.string.settings_section_about))
SettingsEntry(
title = stringResource(R.string.settings_credits_title),
@@ -98,6 +104,38 @@ fun SettingsScreen(
) {
settingsScreenController.onClickImportDatabase()
}
+
+ SettingsSection(title = stringResource(R.string.settings_section_companion_tools))
+ SettingsEntry(
+ title = stringResource(R.string.settings_companion_import_card_image_title),
+ description = stringResource(R.string.settings_companion_import_card_image_desc)
+ ) {
+ settingsScreenController.onClickCompanionImportCardImage()
+ }
+ SettingsEntry(
+ title = stringResource(R.string.settings_companion_import_firmware_title),
+ description = stringResource(R.string.settings_companion_import_firmware_desc)
+ ) {
+ settingsScreenController.onClickCompanionImportFirmware()
+ }
+ SettingsEntry(
+ title = stringResource(R.string.settings_companion_send_watch_logs_title),
+ description = stringResource(R.string.settings_companion_send_watch_logs_desc)
+ ) {
+ settingsScreenController.onClickCompanionSendWatchLogs()
+ }
+ SettingsEntry(
+ title = stringResource(R.string.settings_companion_send_phone_logs_title),
+ description = stringResource(R.string.settings_companion_send_phone_logs_desc)
+ ) {
+ settingsScreenController.onClickCompanionSendPhoneLogs()
+ }
+ SettingsSwitchEntry(
+ title = stringResource(R.string.settings_enable_dim_to_bem_title),
+ description = stringResource(R.string.settings_enable_dim_to_bem_desc),
+ isEnabled = settingsScreenController,
+ onToggle = { settingsScreenController.onToggleDimToBemConversion(it) }
+ )
}
}
}
@@ -123,6 +161,40 @@ fun SettingsEntry(
}
}
+@Composable
+fun SettingsSwitchEntry(
+ title: String,
+ description: String,
+ isEnabled: SettingsScreenControllerImpl,
+ onToggle: (Boolean) -> Unit
+) {
+ val dimToBemEnabled by isEnabled.dimToBemConversionEnabled.collectAsState(false)
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .padding(end = 16.dp)
+ ) {
+ Text(text = title)
+ Text(
+ text = description,
+ fontSize = 12.sp,
+ color = MaterialTheme.colorScheme.outline
+ )
+ }
+ Switch(
+ checked = dimToBemEnabled,
+ onCheckedChange = onToggle
+ )
+ }
+}
+
@Composable
fun SettingsSection(
title: String
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/SettingsScreenController.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/SettingsScreenController.kt
index ea0fb19..45f8565 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/SettingsScreenController.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/SettingsScreenController.kt
@@ -5,4 +5,8 @@ interface SettingsScreenController {
fun onClickImportDatabase()
fun onClickImportApk()
fun onClickImportCard()
+ fun onClickCompanionImportCardImage()
+ fun onClickCompanionImportFirmware()
+ fun onClickCompanionSendWatchLogs()
+ fun onClickCompanionSendPhoneLogs()
}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/SettingsScreenControllerImpl.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/SettingsScreenControllerImpl.kt
index dd78c14..dc065af 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/SettingsScreenControllerImpl.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/SettingsScreenControllerImpl.kt
@@ -1,5 +1,6 @@
package com.github.nacabaro.vbhelper.screens.settingsScreen
+import android.content.Intent
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import android.net.Uri
@@ -7,8 +8,13 @@ import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
+import com.github.nacabaro.vbhelper.companion.card.CompanionImportCardActivity
+import com.github.nacabaro.vbhelper.companion.firmware.CompanionFirmwareImportActivity
+import com.github.nacabaro.vbhelper.companion.logs.CompanionWatchLogsActivity
+import com.github.nacabaro.vbhelper.companion.logging.TinyLogTree
import com.github.nacabaro.vbhelper.database.AppDatabase
import com.github.nacabaro.vbhelper.di.VBHelper
+import com.github.nacabaro.vbhelper.R
import com.github.nacabaro.vbhelper.screens.settingsScreen.controllers.CardImportController
import com.github.nacabaro.vbhelper.screens.settingsScreen.controllers.DatabaseManagementController
import com.github.nacabaro.vbhelper.source.ApkSecretsImporter
@@ -16,6 +22,7 @@ import com.github.nacabaro.vbhelper.source.SecretsImporter
import com.github.nacabaro.vbhelper.source.SecretsRepository
import com.github.nacabaro.vbhelper.source.proto.Secrets
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.first
class SettingsScreenControllerImpl(
@@ -29,11 +36,14 @@ class SettingsScreenControllerImpl(
private val application = context.applicationContext as VBHelper
private val secretsRepository: SecretsRepository = application.container.dataStoreSecretsRepository
private val database: AppDatabase = application.container.db
+ private val cardSettingsRepository = application.container.cardSettingsRepository
private val databaseManagementController = DatabaseManagementController(
componentActivity = context,
application = application
)
+ val dimToBemConversionEnabled = cardSettingsRepository.enableDimToBemConversion
+
init {
filePickerLauncher = context.registerForActivityResult(
ActivityResultContracts.CreateDocument("application/octet-stream")
@@ -101,13 +111,41 @@ class SettingsScreenControllerImpl(
filePickerCard.launch(arrayOf("*/*"))
}
+ override fun onClickCompanionImportCardImage() {
+ context.startActivity(Intent(context, CompanionImportCardActivity::class.java))
+ }
+
+ override fun onClickCompanionImportFirmware() {
+ context.startActivity(Intent(context, CompanionFirmwareImportActivity::class.java))
+ }
+
+ override fun onClickCompanionSendWatchLogs() {
+ context.startActivity(Intent(context, CompanionWatchLogsActivity::class.java))
+ }
+
+ override fun onClickCompanionSendPhoneLogs() {
+ val file = TinyLogTree.getMostRecentLogFile(context.applicationContext)
+ if (file == null) {
+ Toast.makeText(context, context.getString(R.string.companion_logs_no_files), Toast.LENGTH_SHORT).show()
+ return
+ }
+ application.companionLogService.sendLogFile(context.applicationContext, file, context)
+ }
+
+ fun onToggleDimToBemConversion(enabled: Boolean) {
+ context.lifecycleScope.launch(Dispatchers.IO) {
+ cardSettingsRepository.setEnableDimToBemConversion(enabled)
+ }
+ }
+
private fun importCard(uri: Uri) {
context.lifecycleScope.launch(Dispatchers.IO) {
val contentResolver = context.contentResolver
val inputStream = contentResolver.openInputStream(uri)
inputStream.use { fileReader ->
- val cardImportController = CardImportController(database)
+ val dimToBemEnabled = cardSettingsRepository.enableDimToBemConversion.first()
+ val cardImportController = CardImportController(database, dimToBemEnabled)
cardImportController.importCard(fileReader)
}
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/controllers/CardImportController.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/controllers/CardImportController.kt
index 1626405..5af2606 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/controllers/CardImportController.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/controllers/CardImportController.kt
@@ -13,7 +13,8 @@ import com.github.nacabaro.vbhelper.domain.characters.Sprite
import java.io.InputStream
class CardImportController(
- private val database: AppDatabase
+ private val database: AppDatabase,
+ private val enableDimToBemConversion: Boolean = false
) {
suspend fun importCard(
fileReader: InputStream?
@@ -117,6 +118,16 @@ class CardImportController(
.spriteDao()
.insertSprite(domainSprite)
+ // Apply DIM-to-BEM conversion if enabled and card is a DIM card (not BEM)
+ var baseHp = characters[index].hp
+ var baseBp = characters[index].dp
+ var baseAp = characters[index].ap
+
+ if (enableDimToBemConversion && card is DimCard) {
+ baseHp = convertDimStatToBem(baseHp, characters[index].stage, Stat.HP)
+ baseBp = convertDimStatToBem(baseBp, characters[index].stage, Stat.BP)
+ baseAp = convertDimStatToBem(baseAp, characters[index].stage, Stat.AP)
+ }
domainCharacters.add(
CardCharacter(
@@ -126,9 +137,9 @@ class CardImportController(
nameSprite = card.spriteData.sprites[spriteCounter].pixelData,
stage = characters[index].stage,
attribute = NfcCharacter.Attribute.entries[characters[index].attribute],
- baseHp = characters[index].hp,
- baseBp = characters[index].dp,
- baseAp = characters[index].ap,
+ baseHp = baseHp,
+ baseBp = baseBp,
+ baseAp = baseAp,
nameWidth = card.spriteData.sprites[spriteCounter].spriteDimensions.width,
nameHeight = card.spriteData.sprites[spriteCounter].spriteDimensions.height
)
@@ -150,6 +161,40 @@ class CardImportController(
.insertCharacter(*domainCharacters.toTypedArray())
}
+ private enum class Stat { HP, BP, AP }
+
+ private fun convertDimStatToBem(dimStat: Int, phase: Int, statType: Stat): Int {
+ // DIM-to-BEM conversion factors based on phase and stat type
+ // These are approximations based on the average conversion ratios
+ val conversionFactor = when (phase) {
+ 1 -> 0.0f
+ 2 -> 0.0f
+ 3 -> when (statType) {
+ Stat.HP -> 4657.5f
+ Stat.BP -> 4657.5f
+ Stat.AP -> 1022.5f
+ }
+ 4 -> when (statType) {
+ Stat.HP -> 5171.528f
+ Stat.BP -> 5028.528f
+ Stat.AP -> 1222.9167f
+ }
+ 5 -> when (statType) {
+ Stat.HP -> 5664.4f
+ Stat.BP -> 5036.4f
+ Stat.AP -> 1505.7333f
+ }
+ 6 -> when (statType) {
+ Stat.HP -> 5972.2974f
+ Stat.BP -> 5256.2974f
+ Stat.AP -> 1976.7568f
+ }
+ else -> 0.0f
+ }
+
+ return (dimStat * conversionFactor).toInt()
+ }
+
private suspend fun importAdventureMissions(
cardId: Long,
card: com.github.cfogrady.vb.dim.card.Card<*, *, *, *, *, *>
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/controllers/DatabaseManagementController.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/controllers/DatabaseManagementController.kt
index f3fb931..0f9e34e 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/controllers/DatabaseManagementController.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/controllers/DatabaseManagementController.kt
@@ -1,11 +1,14 @@
package com.github.nacabaro.vbhelper.screens.settingsScreen.controllers
+import androidx.room.Room
+import android.database.sqlite.SQLiteDatabase
import android.net.Uri
import android.provider.OpenableColumns
import android.util.Log
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.lifecycle.lifecycleScope
+import com.github.nacabaro.vbhelper.database.AppDatabase
import com.github.nacabaro.vbhelper.di.VBHelper
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -18,6 +21,19 @@ class DatabaseManagementController(
val application: VBHelper
) {
private val roomDbName = "internalDb"
+ private val requiredLegacyVersions = setOf(1, 2, 3, 4, 5)
+ private val requiredCoreTables = setOf(
+ "UserCharacter",
+ "Character",
+ "Card",
+ "CardCharacter",
+ "TransformationHistory",
+ )
+
+ private data class ValidationResult(
+ val isValid: Boolean,
+ val reason: String? = null,
+ )
fun exportDatabase( destinationUri: Uri) {
componentActivity.lifecycleScope.launch(Dispatchers.IO) {
@@ -59,6 +75,19 @@ class DatabaseManagementController(
return@launch
}
+ val validationResult = validateImportBackup(sourceUri)
+ if (!validationResult.isValid) {
+ componentActivity.runOnUiThread {
+ Toast.makeText(
+ componentActivity,
+ validationResult.reason
+ ?: "Import blocked: backup is incompatible with this VBH-VW version.",
+ Toast.LENGTH_LONG
+ ).show()
+ }
+ return@launch
+ }
+
application.container.db.close()
val dbPath = componentActivity.getDatabasePath(roomDbName)
@@ -92,6 +121,118 @@ class DatabaseManagementController(
}
}
+ private fun validateImportBackup(sourceUri: Uri): ValidationResult {
+ val tempDbName = "import_validation_temp.db"
+ val tempDbPath = componentActivity.getDatabasePath(tempDbName)
+ val tempShm = File(tempDbPath.parentFile, "$tempDbName-shm")
+ val tempWal = File(tempDbPath.parentFile, "$tempDbName-wal")
+
+ try {
+ if (tempDbPath.exists()) tempDbPath.delete()
+ if (tempShm.exists()) tempShm.delete()
+ if (tempWal.exists()) tempWal.delete()
+
+ componentActivity.contentResolver.openInputStream(sourceUri)?.use { inputStream ->
+ tempDbPath.outputStream().use { outputStream ->
+ copyFile(inputStream, outputStream)
+ }
+ } ?: return ValidationResult(false, "Import blocked: unable to read selected backup file.")
+
+ // Fast static checks before attempting full Room open/migration.
+ val legacyVersion = readPragmaUserVersion(tempDbPath)
+ if (legacyVersion !in requiredLegacyVersions) {
+ return ValidationResult(
+ false,
+ "Import blocked: unsupported DB version ($legacyVersion). Use a VBH-VW export from a compatible app version."
+ )
+ }
+
+ val tableNames = readTableNames(tempDbPath)
+ if (!tableNames.contains("room_master_table")) {
+ return ValidationResult(
+ false,
+ "Import blocked: backup is not a Room VBH-VW database (room metadata missing)."
+ )
+ }
+
+ val missingTables = requiredCoreTables.filterNot { tableNames.contains(it) }
+ if (missingTables.isNotEmpty()) {
+ return ValidationResult(
+ false,
+ "Import blocked: backup is missing required tables (${missingTables.joinToString(", ")})."
+ )
+ }
+
+ // Hard validation: open with the app's exact Room schema + migrations.
+ val probeDb = Room.databaseBuilder(
+ componentActivity,
+ AppDatabase::class.java,
+ tempDbName
+ )
+ .addMigrations(AppDatabase.MIGRATION_1_2)
+ .addMigrations(AppDatabase.MIGRATION_2_3)
+ .addMigrations(AppDatabase.MIGRATION_3_5)
+ .addMigrations(AppDatabase.MIGRATION_4_5)
+ .addMigrations(AppDatabase.MIGRATION_5_6)
+ .build()
+
+ try {
+ probeDb.openHelper.writableDatabase.query("SELECT 1").close()
+ } catch (e: Exception) {
+ return ValidationResult(
+ false,
+ "Import blocked: backup cannot be opened/migrated by this VBH-VW build."
+ )
+ } finally {
+ probeDb.close()
+ }
+
+ return ValidationResult(true)
+ } finally {
+ if (tempDbPath.exists()) tempDbPath.delete()
+ if (tempShm.exists()) tempShm.delete()
+ if (tempWal.exists()) tempWal.delete()
+ }
+ }
+
+ private fun readPragmaUserVersion(dbFile: File): Int {
+ val sqliteDb = SQLiteDatabase.openDatabase(
+ dbFile.absolutePath,
+ null,
+ SQLiteDatabase.OPEN_READONLY
+ )
+ try {
+ sqliteDb.rawQuery("PRAGMA user_version", null).use { cursor ->
+ if (cursor.moveToFirst()) {
+ return cursor.getInt(0)
+ }
+ }
+ return -1
+ } finally {
+ sqliteDb.close()
+ }
+ }
+
+ private fun readTableNames(dbFile: File): Set {
+ val sqliteDb = SQLiteDatabase.openDatabase(
+ dbFile.absolutePath,
+ null,
+ SQLiteDatabase.OPEN_READONLY
+ )
+ try {
+ val tableNames = mutableSetOf()
+ sqliteDb.rawQuery("SELECT name FROM sqlite_master WHERE type='table'", null)
+ .use { cursor ->
+ while (cursor.moveToNext()) {
+ tableNames.add(cursor.getString(0))
+ }
+ }
+ return tableNames
+ } finally {
+ sqliteDb.close()
+ }
+ }
+
private fun copyFile(inputStream: InputStream, outputStream: OutputStream) {
val buffer = ByteArray(1024)
var bytesRead: Int
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/source/AuthRepository.kt b/app/src/main/java/com/github/nacabaro/vbhelper/source/AuthRepository.kt
index 53bc737..c379a27 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/source/AuthRepository.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/source/AuthRepository.kt
@@ -44,12 +44,18 @@ class AuthRepository(
preferences[IS_AUTHENTICATED] = isAuthenticated
if (nacatechToken != null) {
preferences[AUTH_TOKEN] = nacatechToken
+ } else {
+ preferences.remove(AUTH_TOKEN)
}
if (sessionToken != null) {
preferences[SESSION_TOKEN] = sessionToken
+ } else {
+ preferences.remove(SESSION_TOKEN)
}
if (userId != null) {
preferences[USER_ID] = userId
+ } else {
+ preferences.remove(USER_ID)
}
}
}
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/source/CardSettingsRepository.kt b/app/src/main/java/com/github/nacabaro/vbhelper/source/CardSettingsRepository.kt
new file mode 100644
index 0000000..557cf67
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/source/CardSettingsRepository.kt
@@ -0,0 +1,28 @@
+package com.github.nacabaro.vbhelper.source
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+
+class CardSettingsRepository(
+ private val dataStore: DataStore
+) {
+ private companion object {
+ val ENABLE_DIM_TO_BEM_CONVERSION = booleanPreferencesKey("enable_dim_to_bem_conversion")
+ }
+
+ val enableDimToBemConversion: Flow = dataStore.data
+ .map { preferences ->
+ preferences[ENABLE_DIM_TO_BEM_CONVERSION] ?: false
+ }
+
+ suspend fun setEnableDimToBemConversion(enable: Boolean) {
+ dataStore.edit { preferences ->
+ preferences[ENABLE_DIM_TO_BEM_CONVERSION] = enable
+ }
+ }
+}
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/source/DeviceTypeConverter.kt b/app/src/main/java/com/github/nacabaro/vbhelper/source/DeviceTypeConverter.kt
new file mode 100644
index 0000000..6a97062
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/source/DeviceTypeConverter.kt
@@ -0,0 +1,54 @@
+package com.github.nacabaro.vbhelper.source
+
+import com.github.nacabaro.vbhelper.database.AppDatabase
+import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData
+import com.github.nacabaro.vbhelper.utils.DeviceType
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+/**
+ * Utility for converting character device types.
+ * Used to fix mismatched device types when sending to physical NFC devices.
+ */
+class DeviceTypeConverter(private val database: AppDatabase) {
+
+ /**
+ * Prepares a BE character for VB-target exports without deleting BE stats.
+ * This keeps the BE profile intact while ensuring a VB sidecar exists.
+ */
+ suspend fun convertBEToVB(characterId: Long): Boolean = withContext(Dispatchers.IO) {
+ try {
+ val userCharacter = database.userCharacterDao().getCharacter(characterId)
+
+ if (userCharacter.characterType != DeviceType.BEDevice) {
+ // Already VB or other type, no conversion needed
+ return@withContext true
+ }
+
+ // Keep BE typing/stats as-is; only ensure a VB sidecar profile exists for exports.
+ ensureVBCharacterDataExists(characterId)
+
+ true
+ } catch (e: Exception) {
+ // Log but don't crash - character can still be sent as-is
+ android.util.Log.e("DeviceTypeConverter", "Failed to convert character $characterId to VB type", e)
+ false
+ }
+ }
+
+ private suspend fun ensureVBCharacterDataExists(characterId: Long) {
+ val vbData = database.userCharacterDao().getVbDataOrNull(characterId)
+ if (vbData == null) {
+ val vbCharacterData = VBCharacterData(
+ id = characterId,
+ generation = 0,
+ totalTrophies = 0
+ )
+ database.userCharacterDao().insertVBCharacterData(vbCharacterData)
+ }
+ }
+}
+
+
+
+
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterExporter.kt b/app/src/main/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterExporter.kt
index 2f710e6..45078e0 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterExporter.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterExporter.kt
@@ -3,11 +3,8 @@ package com.github.nacabaro.vbhelper.source
import com.github.cfogrady.vitalwear.protos.Character
import com.github.nacabaro.vbhelper.database.AppDatabase
import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData
-import com.github.nacabaro.vbhelper.transfer.CharacterTransferPolicyResolver
-import com.github.nacabaro.vbhelper.transfer.TransferTarget
-import com.github.nacabaro.vbhelper.transfer.TransferTransport
-import com.github.nacabaro.vbhelper.transfer.toTransferDeviceType
import com.github.nacabaro.vbhelper.utils.DeviceType
+import com.google.protobuf.ByteString
import kotlinx.coroutines.flow.firstOrNull
internal const val DEFAULT_VITALWEAR_TRAINING_TIME_SECONDS = 100L * 60L * 60L
@@ -34,15 +31,25 @@ internal fun resolveTrainingSeconds(
class VitalWearCharacterExporter(
private val database: AppDatabase
) {
- private val transferPolicyResolver = CharacterTransferPolicyResolver(database.characterTransferPolicyDao())
-
/**
* Builds a Character proto from the stored character data.
* Used by the HCE ISO-DEP transfer path.
+ *
+ * @param characterId ID of the character to export
+ * @param forcedTransferProfile If specified (e.g., DeviceType.BEDevice for HCE), overrides stored device type
+ * and ensures stats are serialized for the target ecosystem.
+ * Used to lock VitalWear (HCE) transfers to BE profile.
*/
- suspend fun buildCharacterProto(characterId: Long): Character {
+ suspend fun buildCharacterProto(
+ characterId: Long,
+ forcedTransferProfile: DeviceType? = null,
+ ): Character {
val characterWithSprites = database.userCharacterDao().getCharacterWithSprites(characterId)
val userCharacter = database.userCharacterDao().getCharacter(characterId)
+ val cardCharacter = database.characterDao().getCharacterById(userCharacter.charId)
+ ?: error("Card character not found for user character $characterId")
+ val sprite = database.spriteDao().getSpriteById(cardCharacter.spriteId)
+ ?: error("Sprite not found for user character $characterId")
val characterInfo = database.characterDao().getCharacterInfo(userCharacter.charId)
val vwSettings = database.vitalWearSettingsDao().getByCharacterId(characterId)
val card = database.cardDao().getCardByCharacterIdSync(characterId)
@@ -50,17 +57,25 @@ class VitalWearCharacterExporter(
val cardProgress = database.cardProgressDao().getCardProgressSync(card.id) ?: 0
val beData = database.userCharacterDao().getBeDataOrNull(characterId)
val vbData = database.userCharacterDao().getVbDataOrNull(characterId)
- val exportFormat = transferPolicyResolver.resolveExportFormat(
- characterId = characterId,
- characterType = userCharacter.characterType,
- transport = TransferTransport.HCE,
- target = TransferTarget.VITAL_WEAR,
- isBemCard = card.isBEm,
- )
val normalizedTransformationCountdownMinutes = normalizeTransformationCountdownMinutes(
transformationCountdownMinutes = userCharacter.transformationCountdown,
hasPossibleTransformations = hasPossibleTransformations(userCharacter.charId),
)
+
+ // Use forced profile if specified (e.g., BE for HCE route), otherwise use stored type.
+ // For HCE/VitalWear routes, forcedTransferProfile = BE ensures BE stats are serialized.
+ val transferDeviceType = forcedTransferProfile ?: userCharacter.characterType
+
+ val settingsBuilder = Character.Settings.newBuilder()
+ .setTrainingInBackground(vwSettings?.trainingInBackground ?: false)
+ .setAllowedBattles(resolveAllowedBattles(vwSettings?.allowedBattles))
+
+ // VitalWear chooses DIM-vs-BE runtime path from card type + assumedFranchise.
+ // For forced BE HCE exports of DIM cards, provide a stable assumed franchise hint.
+ if (forcedTransferProfile == DeviceType.BEDevice && !card.isBEm) {
+ settingsBuilder.setAssumedFranchise(0)
+ }
+
return Character.newBuilder()
.setCardId(card.cardId)
.setCardName(card.name)
@@ -68,7 +83,7 @@ class VitalWearCharacterExporter(
Character.CharacterStats.newBuilder()
.setSlotId(characterInfo.charId)
.setVitals(characterWithSprites.vitalPoints)
- .setTrainingTimeRemainingInSeconds(resolveTrainingSeconds(userCharacter.characterType, beData))
+ .setTrainingTimeRemainingInSeconds(resolveTrainingSeconds(transferDeviceType, beData))
.setTimeUntilNextTransformation(normalizedTransformationCountdownMinutes.toLong() * 60L)
.setTrainedBp(resolveTrainedBp(beData))
.setTrainedHp(resolveTrainedHp(beData))
@@ -81,7 +96,7 @@ class VitalWearCharacterExporter(
.setTotalWins(characterWithSprites.totalBattlesWon)
.setCurrentPhaseWins(characterWithSprites.currentPhaseBattlesWon)
.setMood(characterWithSprites.mood)
- .setDeviceType(exportFormat.toTransferDeviceType())
+ .setDeviceType(transferDeviceType.toTransferDeviceType())
.setAgeInDays(userCharacter.ageInDays.coerceAtLeast(0))
.setActivityLevel(userCharacter.activityLevel.coerceAtLeast(0))
.setHeartRateCurrent(userCharacter.heartRateCurrent.coerceAtLeast(0))
@@ -106,12 +121,7 @@ class VitalWearCharacterExporter(
.setFirmwareMajorVersion(beData?.majorVersion ?: 0)
.build()
)
- .setSettings(
- Character.Settings.newBuilder()
- .setTrainingInBackground(vwSettings?.trainingInBackground ?: false)
- .setAllowedBattles(resolveAllowedBattles(vwSettings?.allowedBattles))
- .build()
- )
+ .setSettings(settingsBuilder.build())
.putMaxAdventureCompletedByCard(card.name, (cardProgress - 1).coerceAtLeast(0))
.addAllTransformationHistory(
database.userCharacterDao().getTransformationHistoryForExport(characterId).map {
@@ -122,6 +132,43 @@ class VitalWearCharacterExporter(
.build()
}
)
+ .setTransferCard(
+ Character.TransferCard.newBuilder()
+ .setCardId(card.cardId)
+ .setCardName(card.name)
+ .setIsBem(card.isBEm)
+ .setStageCount(card.stageCount)
+ .setFranchise(0)
+ .build()
+ )
+ .setTransferSpecies(
+ Character.TransferSpecies.newBuilder()
+ .setSlotId(characterInfo.charId)
+ .setStage(cardCharacter.stage)
+ .setAttribute(cardCharacter.attribute.ordinal)
+ .setBaseHp(cardCharacter.baseHp)
+ .setBaseBp(cardCharacter.baseBp)
+ .setBaseAp(cardCharacter.baseAp)
+ .setNameSprite(ByteString.copyFrom(cardCharacter.nameSprite))
+ .setNameSpriteWidth(cardCharacter.nameWidth)
+ .setNameSpriteHeight(cardCharacter.nameHeight)
+ .setIdle1(ByteString.copyFrom(sprite.spriteIdle1))
+ .setIdle2(ByteString.copyFrom(sprite.spriteIdle2))
+ .setWalk1(ByteString.copyFrom(sprite.spriteWalk1))
+ .setWalk2(ByteString.copyFrom(sprite.spriteWalk2))
+ .setRun1(ByteString.copyFrom(sprite.spriteRun1))
+ .setRun2(ByteString.copyFrom(sprite.spriteRun2))
+ .setTrain1(ByteString.copyFrom(sprite.spriteTrain1))
+ .setTrain2(ByteString.copyFrom(sprite.spriteTrain2))
+ .setWin(ByteString.copyFrom(sprite.spriteHappy))
+ .setDown(ByteString.copyFrom(sprite.spriteSleep))
+ .setAttack(ByteString.copyFrom(sprite.spriteAttack))
+ .setDodge(ByteString.copyFrom(sprite.spriteDodge))
+ .setSplash(ByteString.copyFrom(sprite.spriteIdle1))
+ .setSpriteWidth(sprite.width)
+ .setSpriteHeight(sprite.height)
+ .build()
+ )
.build()
}
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterImporter.kt b/app/src/main/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterImporter.kt
index fdd5593..704b0f9 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterImporter.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterImporter.kt
@@ -5,12 +5,13 @@ import com.github.cfogrady.vitalwear.common.data.SharedTransferSeenDao
import com.github.cfogrady.vitalwear.protos.Character
import com.github.nacabaro.vbhelper.database.AppDatabase
import com.github.nacabaro.vbhelper.domain.card.Card
+import com.github.nacabaro.vbhelper.domain.card.CardCharacter
+import com.github.nacabaro.vbhelper.domain.card.CardProgress
import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData
import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter
import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData
import com.github.nacabaro.vbhelper.domain.device_data.VitalWearCharacterSettings
-import com.github.nacabaro.vbhelper.transfer.CharacterTransferPolicyResolver
-import com.github.nacabaro.vbhelper.transfer.ExportFormat
+import com.github.nacabaro.vbhelper.domain.characters.Sprite
import com.github.nacabaro.vbhelper.utils.DeviceType
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.runBlocking
@@ -20,8 +21,6 @@ class VitalWearCharacterImporter(
private val database: AppDatabase,
private val transferSeenDao: SharedTransferSeenDao,
) {
- private val transferPolicyResolver = CharacterTransferPolicyResolver(database.characterTransferPolicyDao())
-
data class ImportResult(
val success: Boolean,
val message: String
@@ -29,15 +28,16 @@ class VitalWearCharacterImporter(
fun importCharacter(character: Character): ImportResult {
val importedCard = resolveCard(character)
+ ?: createPlaceholderCard(character)
?: return ImportResult(
success = false,
message = "Matching card not found in VBHelper. Import that card first."
)
val slotId = character.characterStats.slotId
- val cardCharacter = runCatching {
- database.characterDao().getCharacterByMonIndex(slotId, importedCard.id)
- }.getOrNull() ?: return ImportResult(
+ val cardCharacter = database.characterDao().getCharacterByMonIndexOrNull(slotId, importedCard.id)
+ ?: createPlaceholderCharacter(importedCard, character)
+ ?: return ImportResult(
success = false,
message = "Character slot $slotId was not found on card ${importedCard.name}."
)
@@ -62,11 +62,6 @@ class VitalWearCharacterImporter(
fallbackIsBeCharacter = fallbackIsBeCharacter,
)
val isBeCharacter = deviceType == DeviceType.BEDevice
- val observedImportFormat = resolveObservedHceImportFormat(
- transferDeviceType = character.characterStats.deviceType,
- importedCard = importedCard,
- hasBePayloadStats = hasBePayloadStats,
- )
val userCharacterId = database.userCharacterDao().insertCharacterData(
UserCharacter(
@@ -100,6 +95,21 @@ class VitalWearCharacterImporter(
)
}
+ val vbProfile = VBCharacterData(
+ id = userCharacterId,
+ generation = if (character.characterStats.generation > 0) {
+ character.characterStats.generation
+ } else {
+ max(character.transformationHistoryCount - 1, 0)
+ },
+ totalTrophies = if (character.characterStats.totalTrophies > 0) {
+ character.characterStats.totalTrophies
+ } else {
+ character.characterStats.trainedPp.coerceAtLeast(0)
+ }
+ )
+ database.userCharacterDao().insertVBCharacterData(vbProfile)
+
if (isBeCharacter) {
val abilityRarity = resolveAbilityRarity(character.characterStats.abilityRarity)
database.userCharacterDao().insertBECharacterData(
@@ -129,31 +139,179 @@ class VitalWearCharacterImporter(
majorVersion = character.characterStats.firmwareMajorVersion
)
)
- } else {
- database.userCharacterDao().insertVBCharacterData(
- VBCharacterData(
- id = userCharacterId,
- generation = if (character.characterStats.generation > 0) {
- character.characterStats.generation
- } else {
- max(character.transformationHistoryCount - 1, 0)
- },
- totalTrophies = if (character.characterStats.totalTrophies > 0) {
- character.characterStats.totalTrophies
- } else {
- character.characterStats.trainedPp.coerceAtLeast(0)
- }
+ }
+
+ val now = System.currentTimeMillis()
+ markSeen(importedCard.name, slotId, importedCard.id, now)
+
+ var insertedTransformationCount = 0
+ for (transformation in character.transformationHistoryList) {
+ val transformationCard = resolveRelatedCard(
+ incomingCardName = transformation.cardName,
+ incomingCardId = character.cardId,
+ matchedRootCard = importedCard,
+ incomingRootCardName = character.cardName,
+ )
+ if (transformationCard != null) {
+ database.userCharacterDao().insertTransformation(
+ userCharacterId,
+ transformation.slotId,
+ transformationCard.id,
+ now
+ )
+ insertedTransformationCount++
+ markSeen(transformationCard.name, transformation.slotId, transformationCard.id, now)
+ }
+ }
+
+ if (insertedTransformationCount == 0) {
+ // Keep HomeScreen renderable for freshly imported characters with empty history.
+ database.userCharacterDao().insertTransformation(
+ userCharacterId,
+ slotId,
+ importedCard.id,
+ now
+ )
+ }
+
+ for ((cardName, maxAdventureCompleted) in character.maxAdventureCompletedByCardMap) {
+ val adventureCard = resolveRelatedCard(
+ incomingCardName = cardName,
+ incomingCardId = null,
+ matchedRootCard = importedCard,
+ incomingRootCardName = character.cardName,
+ ) ?: continue
+ val currentStage = (maxAdventureCompleted + 1).coerceAtLeast(0)
+ database.cardProgressDao().updateCardProgress(
+ currentStage = currentStage,
+ cardId = adventureCard.id,
+ unlocked = currentStage > adventureCard.stageCount
+ )
+ }
+
+ val nextAdventureMissionStage = character.characterStats.nextAdventureMissionStage.coerceAtLeast(0)
+ if (nextAdventureMissionStage > 0) {
+ database.cardProgressDao().updateCardProgress(
+ currentStage = nextAdventureMissionStage,
+ cardId = importedCard.id,
+ unlocked = nextAdventureMissionStage > importedCard.stageCount,
+ )
+ }
+
+ return ImportResult(
+ success = true,
+ message = "Imported ${importedCard.name} slot $slotId from VitalWear."
+ )
+ }
+
+ /**
+ * Import a VitalWear character with a forced device type.
+ * Used by HCE (VitalWear) read path to ensure imports are marked as BE.
+ *
+ * @param character Protobuf character from VitalWear HCE
+ * @param forcedDeviceType Always use this device type for the imported character (e.g., BEDevice for HCE)
+ */
+ fun importCharacter(character: Character, forcedDeviceType: DeviceType): ImportResult {
+ val importedCard = resolveCard(character)
+ ?: createPlaceholderCard(character)
+ ?: return ImportResult(
+ success = false,
+ message = "Matching card not found in VBHelper. Import that card first."
+ )
+
+ val slotId = character.characterStats.slotId
+ val cardCharacter = database.characterDao().getCharacterByMonIndexOrNull(slotId, importedCard.id)
+ ?: createPlaceholderCharacter(importedCard, character)
+ ?: return ImportResult(
+ success = false,
+ message = "Character slot $slotId was not found on card ${importedCard.name}."
+ )
+
+ database.userCharacterDao().clearActiveCharacter()
+
+ val totalBattles = character.characterStats.totalBattles
+ val totalWins = character.characterStats.totalWins.coerceAtMost(totalBattles)
+ val currentPhaseBattles = character.characterStats.currentPhaseBattles
+ val currentPhaseWins = character.characterStats.currentPhaseWins.coerceAtMost(currentPhaseBattles)
+ val normalizedTransformationCountdown = normalizeTransformationCountdownMinutes(
+ transformationCountdownMinutes = secondsToMinutes(character.characterStats.timeUntilNextTransformation),
+ hasPossibleTransformations = hasPossibleTransformations(cardCharacter.id),
+ )
+
+ val userCharacterId = database.userCharacterDao().insertCharacterData(
+ UserCharacter(
+ charId = cardCharacter.id,
+ ageInDays = character.characterStats.ageInDays.takeIf { it > 0 }
+ ?: max(character.transformationHistoryCount - 1, 0),
+ mood = character.characterStats.mood,
+ vitalPoints = character.characterStats.vitals,
+ transformationCountdown = normalizedTransformationCountdown,
+ injuryStatus = resolveInjuryStatus(character.characterStats.injured),
+ trophies = character.characterStats.trainedPp,
+ currentPhaseBattlesWon = currentPhaseWins,
+ currentPhaseBattlesLost = (currentPhaseBattles - currentPhaseWins).coerceAtLeast(0),
+ totalBattlesWon = totalWins,
+ totalBattlesLost = (totalBattles - totalWins).coerceAtLeast(0),
+ activityLevel = character.characterStats.activityLevel.coerceAtLeast(0),
+ heartRateCurrent = character.characterStats.heartRateCurrent.coerceAtLeast(0),
+ characterType = forcedDeviceType,
+ isActive = true
+ )
+ )
+
+ runBlocking {
+ database.vitalWearSettingsDao().upsert(
+ VitalWearCharacterSettings(
+ characterId = userCharacterId,
+ trainingInBackground = character.settings.trainingInBackground,
+ allowedBattles = character.settings.allowedBattles.number,
+ accumulatedDailyInjuries = character.characterStats.accumulatedDailyInjuries,
)
)
}
- runBlocking {
- database.characterTransferPolicyDao().upsert(
- transferPolicyResolver.policyForHceImport(
- characterId = userCharacterId,
- importedCardIsBem = importedCard.isBEm,
- resolvedDeviceType = deviceType,
- observedFormat = observedImportFormat,
+ val vbProfile = VBCharacterData(
+ id = userCharacterId,
+ generation = if (character.characterStats.generation > 0) {
+ character.characterStats.generation
+ } else {
+ max(character.transformationHistoryCount - 1, 0)
+ },
+ totalTrophies = if (character.characterStats.totalTrophies > 0) {
+ character.characterStats.totalTrophies
+ } else {
+ character.characterStats.trainedPp.coerceAtLeast(0)
+ }
+ )
+ database.userCharacterDao().insertVBCharacterData(vbProfile)
+
+ if (forcedDeviceType == DeviceType.BEDevice) {
+ val abilityRarity = resolveAbilityRarity(character.characterStats.abilityRarity)
+ database.userCharacterDao().insertBECharacterData(
+ BECharacterData(
+ id = userCharacterId,
+ trainingHp = character.characterStats.trainedHp,
+ trainingAp = character.characterStats.trainedAp,
+ trainingBp = character.characterStats.trainedBp,
+ remainingTrainingTimeInMinutes = secondsToMinutes(character.characterStats.trainingTimeRemainingInSeconds),
+ itemEffectMentalStateValue = character.characterStats.itemEffectMentalStateValue,
+ itemEffectMentalStateMinutesRemaining = character.characterStats.itemEffectMentalStateMinutesRemaining,
+ itemEffectActivityLevelValue = character.characterStats.itemEffectActivityLevelValue,
+ itemEffectActivityLevelMinutesRemaining = character.characterStats.itemEffectActivityLevelMinutesRemaining,
+ itemEffectVitalPointsChangeValue = character.characterStats.itemEffectVitalPointsChangeValue,
+ itemEffectVitalPointsChangeMinutesRemaining = character.characterStats.itemEffectVitalPointsChangeMinutesRemaining,
+ abilityRarity = abilityRarity,
+ abilityType = character.characterStats.abilityType,
+ abilityBranch = character.characterStats.abilityBranch,
+ abilityReset = character.characterStats.abilityReset,
+ rank = character.characterStats.rank,
+ itemType = character.characterStats.itemType,
+ itemMultiplier = character.characterStats.itemMultiplier,
+ itemRemainingTime = character.characterStats.itemRemainingTime,
+ otp0 = "",
+ otp1 = "",
+ minorVersion = character.characterStats.firmwareMinorVersion,
+ majorVersion = character.characterStats.firmwareMajorVersion
)
)
}
@@ -182,7 +340,6 @@ class VitalWearCharacterImporter(
}
if (insertedTransformationCount == 0) {
- // Keep HomeScreen renderable for freshly imported characters with empty history.
database.userCharacterDao().insertTransformation(
userCharacterId,
slotId,
@@ -252,6 +409,131 @@ class VitalWearCharacterImporter(
return resolveCard(incomingCardName, incomingCardId)
}
+ private fun createPlaceholderCard(character: Character): Card? {
+ if (!character.hasTransferCard() || !character.hasTransferSpecies()) {
+ return null
+ }
+
+ val transferCard = character.transferCard
+ selectImportedCard(
+ candidates = database.cardDao().getAllCards(),
+ incomingCardName = transferCard.cardName,
+ incomingCardId = transferCard.cardId,
+ )?.let { return it }
+
+ val transferSpecies = character.transferSpecies
+ val baseName = transferCard.cardName
+ .takeIf { it.isNotBlank() }
+ ?: character.cardName.takeIf { it.isNotBlank() }
+ ?: "Transferred Card ${transferCard.cardId}"
+ val placeholderName = buildPlaceholderCardName(baseName, transferCard.cardId)
+ val logoBytes = when {
+ !transferSpecies.nameSprite.isEmpty -> transferSpecies.nameSprite.toByteArray()
+ !transferSpecies.idle1.isEmpty -> transferSpecies.idle1.toByteArray()
+ else -> byteArrayOf()
+ }
+ val logoWidth = when {
+ transferSpecies.nameSpriteWidth > 0 -> transferSpecies.nameSpriteWidth
+ transferSpecies.spriteWidth > 0 -> transferSpecies.spriteWidth
+ else -> 0
+ }
+ val logoHeight = when {
+ transferSpecies.nameSpriteHeight > 0 -> transferSpecies.nameSpriteHeight
+ transferSpecies.spriteHeight > 0 -> transferSpecies.spriteHeight
+ else -> 0
+ }
+
+ val insertedId = runBlocking {
+ database.cardDao().insertNewCard(
+ Card(
+ cardId = transferCard.cardId,
+ logo = logoBytes,
+ logoWidth = logoWidth,
+ logoHeight = logoHeight,
+ name = placeholderName,
+ stageCount = transferCard.stageCount.coerceAtLeast(0),
+ isBEm = transferCard.isBem,
+ )
+ )
+ }
+ val resolvedCard = when {
+ insertedId > 0L -> database.cardDao().getCardById(insertedId)
+ else -> database.cardDao().getCardByName(placeholderName)
+ } ?: return null
+
+ if (database.cardProgressDao().getCardProgressSync(resolvedCard.id) == null) {
+ database.cardProgressDao().insertCardProgress(
+ CardProgress(
+ cardId = resolvedCard.id,
+ currentStage = 1,
+ unlocked = false,
+ )
+ )
+ }
+
+ return resolvedCard
+ }
+
+ private fun createPlaceholderCharacter(card: Card, character: Character): CardCharacter? {
+ if (!character.hasTransferSpecies()) {
+ return null
+ }
+
+ val transferSpecies = character.transferSpecies
+ database.characterDao().getCharacterByMonIndexOrNull(transferSpecies.slotId, card.id)?.let { return it }
+
+ val spriteId = database.spriteDao().insertSprite(
+ Sprite(
+ spriteIdle1 = transferSpecies.idle1.toByteArray(),
+ spriteIdle2 = transferSpecies.idle2.toByteArray(),
+ spriteWalk1 = transferSpecies.walk1.toByteArray(),
+ spriteWalk2 = transferSpecies.walk2.toByteArray(),
+ spriteRun1 = transferSpecies.run1.toByteArray(),
+ spriteRun2 = transferSpecies.run2.toByteArray(),
+ spriteTrain1 = transferSpecies.train1.toByteArray(),
+ spriteTrain2 = transferSpecies.train2.toByteArray(),
+ spriteHappy = transferSpecies.win.toByteArray(),
+ spriteSleep = transferSpecies.down.toByteArray(),
+ spriteAttack = transferSpecies.attack.toByteArray(),
+ spriteDodge = transferSpecies.dodge.toByteArray(),
+ width = transferSpecies.spriteWidth,
+ height = transferSpecies.spriteHeight,
+ )
+ )
+
+ val attribute = enumValues().getOrElse(transferSpecies.attribute) {
+ enumValues().first()
+ }
+
+ runBlocking {
+ database.characterDao().insertCharacter(
+ CardCharacter(
+ cardId = card.id,
+ spriteId = spriteId,
+ charaIndex = transferSpecies.slotId,
+ stage = transferSpecies.stage,
+ attribute = attribute,
+ baseHp = transferSpecies.baseHp,
+ baseBp = transferSpecies.baseBp,
+ baseAp = transferSpecies.baseAp,
+ nameSprite = transferSpecies.nameSprite.toByteArray(),
+ nameWidth = transferSpecies.nameSpriteWidth,
+ nameHeight = transferSpecies.nameSpriteHeight,
+ )
+ )
+ }
+
+ return database.characterDao().getCharacterByMonIndexOrNull(transferSpecies.slotId, card.id)
+ }
+
+ private fun buildPlaceholderCardName(baseName: String, cardId: Int): String {
+ val trimmedBaseName = baseName.trim().ifBlank { "Transferred Card" }
+ database.cardDao().getCardByName(trimmedBaseName)?.let {
+ return if (cardId > 0) "$trimmedBaseName [Transfer $cardId]" else "$trimmedBaseName [Transfer]"
+ }
+ return trimmedBaseName
+ }
+
private fun secondsToMinutes(seconds: Long): Int {
if (seconds <= 0L) {
return 0
@@ -290,23 +572,6 @@ class VitalWearCharacterImporter(
return rarities.getOrElse(rawValue) { resolveDefaultAbilityRarity() }
}
- private fun resolveObservedHceImportFormat(
- transferDeviceType: Character.CharacterStats.TransferDeviceType,
- importedCard: Card,
- hasBePayloadStats: Boolean,
- ): ExportFormat {
- return when (transferDeviceType) {
- Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_BE -> ExportFormat.BE
- Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_VB -> ExportFormat.VB
- Character.CharacterStats.TransferDeviceType.UNRECOGNIZED,
- Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_UNSPECIFIED -> if (importedCard.isBEm || hasBePayloadStats) {
- ExportFormat.BE
- } else {
- ExportFormat.VB
- }
- }
- }
-
private fun markSeen(cardName: String, slotId: Int, cardId: Long, timestamp: Long) {
database.dexDao().insertCharacter(slotId, cardId, timestamp)
transferSeenDao.markSeen(cardName, slotId, timestamp)
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/transfer/hce/VitalWearHceReaderClient.kt b/app/src/main/java/com/github/nacabaro/vbhelper/transfer/hce/VitalWearHceReaderClient.kt
index 65f5f00..de2e047 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/transfer/hce/VitalWearHceReaderClient.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/transfer/hce/VitalWearHceReaderClient.kt
@@ -2,6 +2,8 @@
import android.nfc.tech.IsoDep
import android.util.Log
import com.github.cfogrady.vitalwear.protos.Character
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.runBlocking
/**
* Phone-side ISO-DEP client that drives the VitalWear HCE session.
* Mirrors the APDU protocol defined in VitalWearHceProtocol on the watch.
@@ -20,10 +22,17 @@ class VitalWearHceReaderClient(private val isoDep: IsoDep) {
private val INS_READ_CHUNK: Byte = 0x20
private val INS_WRITE_CHUNK: Byte = 0x30
private val INS_COMMIT: Byte = 0x40
+ private val INS_STATUS: Byte = 0x50
+ private val INS_SYNC_UI: Byte = 0x60
+ private val INS_VIBRATE: Byte = 0x70
private val MODE_WATCH_TO_PHONE: Byte = 0x01
private val MODE_PHONE_TO_WATCH: Byte = 0x02
private val VERSION: Byte = 0x01
private val SW_OK = 0x9000
+ private val STATUS_SUCCESS: Byte = 0x04
+ private val STATUS_FAILURE: Byte = 0x05
+ private val STATUS_SYNCING: Byte = 0x03
+
/** MOVE READ: read character bytes first, let caller import/validate, then COMMIT only on success.
* If [onCharacterRead] returns false, COMMIT is skipped so source can remain on the watch.
*/
@@ -67,8 +76,16 @@ class VitalWearHceReaderClient(private val isoDep: IsoDep) {
}
/** WRITE: watch has called armReceive() — phone pushes a character to the watch. */
- fun sendCharacterToWatch(character: Character) {
+ fun sendCharacterToWatch(character: Character, closeSyncUiAfterCommit: Boolean = true) {
selectAid()
+
+ // --- True to Toy Ceremony ---
+ // 1. Haptic Lock-on
+ sendApdu(INS_VIBRATE, byteArrayOf())
+
+ // 2. Visual Sync (Start Beam)
+ sendApdu(INS_SYNC_UI, byteArrayOf(0x01))
+
val payload = character.toByteArray()
val negResponse = sendApdu(INS_NEGOTIATE, byteArrayOf(MODE_PHONE_TO_WATCH, VERSION))
requireOk(negResponse, "NEGOTIATE")
@@ -82,6 +99,40 @@ class VitalWearHceReaderClient(private val isoDep: IsoDep) {
offset = chunkEnd
}
requireOk(sendApdu(INS_COMMIT, byteArrayOf()), "COMMIT")
+
+ // 3. Visual Sync (End Beam)
+ if (closeSyncUiAfterCommit) {
+ sendApdu(INS_SYNC_UI, byteArrayOf(0x00))
+ }
+ }
+
+ /**
+ * WRITE + confirmation polling.
+ * Returns true only when the watch reports import success.
+ */
+ fun sendCharacterToWatchAndConfirm(character: Character): Boolean {
+ sendCharacterToWatch(character, closeSyncUiAfterCommit = false)
+ val confirmed = runBlocking {
+ repeat(12) {
+ val statusResponse = sendApdu(INS_STATUS, byteArrayOf())
+ requireOk(statusResponse, "STATUS")
+ val statusByte = statusResponse.dropLast(2).firstOrNull()
+ Log.d("HCE_CLIENT", "STATUS poll[$it]: statusByte=${statusByte?.toInt()?.and(0xFF)}")
+ if (statusByte == STATUS_SUCCESS) {
+ return@runBlocking true
+ }
+ if (statusByte == STATUS_FAILURE) {
+ return@runBlocking false
+ }
+ delay(250)
+ }
+ false
+ }
+ // End the sync UI after we have consumed terminal status.
+ runCatching {
+ sendApdu(INS_SYNC_UI, byteArrayOf(0x00))
+ }
+ return confirmed
}
/**
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/utils/BitmapData.kt b/app/src/main/java/com/github/nacabaro/vbhelper/utils/BitmapData.kt
index eae66e2..a169743 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/utils/BitmapData.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/utils/BitmapData.kt
@@ -42,7 +42,7 @@ fun BitmapData.getImageBitmap(
}
fun BitmapData.getBitmap(): Bitmap {
- return Bitmap.createBitmap(createARGBIntArray(), this.width, this.height, Bitmap.Config.HARDWARE)
+ return Bitmap.createBitmap(createARGBIntArray(), this.width, this.height, Bitmap.Config.ARGB_8888)
}
object ARGBMasks {
@@ -64,7 +64,7 @@ fun BitmapData.getObscuredBitmap(): Bitmap {
argbPixels[i] = BLACK
}
}
- return Bitmap.createBitmap(argbPixels, this.width, this.height, Bitmap.Config.HARDWARE)
+ return Bitmap.createBitmap(argbPixels, this.width, this.height, Bitmap.Config.ARGB_8888)
}
diff --git a/app/src/main/proto/com/github/cfogrady/vitalwear/protos/Character.proto b/app/src/main/proto/com/github/cfogrady/vitalwear/protos/Character.proto
index 3c7a06c..973ab02 100644
--- a/app/src/main/proto/com/github/cfogrady/vitalwear/protos/Character.proto
+++ b/app/src/main/proto/com/github/cfogrady/vitalwear/protos/Character.proto
@@ -7,6 +7,41 @@ message Character {
string card_name = 1;
int32 card_id = 2;
+ message TransferCard {
+ int32 card_id = 1;
+ string card_name = 2;
+ bool is_bem = 3;
+ int32 stage_count = 4;
+ int32 franchise = 5;
+ }
+
+ message TransferSpecies {
+ int32 slot_id = 1;
+ int32 stage = 2;
+ int32 attribute = 3;
+ int32 base_hp = 4;
+ int32 base_bp = 5;
+ int32 base_ap = 6;
+ bytes name_sprite = 7;
+ int32 name_sprite_width = 8;
+ int32 name_sprite_height = 9;
+ bytes idle1 = 10;
+ bytes idle2 = 11;
+ bytes walk1 = 12;
+ bytes walk2 = 13;
+ bytes run1 = 14;
+ bytes run2 = 15;
+ bytes train1 = 16;
+ bytes train2 = 17;
+ bytes win = 18;
+ bytes down = 19;
+ bytes attack = 20;
+ bytes dodge = 21;
+ bytes splash = 22;
+ int32 sprite_width = 23;
+ int32 sprite_height = 24;
+ }
+
message CharacterStats {
enum TransferDeviceType {
TRANSFER_DEVICE_TYPE_UNSPECIFIED = 0;
@@ -77,4 +112,8 @@ message Character {
repeated TransformationEvent transformation_history = 5;
map max_adventure_completed_by_card = 6;
+
+ optional TransferCard transfer_card = 7;
+
+ optional TransferSpecies transfer_species = 8;
}
diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml
index 17a5f50..8283b4d 100644
--- a/app/src/main/res/values-pt-rBR/strings.xml
+++ b/app/src/main/res/values-pt-rBR/strings.xml
@@ -178,7 +178,7 @@
Nome do personagem
HP: %1$d, BP: %2$d, AP: %3$d
Stg: %1$s, Atr: %2$s
- \?\?\?\?\?\?\?\?\?\?\?\?\?\?\?\?
+ ________________
Stg: -, Atr: -
HP: -, BP: -, AP: -
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 3235e11..90d71c0 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -68,14 +68,17 @@
Transfer complete. You can remove the phone now.
Transfer failed. Reposition devices and try again.
Transfer cancelled.
+ Detected target: Bandai Vital Bracelet (NFC-A)
+ Detected target: VitalWear (HCE)
Settings
NFC Communication
- DiM/BEm management
+ VBH-VW card management
About and credits
Data management
+ VitalWear companion tools
Import APK
@@ -84,7 +87,12 @@
Import card
- Import DiM/BEm
+ Import DIM/BEM cards into VBHelper\'s Dex.
+
+
+ Scale DIM cards to BEM stats
+
+ Automatically upscale DIM card stats to BEM level when importing
Credits
@@ -103,6 +111,27 @@
Import application database
+ Import card to VitalWear
+ Import Dim/Bem to Vitalwear
+ Import firmware to VitalWear
+ Import firmware to Watch (10b for now)
+ Send watch logs
+ Request logs from a connected VitalWear watch and share them
+ Send phone logs
+ Share the latest VBHelper phone log file
+ WARNING: Logs have personal health information such as heart rate measurements and steps per day.
+ No log files found
+ Failed to send log request to watch
+ Error receiving log file
+ No connected watch found
+ Firmware sent successfully
+ Imported Successfully
+ Connect to VB
+ Insert Card in VB and Reconnect
+ Card Validated Successfully
+ Select firmware file
+ Character transfer is disabled in companion. Use watch Transfer instead.
+
Credits
Reverse engineering
Application development
@@ -187,7 +216,7 @@
Character name
HP: %1$d, BP: %2$d, AP: %3$d
Stg: %1$s, Atr: %2$s
- \?\?\?\?\?\?\?\?\?\?\?\?\?\?\?\?
+ ________________
Stg: -, Atr: -
HP: -, BP: -, AP: -
diff --git a/app/src/main/res/xml/provider_paths.xml b/app/src/main/res/xml/provider_paths.xml
index 8a3c842..b4b063a 100644
--- a/app/src/main/res/xml/provider_paths.xml
+++ b/app/src/main/res/xml/provider_paths.xml
@@ -1,5 +1,8 @@
+
diff --git a/app/src/test/java/com/github/nacabaro/vbhelper/screens/scanScreen/converters/ToNfcConverterProfileSelectionTest.kt b/app/src/test/java/com/github/nacabaro/vbhelper/screens/scanScreen/converters/ToNfcConverterProfileSelectionTest.kt
new file mode 100644
index 0000000..ff4fef7
--- /dev/null
+++ b/app/src/test/java/com/github/nacabaro/vbhelper/screens/scanScreen/converters/ToNfcConverterProfileSelectionTest.kt
@@ -0,0 +1,25 @@
+package com.github.nacabaro.vbhelper.screens.scanScreen.converters
+
+import com.github.nacabaro.vbhelper.utils.DeviceType
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class ToNfcConverterProfileSelectionTest {
+ @Test
+ fun shouldEncodeAsBem_usesStoredTypeWhenForcedProfileMissing() {
+ assertTrue(ToNfcConverter.shouldEncodeAsBem(null, DeviceType.BEDevice))
+ assertFalse(ToNfcConverter.shouldEncodeAsBem(null, DeviceType.VBDevice))
+ }
+
+ @Test
+ fun shouldEncodeAsBem_forcedVBWinsOverStoredBE() {
+ assertFalse(ToNfcConverter.shouldEncodeAsBem(DeviceType.VBDevice, DeviceType.BEDevice))
+ }
+
+ @Test
+ fun shouldEncodeAsBem_forcedBEWinsOverStoredVB() {
+ assertTrue(ToNfcConverter.shouldEncodeAsBem(DeviceType.BEDevice, DeviceType.VBDevice))
+ }
+}
+
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 97d43aa..5f078f3 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -17,6 +17,12 @@ protobufJavalite = "4.33.4"
roomRuntime = "2.8.4"
vbNfcReader = "0.2.0-SNAPSHOT"
dimReader = "2.0.0"
+playServicesWearable = "19.0.0"
+kotlinxCoroutinesPlayServices = "1.4.3-native-mt"
+timber = "5.0.1"
+tinyLog = "2.4.1"
+slf4jTimber = "1.0.0"
+slf4jApi = "2.0.16"
[libraries]
androidx-compose-material = { module = "androidx.compose.material:material" }
@@ -46,6 +52,13 @@ protobuf-gradle-plugin = { module = "com.google.protobuf:protobuf-gradle-plugin"
protobuf-javalite = { module = "com.google.protobuf:protobuf-javalite", version.ref = "protobufJavalite" }
vb-nfc-reader = { module = "com.github.cfogrady:vb-nfc-reader", version.ref = "vbNfcReader" }
dim-reader = { module = "com.github.cfogrady:vb-dim-reader", version.ref = "dimReader" }
+play-services-wearable = { module = "com.google.android.gms:play-services-wearable", version.ref = "playServicesWearable" }
+kotlinx-coroutines-play-services = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-play-services", version.ref = "kotlinxCoroutinesPlayServices" }
+timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
+tiny-log = { module = "org.tinylog:tinylog-api", version.ref = "tinyLog" }
+tiny-log-impl = { module = "org.tinylog:tinylog-impl", version.ref = "tinyLog" }
+slf4j-timber = { module = "at.favre.lib:slf4j-timber", version.ref = "slf4jTimber" }
+slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4jApi" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 50f6d5b..5e8316b 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -17,17 +17,13 @@ dependencyResolutionManagement {
mavenLocal()
google()
mavenCentral()
+ maven { url = uri("https://jitpack.io") }
}
}
rootProject.name = "VBHelper"
include(":app")
-includeBuild("../VB-DIM-Reader-2.0.0") {
- dependencySubstitution {
- substitute(module("com.github.cfogrady:vb-dim-reader")).using(project(":"))
- }
-}
includeBuild("../vb-nfc-reader") {
dependencySubstitution {
From 431be5faea2be53706dfe52ec1e98bddbbd0b58c Mon Sep 17 00:00:00 2001
From: Taveon Nelson <74945303+457R0@users.noreply.github.com>
Date: Mon, 18 May 2026 05:46:40 -0500
Subject: [PATCH 10/13] Battlescreen login loop fix
---
.../github/nacabaro/vbhelper/MainActivity.kt | 19 +++----------------
build/reports/problems/problems-report.html | 2 +-
2 files changed, 4 insertions(+), 17 deletions(-)
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 a9676e3..c883fbc 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/MainActivity.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/MainActivity.kt
@@ -23,9 +23,6 @@ import com.github.nacabaro.vbhelper.screens.cardScreen.CardScreenControllerImpl
import com.github.nacabaro.vbhelper.screens.spriteViewer.SpriteViewerControllerImpl
import com.github.nacabaro.vbhelper.screens.storageScreen.StorageScreenControllerImpl
import com.github.nacabaro.vbhelper.ui.theme.VBHelperTheme
-import androidx.lifecycle.lifecycleScope
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.Dispatchers
class MainActivity : ComponentActivity() {
@@ -119,19 +116,9 @@ class MainActivity : ComponentActivity() {
val hasAuthToken = !token.isNullOrEmpty()
if (isAppAuthCallback || isLocalhostAuthCallback || hasAuthToken) {
- if (!token.isNullOrEmpty()) {
- val application = applicationContext as VBHelper
- val authRepository = com.github.nacabaro.vbhelper.battle.BattleAuthContainer(this).authRepository
- val userId = data.getQueryParameter("userId")?.toLongOrNull()
-
- lifecycleScope.launch(Dispatchers.IO) {
- authRepository.setAuthenticated(
- isAuthenticated = true,
- nacatechToken = token,
- userId = userId
- )
- }
- }
+ // 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
}
}
diff --git a/build/reports/problems/problems-report.html b/build/reports/problems/problems-report.html
index d103092..0cf967c 100644
--- a/build/reports/problems/problems-report.html
+++ b/build/reports/problems/problems-report.html
@@ -650,7 +650,7 @@ code + .copy-button {
From 1a835275907b2db02a2a0f0db17448c04de857ef Mon Sep 17 00:00:00 2001
From: Taveon Nelson <74945303+457R0@users.noreply.github.com>
Date: Mon, 18 May 2026 17:28:24 -0500
Subject: [PATCH 11/13] speeding up HCE
---
.../scanScreen/converters/FromNfcConverter.kt | 5 +-
.../transfer/hce/VitalWearHceReaderClient.kt | 386 +++++++++++++++---
.../transfer/hce/VitalWearHceSession.kt | 40 ++
3 files changed, 368 insertions(+), 63 deletions(-)
create mode 100644 app/src/main/java/com/github/nacabaro/vbhelper/transfer/hce/VitalWearHceSession.kt
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 6f85764..d23f6be 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,6 +3,7 @@ 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
@@ -268,8 +269,8 @@ class FromNfcConverter (
SpecialMissions(
characterId = characterId,
watchId = watchId,
- missionType = com.github.cfogrady.vbnfc.data.MissionType.NONE,
- status = com.github.cfogrady.vbnfc.data.MissionStatus.UNAVAILABLE,
+ missionType = SpecialMission.Type.NONE,
+ status = SpecialMission.Status.UNAVAILABLE,
goal = 0,
progress = 0,
timeElapsedInMinutes = 0,
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/transfer/hce/VitalWearHceReaderClient.kt b/app/src/main/java/com/github/nacabaro/vbhelper/transfer/hce/VitalWearHceReaderClient.kt
index de2e047..74b6948 100644
--- a/app/src/main/java/com/github/nacabaro/vbhelper/transfer/hce/VitalWearHceReaderClient.kt
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/transfer/hce/VitalWearHceReaderClient.kt
@@ -1,5 +1,6 @@
package com.github.nacabaro.vbhelper.transfer.hce
import android.nfc.tech.IsoDep
+import android.os.SystemClock
import android.util.Log
import com.github.cfogrady.vitalwear.protos.Character
import kotlinx.coroutines.delay
@@ -8,7 +9,15 @@ import kotlinx.coroutines.runBlocking
* Phone-side ISO-DEP client that drives the VitalWear HCE session.
* Mirrors the APDU protocol defined in VitalWearHceProtocol on the watch.
*/
-class VitalWearHceReaderClient(private val isoDep: IsoDep) {
+class VitalWearHceReaderClient(
+ private val isoDep: IsoDep,
+ private val fastMode: Boolean = true,
+) {
+ companion object {
+ private const val MIN_CHUNK_SIZE = 256
+ private val bestChunkByDevice = linkedMapOf()
+ }
+
init {
// Import + DB writes can make COMMIT slower than default transceive time on some devices.
isoDep.timeout = 10_000
@@ -32,78 +41,182 @@ class VitalWearHceReaderClient(private val isoDep: IsoDep) {
private val STATUS_SUCCESS: Byte = 0x04
private val STATUS_FAILURE: Byte = 0x05
private val STATUS_SYNCING: Byte = 0x03
-
+ private val STATUS_ARMED_SEND: Byte = 0x01
+ private val STATUS_ARMED_RECEIVE: Byte = 0x02
+ private val STATUS_IDLE: Byte = 0x00
+ private val CONFIRMATION_MAX_POLLS = 84 // ~21s with adaptive delay schedule
+ private val PREFERRED_MAX_CHUNK_SIZE = 2048
+ // Fast mode is the default; ceremony APDUs are only enabled when fastMode is false.
+ private val ENABLE_TRANSFER_CEREMONY = !fastMode
+
+ private data class ReadPayloadResult(
+ val character: Character,
+ val session: VitalWearHceSessionInfo,
+ )
+
+ private data class HceTransferMetrics(
+ val direction: String,
+ val payloadBytes: Int,
+ val negotiatedChunkBytes: Int,
+ val apduCountTotal: Int,
+ val apduCountData: Int,
+ val statusPollCount: Int,
+ val tSelectNegotiateMs: Long,
+ val tChunkLoopMs: Long,
+ val tCommitAckMs: Long,
+ val tConfirmMs: Long,
+ val tTotalMs: Long,
+ val result: String,
+ ) {
+ fun log() {
+ Log.i(
+ "HCE_CLIENT_METRICS",
+ "dir=$direction payloadBytes=$payloadBytes chunk=$negotiatedChunkBytes apduTotal=$apduCountTotal " +
+ "apduData=$apduCountData polls=$statusPollCount selectNegotiateMs=$tSelectNegotiateMs " +
+ "chunkLoopMs=$tChunkLoopMs commitAckMs=$tCommitAckMs confirmMs=$tConfirmMs totalMs=$tTotalMs result=$result"
+ )
+ }
+ }
+
/** MOVE READ: read character bytes first, let caller import/validate, then COMMIT only on success.
* If [onCharacterRead] returns false, COMMIT is skipped so source can remain on the watch.
*/
fun moveCharacterFromWatch(onCharacterRead: (Character) -> Boolean): Boolean {
- val character = readCharacterFromWatchWithoutCommit()
- if (!onCharacterRead(character)) {
+ val readResult = readCharacterFromWatchWithoutCommit()
+ if (!onCharacterRead(readResult.character)) {
return false
}
+ if (!readResult.session.canCommit()) {
+ error("Session does not allow COMMIT for watch->phone transfer")
+ }
requireOk(sendApdu(INS_COMMIT, byteArrayOf()), "COMMIT")
return true
}
/** READ: watch has called armSend() — phone reads the character off the watch. */
fun receiveCharacterFromWatch(): Character {
- val character = readCharacterFromWatchWithoutCommit()
+ val readResult = readCharacterFromWatchWithoutCommit()
+ if (!readResult.session.canCommit()) {
+ error("Session does not allow COMMIT for watch->phone transfer")
+ }
requireOk(sendApdu(INS_COMMIT, byteArrayOf()), "COMMIT")
- return character
+ return readResult.character
}
- private fun readCharacterFromWatchWithoutCommit(): Character {
+ private fun readCharacterFromWatchWithoutCommit(): ReadPayloadResult {
+ val transferStartMs = SystemClock.elapsedRealtime()
+ var apduCount = 0
+ var dataApduCount = 0
selectAid()
- val negResponse = sendApdu(INS_NEGOTIATE, byteArrayOf(MODE_WATCH_TO_PHONE, VERSION))
- requireOk(negResponse, "NEGOTIATE")
- val maxChunk = ((negResponse[2].toInt() and 0xFF) shl 8) or (negResponse[3].toInt() and 0xFF)
- val payloadLen =
- ((negResponse[4].toInt() and 0xFF) shl 24) or
- ((negResponse[5].toInt() and 0xFF) shl 16) or
- ((negResponse[6].toInt() and 0xFF) shl 8) or
- (negResponse[7].toInt() and 0xFF)
- Log.d("HCE_CLIENT", "Receiving $payloadLen bytes in chunks of $maxChunk")
- val payload = ByteArray(payloadLen)
+ apduCount++
+ val session = negotiateSession(MODE_WATCH_TO_PHONE, desiredMaxChunkSize())
+ apduCount++
+ val negotiatedMs = SystemClock.elapsedRealtime()
+ check(session.isWatchToPhone()) { "NEGOTIATE direction mismatch for WATCH_TO_PHONE" }
+ Log.d("HCE_CLIENT", "Receiving ${session.payloadLength} bytes in chunks of ${session.maxChunkSize}")
+ val payload = ByteArray(session.payloadLength)
var offset = 0
- while (offset < payloadLen) {
+ while (offset < session.payloadLength) {
val chunkResponse = sendApdu(INS_READ_CHUNK, intToBytes(offset))
+ apduCount++
+ dataApduCount++
requireOk(chunkResponse, "READ_CHUNK @ $offset")
val chunkData = chunkResponse.dropLast(2).toByteArray()
chunkData.copyInto(payload, offset)
offset += chunkData.size
}
- return Character.parseFrom(payload)
+ val chunksDoneMs = SystemClock.elapsedRealtime()
+ HceTransferMetrics(
+ direction = "WATCH_TO_PHONE_READ",
+ payloadBytes = payload.size,
+ negotiatedChunkBytes = session.maxChunkSize,
+ apduCountTotal = apduCount,
+ apduCountData = dataApduCount,
+ statusPollCount = 0,
+ tSelectNegotiateMs = negotiatedMs - transferStartMs,
+ tChunkLoopMs = chunksDoneMs - negotiatedMs,
+ tCommitAckMs = 0L,
+ tConfirmMs = 0L,
+ tTotalMs = chunksDoneMs - transferStartMs,
+ result = "read_complete",
+ ).log()
+ return ReadPayloadResult(
+ character = Character.parseFrom(payload),
+ session = session,
+ )
}
/** WRITE: watch has called armReceive() — phone pushes a character to the watch. */
fun sendCharacterToWatch(character: Character, closeSyncUiAfterCommit: Boolean = true) {
+ val session = sendCharacterToWatchInternal(
+ character = character,
+ closeSyncUiAfterCommit = closeSyncUiAfterCommit,
+ requestedChunkSize = desiredMaxChunkSize(),
+ )
+ rememberBestChunk(session.maxChunkSize)
+ }
+
+ private fun sendCharacterToWatchInternal(
+ character: Character,
+ closeSyncUiAfterCommit: Boolean,
+ requestedChunkSize: Int,
+ ): VitalWearHceSessionInfo {
+ val transferStartMs = SystemClock.elapsedRealtime()
+ var apduCount = 0
+ var dataApduCount = 0
selectAid()
-
- // --- True to Toy Ceremony ---
- // 1. Haptic Lock-on
- sendApdu(INS_VIBRATE, byteArrayOf())
-
- // 2. Visual Sync (Start Beam)
- sendApdu(INS_SYNC_UI, byteArrayOf(0x01))
-
+ apduCount++
+ if (ENABLE_TRANSFER_CEREMONY) {
+ // Optional toy-like ceremony; kept behind a toggle to avoid transfer overhead.
+ sendApdu(INS_VIBRATE, byteArrayOf())
+ apduCount++
+ sendApdu(INS_SYNC_UI, byteArrayOf(0x01))
+ apduCount++
+ }
+
val payload = character.toByteArray()
- val negResponse = sendApdu(INS_NEGOTIATE, byteArrayOf(MODE_PHONE_TO_WATCH, VERSION))
- requireOk(negResponse, "NEGOTIATE")
- val maxChunk = ((negResponse[2].toInt() and 0xFF) shl 8) or (negResponse[3].toInt() and 0xFF)
- Log.d("HCE_CLIENT", "Sending ${payload.size} bytes in chunks of $maxChunk")
+ val session = negotiateSession(MODE_PHONE_TO_WATCH, requestedChunkSize)
+ apduCount++
+ val negotiatedMs = SystemClock.elapsedRealtime()
+ check(session.isPhoneToWatch()) { "NEGOTIATE direction mismatch for PHONE_TO_WATCH" }
+ Log.d("HCE_CLIENT", "Sending ${payload.size} bytes in chunks of ${session.maxChunkSize}")
var offset = 0
while (offset < payload.size) {
- val chunkEnd = (offset + maxChunk).coerceAtMost(payload.size)
+ val chunkEnd = (offset + session.maxChunkSize).coerceAtMost(payload.size)
val chunk = payload.copyOfRange(offset, chunkEnd)
requireOk(sendApdu(INS_WRITE_CHUNK, intToBytes(offset) + chunk), "WRITE_CHUNK @ $offset")
+ apduCount++
+ dataApduCount++
offset = chunkEnd
}
- requireOk(sendApdu(INS_COMMIT, byteArrayOf()), "COMMIT")
-
- // 3. Visual Sync (End Beam)
- if (closeSyncUiAfterCommit) {
- sendApdu(INS_SYNC_UI, byteArrayOf(0x00))
+ val chunksDoneMs = SystemClock.elapsedRealtime()
+ if (!session.canCommit()) {
+ error("Session does not allow COMMIT for phone->watch transfer")
}
+ val commitStartMs = SystemClock.elapsedRealtime()
+ requireOk(sendApdu(INS_COMMIT, byteArrayOf()), "COMMIT")
+ apduCount++
+ val commitDoneMs = SystemClock.elapsedRealtime()
+
+ if (ENABLE_TRANSFER_CEREMONY && closeSyncUiAfterCommit) {
+ sendApdu(INS_SYNC_UI, byteArrayOf(0x00))
+ apduCount++
+ }
+ HceTransferMetrics(
+ direction = "PHONE_TO_WATCH_WRITE",
+ payloadBytes = payload.size,
+ negotiatedChunkBytes = session.maxChunkSize,
+ apduCountTotal = apduCount,
+ apduCountData = dataApduCount,
+ statusPollCount = 0,
+ tSelectNegotiateMs = negotiatedMs - transferStartMs,
+ tChunkLoopMs = chunksDoneMs - negotiatedMs,
+ tCommitAckMs = commitDoneMs - commitStartMs,
+ tConfirmMs = 0L,
+ tTotalMs = commitDoneMs - transferStartMs,
+ result = "commit_acked",
+ ).log()
+ return session
}
/**
@@ -111,28 +224,87 @@ class VitalWearHceReaderClient(private val isoDep: IsoDep) {
* Returns true only when the watch reports import success.
*/
fun sendCharacterToWatchAndConfirm(character: Character): Boolean {
- sendCharacterToWatch(character, closeSyncUiAfterCommit = false)
- val confirmed = runBlocking {
- repeat(12) {
- val statusResponse = sendApdu(INS_STATUS, byteArrayOf())
- requireOk(statusResponse, "STATUS")
- val statusByte = statusResponse.dropLast(2).firstOrNull()
- Log.d("HCE_CLIENT", "STATUS poll[$it]: statusByte=${statusByte?.toInt()?.and(0xFF)}")
- if (statusByte == STATUS_SUCCESS) {
- return@runBlocking true
- }
- if (statusByte == STATUS_FAILURE) {
- return@runBlocking false
- }
- delay(250)
+ var lastError: Exception? = null
+ for (candidateChunk in chunkCandidates()) {
+ val confirmStartMs = SystemClock.elapsedRealtime()
+ val session = try {
+ sendCharacterToWatchInternal(
+ character = character,
+ closeSyncUiAfterCommit = false,
+ requestedChunkSize = candidateChunk,
+ )
+ } catch (e: Exception) {
+ lastError = e
+ Log.w("HCE_CLIENT", "Chunk attempt failed before confirmation at chunk=$candidateChunk", e)
+ continue
+ }
+ if (!session.canPollStatus()) {
+ return false
+ }
+ var polls = 0
+ var apduCount = 0
+ var result = "timeout"
+ val confirmed = runBlocking {
+ repeat(CONFIRMATION_MAX_POLLS) {
+ val statusResponse = sendApdu(INS_STATUS, byteArrayOf())
+ apduCount++
+ polls++
+ requireOk(statusResponse, "STATUS")
+ val statusByte = statusResponse.dropLast(2).firstOrNull()
+ Log.d("HCE_CLIENT", "STATUS poll[$it]: statusByte=${statusByte?.toInt()?.and(0xFF)}")
+ if (statusByte == STATUS_SUCCESS) {
+ result = "success"
+ return@runBlocking true
+ }
+ if (statusByte == STATUS_FAILURE) {
+ result = "failure"
+ return@runBlocking false
+ }
+ if (
+ statusByte == STATUS_SYNCING ||
+ statusByte == STATUS_ARMED_RECEIVE ||
+ statusByte == STATUS_ARMED_SEND ||
+ statusByte == STATUS_IDLE
+ ) {
+ delay(delayForPoll(it))
+ return@repeat
+ }
+ // Unknown status byte: keep waiting briefly instead of failing early.
+ delay(delayForPoll(it))
+ }
+ false
+ }
+ if (ENABLE_TRANSFER_CEREMONY) {
+ // End the sync UI after we have consumed terminal status.
+ runCatching {
+ sendApdu(INS_SYNC_UI, byteArrayOf(0x00))
+ }
+ }
+ HceTransferMetrics(
+ direction = "PHONE_TO_WATCH_CONFIRM",
+ payloadBytes = character.serializedSize,
+ negotiatedChunkBytes = session.maxChunkSize,
+ apduCountTotal = apduCount,
+ apduCountData = 0,
+ statusPollCount = polls,
+ tSelectNegotiateMs = 0L,
+ tChunkLoopMs = 0L,
+ tCommitAckMs = 0L,
+ tConfirmMs = SystemClock.elapsedRealtime() - confirmStartMs,
+ tTotalMs = SystemClock.elapsedRealtime() - confirmStartMs,
+ result = "$result(requested=$candidateChunk)",
+ ).log()
+ if (confirmed) {
+ rememberBestChunk(session.maxChunkSize)
+ return true
+ }
+ // Explicit failure means import rejected payload; retrying chunk size won't help.
+ if (result == "failure") {
+ return false
}
- false
}
- // End the sync UI after we have consumed terminal status.
- runCatching {
- sendApdu(INS_SYNC_UI, byteArrayOf(0x00))
- }
- return confirmed
+ lastError?.let { throw it }
+ return false
}
/**
@@ -141,9 +313,8 @@ class VitalWearHceReaderClient(private val isoDep: IsoDep) {
*/
fun verifyWatchReadyToReceive(): Boolean {
selectAid()
- val negResponse = sendApdu(INS_NEGOTIATE, byteArrayOf(MODE_PHONE_TO_WATCH, VERSION))
- requireOk(negResponse, "NEGOTIATE")
- return true
+ val session = negotiateSession(MODE_PHONE_TO_WATCH, desiredMaxChunkSize())
+ return session.canCommit()
}
private fun selectAid() {
@@ -152,9 +323,102 @@ class VitalWearHceReaderClient(private val isoDep: IsoDep) {
if (statusWord(response) != SW_OK) error("SELECT AID failed: ${response.toHex()}")
}
private fun sendApdu(ins: Byte, data: ByteArray): ByteArray {
- val apdu = byteArrayOf(CLA, ins, 0x00, 0x00, data.size.toByte()) + data
+ val apdu = if (data.size <= 0xFF) {
+ byteArrayOf(CLA, ins, 0x00, 0x00, data.size.toByte()) + data
+ } else {
+ byteArrayOf(
+ CLA,
+ ins,
+ 0x00,
+ 0x00,
+ 0x00,
+ ((data.size ushr 8) and 0xFF).toByte(),
+ (data.size and 0xFF).toByte(),
+ ) + data
+ }
return isoDep.transceive(apdu)
}
+
+ private fun negotiateSession(mode: Byte, requestedChunkSize: Int): VitalWearHceSessionInfo {
+ val negResponse = sendApdu(
+ INS_NEGOTIATE,
+ byteArrayOf(mode, VERSION) + intToShortBytes(requestedChunkSize)
+ )
+ requireOk(negResponse, "NEGOTIATE")
+ val negotiatedMode = when (negResponse[1]) {
+ MODE_WATCH_TO_PHONE -> VitalWearHceTransferDirection.WATCH_TO_PHONE
+ MODE_PHONE_TO_WATCH -> VitalWearHceTransferDirection.PHONE_TO_WATCH
+ else -> error("NEGOTIATE returned unknown direction: ${negResponse[1]}")
+ }
+ val maxChunk = ((negResponse[2].toInt() and 0xFF) shl 8) or (negResponse[3].toInt() and 0xFF)
+ val payloadLen =
+ ((negResponse[4].toInt() and 0xFF) shl 24) or
+ ((negResponse[5].toInt() and 0xFF) shl 16) or
+ ((negResponse[6].toInt() and 0xFF) shl 8) or
+ (negResponse[7].toInt() and 0xFF)
+ return VitalWearHceSession(
+ direction = negotiatedMode,
+ maxChunkSize = maxChunk,
+ payloadLength = payloadLen,
+ )
+ }
+
+ private fun desiredMaxChunkSize(): Int {
+ val transceiveLimit = runCatching { isoDep.maxTransceiveLength }.getOrNull()
+ val maxByDevice = if (transceiveLimit != null && transceiveLimit > 0) {
+ // Extended APDU write overhead: 7-byte header + 4-byte offset.
+ (transceiveLimit - 11).coerceAtLeast(1)
+ } else {
+ PREFERRED_MAX_CHUNK_SIZE
+ }
+ val cappedByDevice = maxByDevice.coerceAtMost(PREFERRED_MAX_CHUNK_SIZE)
+ val cachedBest = cachedBestChunk()
+ return if (cachedBest == null) {
+ cappedByDevice
+ } else {
+ cachedBest.coerceIn(MIN_CHUNK_SIZE, cappedByDevice)
+ }
+ }
+
+ private fun chunkCandidates(): List {
+ val maxChunk = desiredMaxChunkSize()
+ val baselineCandidates = listOf(maxChunk, 2048, 1536, 1024, 768, 512, MIN_CHUNK_SIZE)
+ return baselineCandidates
+ .map { it.coerceAtMost(maxChunk) }
+ .filter { it in MIN_CHUNK_SIZE..maxChunk }
+ .distinct()
+ }
+
+ private fun deviceKey(): String {
+ val tagId = isoDep.tag?.id ?: return "unknown-device"
+ return tagId.toHex()
+ }
+
+ private fun cachedBestChunk(): Int? {
+ synchronized(bestChunkByDevice) {
+ return bestChunkByDevice[deviceKey()]
+ }
+ }
+
+ private fun rememberBestChunk(chunkSize: Int) {
+ synchronized(bestChunkByDevice) {
+ bestChunkByDevice[deviceKey()] = chunkSize
+ }
+ }
+
+ private fun delayForPoll(pollIndex: Int): Long {
+ return when {
+ pollIndex < 20 -> 80L
+ pollIndex < 56 -> 175L
+ else -> 300L
+ }
+ }
+
+ private fun intToShortBytes(value: Int): ByteArray = byteArrayOf(
+ ((value ushr 8) and 0xFF).toByte(),
+ (value and 0xFF).toByte()
+ )
+
private fun requireOk(response: ByteArray, context: String) {
if (statusWord(response) != SW_OK) error("$context failed: ${response.toHex()}")
}
diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/transfer/hce/VitalWearHceSession.kt b/app/src/main/java/com/github/nacabaro/vbhelper/transfer/hce/VitalWearHceSession.kt
new file mode 100644
index 0000000..64d9605
--- /dev/null
+++ b/app/src/main/java/com/github/nacabaro/vbhelper/transfer/hce/VitalWearHceSession.kt
@@ -0,0 +1,40 @@
+package com.github.nacabaro.vbhelper.transfer.hce
+
+internal enum class VitalWearHceTransferDirection {
+ WATCH_TO_PHONE,
+ PHONE_TO_WATCH,
+}
+
+internal interface VitalWearHceSessionInfo {
+ val direction: VitalWearHceTransferDirection
+ val maxChunkSize: Int
+ val payloadLength: Int
+
+ fun isWatchToPhone(): Boolean {
+ return direction == VitalWearHceTransferDirection.WATCH_TO_PHONE
+ }
+
+ fun isPhoneToWatch(): Boolean {
+ return direction == VitalWearHceTransferDirection.PHONE_TO_WATCH
+ }
+
+ fun canCommit(): Boolean {
+ return true
+ }
+
+ fun canPollStatus(): Boolean {
+ // Import confirmation polling only applies for phone->watch transfers.
+ return isPhoneToWatch()
+ }
+}
+
+internal data class VitalWearHceSession(
+ override val direction: VitalWearHceTransferDirection,
+ override val maxChunkSize: Int,
+ override val payloadLength: Int,
+) : VitalWearHceSessionInfo
+
+
+
+
+
From 04ea584c4fbeec5f0d98adf9254bc2df4a1b269a Mon Sep 17 00:00:00 2001
From: Taveon Nelson <74945303+457R0@users.noreply.github.com>
Date: Tue, 26 May 2026 03:20:04 -0500
Subject: [PATCH 12/13] Delete problems-report.html
removing useless files
---
build/reports/problems/problems-report.html | 663 --------------------
1 file changed, 663 deletions(-)
delete mode 100644 build/reports/problems/problems-report.html
diff --git a/build/reports/problems/problems-report.html b/build/reports/problems/problems-report.html
deleted file mode 100644
index 0cf967c..0000000
--- a/build/reports/problems/problems-report.html
+++ /dev/null
@@ -1,663 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
- Gradle Configuration Cache
-
-
-
-
-
-
- Loading...
-
-
-
-
-
-
-
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 13/13] 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(":"))
- }
-}