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] 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(":"))
+ }
+}