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