Overwrite with local version

This commit is contained in:
457R0 2026-05-03 18:07:07 -05:00
parent 895b704c8d
commit f829efae68
21 changed files with 590 additions and 65 deletions

View File

@ -5,6 +5,9 @@
<permission <permission
android:name="com.github.nacabaro.vbhelper.permission.ACCESS_TRANSFER_SEEN" android:name="com.github.nacabaro.vbhelper.permission.ACCESS_TRANSFER_SEEN"
android:protectionLevel="signature" /> android:protectionLevel="signature" />
<permission
android:name="com.github.nacabaro.vbhelper.permission.ACCESS_CARD_DATA"
android:protectionLevel="signature" />
<uses-permission android:name="android.permission.NFC" /> <uses-permission android:name="android.permission.NFC" />
<uses-feature android:name="android.hardware.nfc" android:required="true" /> <uses-feature android:name="android.hardware.nfc" android:required="true" />
@ -35,6 +38,12 @@
android:name="android.support.FILE_PROVIDER_PATHS" android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" /> android:resource="@xml/provider_paths" />
</provider> </provider>
<provider
android:name=".transfer.CardImportProvider"
android:authorities="com.github.nacabaro.vbhelper.cardimport"
android:exported="true"
android:readPermission="com.github.nacabaro.vbhelper.permission.ACCESS_CARD_DATA"
android:writePermission="com.github.nacabaro.vbhelper.permission.ACCESS_CARD_DATA" />
<provider <provider
android:name=".transfer.SharedTransferSeenProvider" android:name=".transfer.SharedTransferSeenProvider"
android:authorities="com.github.nacabaro.vbhelper.transferseen" android:authorities="com.github.nacabaro.vbhelper.transferseen"

View File

@ -128,9 +128,15 @@ interface UserCharacterDao {
@Query("SELECT * FROM BECharacterData WHERE id = :id") @Query("SELECT * FROM BECharacterData WHERE id = :id")
fun getBeData(id: Long): Flow<BECharacterData> fun getBeData(id: Long): Flow<BECharacterData>
@Query("SELECT * FROM BECharacterData WHERE id = :id")
suspend fun getBeDataOrNull(id: Long): BECharacterData?
@Query("SELECT * FROM VBCharacterData WHERE id = :id") @Query("SELECT * FROM VBCharacterData WHERE id = :id")
fun getVbData(id: Long): Flow<VBCharacterData> fun getVbData(id: Long): Flow<VBCharacterData>
@Query("SELECT * FROM VBCharacterData WHERE id = :id")
suspend fun getVbDataOrNull(id: Long): VBCharacterData?
@Query("SELECT * FROM SpecialMissions WHERE characterId = :id") @Query("SELECT * FROM SpecialMissions WHERE characterId = :id")
fun getSpecialMissions(id: Long): Flow<List<SpecialMissions>> fun getSpecialMissions(id: Long): Flow<List<SpecialMissions>>

View File

@ -133,6 +133,7 @@ fun ScanScreenPreview() {
scanScreenController = object: ScanScreenController { scanScreenController = object: ScanScreenController {
override val secretsFlow = MutableStateFlow<Secrets>(Secrets.getDefaultInstance()) override val secretsFlow = MutableStateFlow<Secrets>(Secrets.getDefaultInstance())
override val detectedTransportFlow: StateFlow<DetectedTransport> = MutableStateFlow(DetectedTransport.UNKNOWN) override val detectedTransportFlow: StateFlow<DetectedTransport> = MutableStateFlow(DetectedTransport.UNKNOWN)
override val transferStatusFlow: StateFlow<String?> = MutableStateFlow(null)
override fun unregisterActivityLifecycleListener(key: String) { } override fun unregisterActivityLifecycleListener(key: String) { }
override fun registerActivityLifecycleListener( override fun registerActivityLifecycleListener(
key: String, key: String,

View File

@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.StateFlow
interface ScanScreenController { interface ScanScreenController {
val secretsFlow: Flow<Secrets> val secretsFlow: Flow<Secrets>
val detectedTransportFlow: StateFlow<DetectedTransport> val detectedTransportFlow: StateFlow<DetectedTransport>
val transferStatusFlow: StateFlow<String?>
enum class WriteResult { enum class WriteResult {
MOVE_CONFIRMED, MOVE_CONFIRMED,

View File

@ -47,8 +47,11 @@ class ScanScreenControllerImpl(
override val detectedTransportFlow: StateFlow<DetectedTransport> override val detectedTransportFlow: StateFlow<DetectedTransport>
get() = _detectedTransportFlow get() = _detectedTransportFlow
override val transferStatusFlow: StateFlow<String?>
get() = _transferStatusFlow
private val _detectedTransportFlow = MutableStateFlow(DetectedTransport.UNKNOWN) private val _detectedTransportFlow = MutableStateFlow(DetectedTransport.UNKNOWN)
private val _transferStatusFlow = MutableStateFlow<String?>(null)
private var lastScannedCharacter: NfcCharacter? = null private var lastScannedCharacter: NfcCharacter? = null
private var lastRequestedCharacterId: Long? = null private var lastRequestedCharacterId: Long? = null
private val nfcAdapter: NfcAdapter private val nfcAdapter: NfcAdapter
@ -135,6 +138,7 @@ class ScanScreenControllerImpl(
override fun onClickRead(secrets: Secrets, onComplete: () -> Unit, onMultipleCards: (List<Card>) -> Unit) { override fun onClickRead(secrets: Secrets, onComplete: () -> Unit, onMultipleCards: (List<Card>) -> Unit) {
_detectedTransportFlow.value = DetectedTransport.UNKNOWN _detectedTransportFlow.value = DetectedTransport.UNKNOWN
setTransferStatus(R.string.scan_transfer_waiting_tap)
handleTag( handleTag(
secrets, secrets,
nfcAHandler = { tagCommunicator -> nfcAHandler = { tagCommunicator ->
@ -185,6 +189,7 @@ class ScanScreenControllerImpl(
override fun onClickWrite(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: (ScanScreenController.WriteResult) -> Unit) { override fun onClickWrite(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: (ScanScreenController.WriteResult) -> Unit) {
_detectedTransportFlow.value = DetectedTransport.UNKNOWN _detectedTransportFlow.value = DetectedTransport.UNKNOWN
setTransferStatus(R.string.scan_transfer_waiting_tap)
handleTag( handleTag(
secrets, secrets,
nfcAHandler = { tagCommunicator -> nfcAHandler = { tagCommunicator ->
@ -207,10 +212,18 @@ class ScanScreenControllerImpl(
} }
onComplete.invoke(ScanScreenController.WriteResult.MOVE_CONFIRMED) onComplete.invoke(ScanScreenController.WriteResult.MOVE_CONFIRMED)
componentActivity.getString(R.string.scan_sent_character_success) componentActivity.getString(R.string.scan_sent_character_success)
} catch (e: Throwable) { } catch (writeError: Throwable) {
Log.e("TAG", e.stackTraceToString()) 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) componentActivity.getString(R.string.scan_error_generic)
} }
}
}, },
isoDepHandler = { isoDep -> isoDepHandler = { isoDep ->
val characterId = lastRequestedCharacterId val characterId = lastRequestedCharacterId
@ -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) ----------- // ---- Check card (NFC-A only; ISO-DEP watches don't need a DIM prep) -----------
override fun onClickCheckCard(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: () -> Unit) { override fun onClickCheckCard(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: () -> Unit) {
_detectedTransportFlow.value = DetectedTransport.UNKNOWN _detectedTransportFlow.value = DetectedTransport.UNKNOWN
setTransferStatus(R.string.scan_transfer_waiting_tap)
handleTag( handleTag(
secrets, secrets,
nfcAHandler = { tagCommunicator -> nfcAHandler = { tagCommunicator ->
@ -284,6 +329,7 @@ class ScanScreenControllerImpl(
override fun cancelRead() { override fun cancelRead() {
_detectedTransportFlow.value = DetectedTransport.UNKNOWN _detectedTransportFlow.value = DetectedTransport.UNKNOWN
setTransferStatus(R.string.scan_transfer_cancelled)
activeReaderSessionId = readerSessionCounter.incrementAndGet() activeReaderSessionId = readerSessionCounter.incrementAndGet()
isHandlingTag.set(false) isHandlingTag.set(false)
synchronized(tagStateLock) { synchronized(tagStateLock) {
@ -362,6 +408,7 @@ class ScanScreenControllerImpl(
} }
} }
if (shouldHandleTag) { if (shouldHandleTag) {
setTransferStatus(R.string.scan_transfer_detected_keep_tap)
// Detect transport once per tap and lock to that route for this transfer. // Detect transport once per tap and lock to that route for this transfer.
val isoDep = IsoDep.get(tag) val isoDep = IsoDep.get(tag)
@ -380,27 +427,32 @@ class ScanScreenControllerImpl(
_detectedTransportFlow.value = DetectedTransport.ISO_DEP _detectedTransportFlow.value = DetectedTransport.ISO_DEP
isoDepTarget.connect() isoDepTarget.connect()
isoDepTarget.use { isoDepTarget.use {
setTransferStatus(R.string.scan_transfer_in_progress)
val successText = isoDepAction.invoke(isoDepTarget) val successText = isoDepAction.invoke(isoDepTarget)
componentActivity.runOnUiThread { componentActivity.runOnUiThread {
Toast.makeText(componentActivity, successText, Toast.LENGTH_SHORT).show() Toast.makeText(componentActivity, successText, Toast.LENGTH_SHORT).show()
setTransferStatus(R.string.scan_transfer_complete_remove)
} }
} }
} catch (e: Throwable) { } catch (e: Throwable) {
Log.e("NFC_HCE", "IsoDep transfer failed", e) Log.e("NFC_HCE", "IsoDep transfer failed", e)
componentActivity.runOnUiThread { componentActivity.runOnUiThread {
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_error_generic), Toast.LENGTH_SHORT).show() 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) { } else if (hasNfcARoute) {
if (!handleNfcATag(tag, secrets, nfcAHandler)) { if (!handleNfcATag(tag, secrets, nfcAHandler)) {
componentActivity.runOnUiThread { componentActivity.runOnUiThread {
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_tag_not_vb), Toast.LENGTH_SHORT).show() Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_tag_not_vb), Toast.LENGTH_SHORT).show()
setTransferStatus(R.string.scan_transfer_failed_try_again)
} }
} }
} else { } else {
_detectedTransportFlow.value = DetectedTransport.UNKNOWN _detectedTransportFlow.value = DetectedTransport.UNKNOWN
componentActivity.runOnUiThread { componentActivity.runOnUiThread {
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_tag_not_vb), Toast.LENGTH_SHORT).show() Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_tag_not_vb), Toast.LENGTH_SHORT).show()
setTransferStatus(R.string.scan_transfer_failed_try_again)
} }
} }
} finally { } finally {
@ -443,9 +495,11 @@ class ScanScreenControllerImpl(
nfcData.connect() nfcData.connect()
nfcData.use { nfcData.use {
val tagCommunicator = TagCommunicator.getInstance(nfcData, secrets.getCryptographicTransformerMap()) val tagCommunicator = TagCommunicator.getInstance(nfcData, secrets.getCryptographicTransformerMap())
setTransferStatus(R.string.scan_transfer_in_progress)
val successText = nfcAHandler(tagCommunicator) val successText = nfcAHandler(tagCommunicator)
componentActivity.runOnUiThread { componentActivity.runOnUiThread {
Toast.makeText(componentActivity, successText, Toast.LENGTH_SHORT).show() Toast.makeText(componentActivity, successText, Toast.LENGTH_SHORT).show()
setTransferStatus(R.string.scan_transfer_complete_remove)
} }
} }
true true
@ -455,17 +509,23 @@ class ScanScreenControllerImpl(
Log.w("NFC_A", "Ignoring stale NFC-A tag session; waiting for a fresh tap", e) Log.w("NFC_A", "Ignoring stale NFC-A tag session; waiting for a fresh tap", e)
componentActivity.runOnUiThread { componentActivity.runOnUiThread {
Toast.makeText(componentActivity, "Tag session expired. Please tap again.", Toast.LENGTH_SHORT).show() Toast.makeText(componentActivity, "Tag session expired. Please tap again.", Toast.LENGTH_SHORT).show()
setTransferStatus(R.string.scan_transfer_waiting_tap)
} }
return false return false
} }
Log.e("NFC_A", "NfcA transfer failed", e) Log.e("NFC_A", "NfcA transfer failed", e)
componentActivity.runOnUiThread { componentActivity.runOnUiThread {
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_error_generic), Toast.LENGTH_SHORT).show() Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_error_generic), Toast.LENGTH_SHORT).show()
setTransferStatus(R.string.scan_transfer_failed_try_again)
} }
true true
} }
} }
private fun setTransferStatus(messageRes: Int) {
_transferStatusFlow.value = componentActivity.getString(messageRes)
}
private fun disableReaderModeSafely() { private fun disableReaderModeSafely() {
if (!nfcAdapter.isEnabled) return if (!nfcAdapter.isEnabled) return
runCatching { nfcAdapter.disableReaderMode(componentActivity) } runCatching { nfcAdapter.disableReaderMode(componentActivity) }

View File

@ -11,6 +11,7 @@ import com.github.cfogrady.vbnfc.vb.SpecialMission
import com.github.cfogrady.vbnfc.vb.VBNfcCharacter import com.github.cfogrady.vbnfc.vb.VBNfcCharacter
import com.github.nacabaro.vbhelper.database.AppDatabase import com.github.nacabaro.vbhelper.database.AppDatabase
import com.github.nacabaro.vbhelper.di.VBHelper 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.UserCharacter
import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData
import com.github.nacabaro.vbhelper.dtos.CharacterDtos import com.github.nacabaro.vbhelper.dtos.CharacterDtos
@ -22,6 +23,11 @@ import java.util.Date
class ToNfcConverter( class ToNfcConverter(
private val componentActivity: ComponentActivity 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 application: VBHelper = componentActivity.applicationContext as VBHelper
private val database: AppDatabase = application.container.db private val database: AppDatabase = application.container.db
@ -41,8 +47,12 @@ class ToNfcConverter(
.characterDao() .characterDao()
.getCharacterInfo(userCharacter.charId) .getCharacterInfo(userCharacter.charId)
val card = database.cardDao().getCardByCharacterIdSync(characterId) // Use the character's own type as the authoritative signal for encoding.
val shouldEncodeAsBem = card?.isBEm ?: (userCharacter.characterType == DeviceType.BEDevice) // 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) return if (shouldEncodeAsBem)
nfcToBENfc(characterId, characterInfo, userCharacter) nfcToBENfc(characterId, characterInfo, userCharacter)
@ -186,8 +196,32 @@ class ToNfcConverter(
): BENfcCharacter { ): BENfcCharacter {
val beData = database val beData = database
.userCharacterDao() .userCharacterDao()
.getBeData(characterId) .getBeDataOrNull(characterId)
.first() ?: 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) val paddedTransformationArray = generateTransformationHistory(characterId)
@ -264,11 +298,15 @@ class ToNfcConverter(
"Day: ${calendar.get(Calendar.DAY_OF_MONTH)}" "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( NfcCharacter.Transformation(
toCharIndex = it.monIndex.toUByte(), toCharIndex = it.monIndex.toUByte(),
year = calendar year = normalizedYear.toUShort(),
.get(Calendar.YEAR)
.toUShort(),
month = (calendar month = (calendar
.get(Calendar.MONTH) + 1) .get(Calendar.MONTH) + 1)
.toUByte(), .toUByte(),

View File

@ -18,6 +18,7 @@ import com.github.nacabaro.vbhelper.R
@Composable @Composable
fun ActionScreen( fun ActionScreen(
topBannerText: String, topBannerText: String,
transferStatusMessage: String?,
onClickCancel: () -> Unit, onClickCancel: () -> Unit,
) { ) {
Scaffold ( Scaffold (
@ -36,6 +37,12 @@ fun ActionScreen(
.fillMaxSize() .fillMaxSize()
) { ) {
Text(stringResource(R.string.action_place_near_reader)) 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( Button(
onClick = onClickCancel, onClick = onClickCancel,
modifier = Modifier.padding(16.dp) modifier = Modifier.padding(16.dp)

View File

@ -25,6 +25,7 @@ fun ReadingScreen(
onComplete: () -> Unit onComplete: () -> Unit
) { ) {
val secrets by scanScreenController.secretsFlow.collectAsState(null) val secrets by scanScreenController.secretsFlow.collectAsState(null)
val transferStatus by scanScreenController.transferStatusFlow.collectAsState(null)
var cardsRead by remember { mutableStateOf<List<Card>?>(null) } var cardsRead by remember { mutableStateOf<List<Card>?>(null) }
@ -116,7 +117,10 @@ fun ReadingScreen(
} }
if (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") Log.d(TAG, "ActionScreen.onCancel: cancelRead + onCancel")
readingScreen = false readingScreen = false
scanScreenController.cancelRead() scanScreenController.cancelRead()

View File

@ -30,6 +30,7 @@ fun WritingScreen(
onCancel: () -> Unit, onCancel: () -> Unit,
) { ) {
val secrets by scanScreenController.secretsFlow.collectAsState(null) val secrets by scanScreenController.secretsFlow.collectAsState(null)
val transferStatus by scanScreenController.transferStatusFlow.collectAsState(null)
val application = LocalContext.current.applicationContext as VBHelper val application = LocalContext.current.applicationContext as VBHelper
val storageRepository = StorageRepository(application.container.db) val storageRepository = StorageRepository(application.container.db)
@ -103,7 +104,10 @@ fun WritingScreen(
) )
} else if (!isDoneSendingCard) { } else if (!isDoneSendingCard) {
writing = true writing = true
ActionScreen( topBannerText = stringResource(R.string.sending_card_title)) { ActionScreen(
topBannerText = stringResource(R.string.sending_card_title),
transferStatusMessage = transferStatus,
) {
scanScreenController.cancelRead() scanScreenController.cancelRead()
onCancel() onCancel()
} }
@ -121,7 +125,10 @@ fun WritingScreen(
) )
} else if (!isDoneWritingCharacter) { } else if (!isDoneWritingCharacter) {
writing = true writing = true
ActionScreen(topBannerText = stringResource(R.string.writing_character_action_title)) { ActionScreen(
topBannerText = stringResource(R.string.writing_character_action_title),
transferStatusMessage = transferStatus,
) {
isDoneSendingCard = false isDoneSendingCard = false
scanScreenController.cancelRead() scanScreenController.cancelRead()
onCancel() onCancel()

View File

@ -2,9 +2,30 @@ package com.github.nacabaro.vbhelper.source
import com.github.cfogrady.vitalwear.protos.Character import com.github.cfogrady.vitalwear.protos.Character
import com.github.nacabaro.vbhelper.database.AppDatabase import com.github.nacabaro.vbhelper.database.AppDatabase
import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData
import com.github.nacabaro.vbhelper.utils.DeviceType import com.github.nacabaro.vbhelper.utils.DeviceType
import kotlinx.coroutines.flow.firstOrNull 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( class VitalWearCharacterExporter(
private val database: AppDatabase private val database: AppDatabase
@ -16,22 +37,29 @@ class VitalWearCharacterExporter(
suspend fun buildCharacterProto(characterId: Long): Character { suspend fun buildCharacterProto(characterId: Long): Character {
val characterWithSprites = database.userCharacterDao().getCharacterWithSprites(characterId) val characterWithSprites = database.userCharacterDao().getCharacterWithSprites(characterId)
val userCharacter = database.userCharacterDao().getCharacter(characterId) val userCharacter = database.userCharacterDao().getCharacter(characterId)
val characterInfo = database.characterDao().getCharacterInfo(userCharacter.charId)
val vwSettings = database.vitalWearSettingsDao().getByCharacterId(characterId) val vwSettings = database.vitalWearSettingsDao().getByCharacterId(characterId)
val card = database.cardDao().getCardByCharacterIdSync(characterId) val card = database.cardDao().getCardByCharacterIdSync(characterId)
?: error("Card not found for character $characterId") ?: error("Card not found for character $characterId")
val cardProgress = database.cardProgressDao().getCardProgressSync(card.id) ?: 0 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() return Character.newBuilder()
.setCardId(card.cardId) .setCardId(card.cardId)
.setCardName(card.name) .setCardName(card.name)
.setCharacterStats( .setCharacterStats(
Character.CharacterStats.newBuilder() Character.CharacterStats.newBuilder()
.setSlotId(database.userCharacterDao().getCharacterInfo(characterId).charaIndex) .setSlotId(characterInfo.charId)
.setVitals(characterWithSprites.vitalPoints) .setVitals(characterWithSprites.vitalPoints)
.setTrainingTimeRemainingInSeconds(resolveTrainingSeconds(characterId, userCharacter.characterType)) .setTrainingTimeRemainingInSeconds(resolveTrainingSeconds(userCharacter.characterType, beData))
.setTimeUntilNextTransformation(characterWithSprites.transformationCountdown.toLong() * 60L) .setTimeUntilNextTransformation(normalizedTransformationCountdownMinutes.toLong() * 60L)
.setTrainedBp(resolveTrainedBp(characterId, userCharacter.characterType)) .setTrainedBp(resolveTrainedBp(beData))
.setTrainedHp(resolveTrainedHp(characterId, userCharacter.characterType)) .setTrainedHp(resolveTrainedHp(beData))
.setTrainedAp(resolveTrainedAp(characterId, userCharacter.characterType)) .setTrainedAp(resolveTrainedAp(beData))
.setTrainedPp(characterWithSprites.trophies) .setTrainedPp(characterWithSprites.trophies)
.setInjured(characterWithSprites.injuryStatus.name.lowercase().contains("inj")) .setInjured(characterWithSprites.injuryStatus.name.lowercase().contains("inj"))
.setAccumulatedDailyInjuries(vwSettings?.accumulatedDailyInjuries ?: 0) .setAccumulatedDailyInjuries(vwSettings?.accumulatedDailyInjuries ?: 0)
@ -40,6 +68,29 @@ class VitalWearCharacterExporter(
.setTotalWins(characterWithSprites.totalBattlesWon) .setTotalWins(characterWithSprites.totalBattlesWon)
.setCurrentPhaseWins(characterWithSprites.currentPhaseBattlesWon) .setCurrentPhaseWins(characterWithSprites.currentPhaseBattlesWon)
.setMood(characterWithSprites.mood) .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() .build()
) )
.setSettings( .setSettings(
@ -61,32 +112,23 @@ class VitalWearCharacterExporter(
.build() .build()
} }
private fun resolveTrainingSeconds(characterId: Long, deviceType: DeviceType): Long {
if (deviceType != DeviceType.BEDevice) return 0L private fun resolveTrainedBp(beData: com.github.nacabaro.vbhelper.domain.device_data.BECharacterData?): Int {
return database.userCharacterDao().getBeData(characterId).valueOrNull()?.remainingTrainingTimeInMinutes?.toLong()?.times(60L) ?: 0L return beData?.trainingBp ?: 0
} }
private fun resolveTrainedBp(characterId: Long, deviceType: DeviceType): Int { private fun resolveTrainedHp(beData: com.github.nacabaro.vbhelper.domain.device_data.BECharacterData?): Int {
return if (deviceType == DeviceType.BEDevice) { return beData?.trainingHp ?: 0
database.userCharacterDao().getBeData(characterId).valueOrNull()?.trainingBp ?: 0
} else 0
} }
private fun resolveTrainedHp(characterId: Long, deviceType: DeviceType): Int { private fun resolveTrainedAp(beData: com.github.nacabaro.vbhelper.domain.device_data.BECharacterData?): Int {
return if (deviceType == DeviceType.BEDevice) { return beData?.trainingAp ?: 0
database.userCharacterDao().getBeData(characterId).valueOrNull()?.trainingHp ?: 0
} else 0
} }
private fun resolveTrainedAp(characterId: Long, deviceType: DeviceType): Int { private suspend fun hasPossibleTransformations(cardCharacterId: Long): Boolean {
return if (deviceType == DeviceType.BEDevice) { return database.characterDao().getEvolutionRequirementsForCard(cardCharacterId).firstOrNull()?.isNotEmpty() == true
database.userCharacterDao().getBeData(characterId).valueOrNull()?.trainingAp ?: 0
} else 0
} }
private fun <T> kotlinx.coroutines.flow.Flow<T>.valueOrNull(): T? {
return runBlocking { firstOrNull() }
}
private fun resolveAllowedBattles(rawValue: Int?): Character.Settings.AllowedBattles { private fun resolveAllowedBattles(rawValue: Int?): Character.Settings.AllowedBattles {
if (rawValue == null) return Character.Settings.AllowedBattles.CARD_ONLY if (rawValue == null) return Character.Settings.AllowedBattles.CARD_ONLY

View File

@ -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.VBCharacterData
import com.github.nacabaro.vbhelper.domain.device_data.VitalWearCharacterSettings import com.github.nacabaro.vbhelper.domain.device_data.VitalWearCharacterSettings
import com.github.nacabaro.vbhelper.utils.DeviceType import com.github.nacabaro.vbhelper.utils.DeviceType
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlin.math.max import kotlin.math.max
@ -43,23 +44,37 @@ class VitalWearCharacterImporter(
val totalWins = character.characterStats.totalWins.coerceAtMost(totalBattles) val totalWins = character.characterStats.totalWins.coerceAtMost(totalBattles)
val currentPhaseBattles = character.characterStats.currentPhaseBattles val currentPhaseBattles = character.characterStats.currentPhaseBattles
val currentPhaseWins = character.characterStats.currentPhaseWins.coerceAtMost(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( val userCharacterId = database.userCharacterDao().insertCharacterData(
UserCharacter( UserCharacter(
charId = cardCharacter.id, 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, mood = character.characterStats.mood,
vitalPoints = character.characterStats.vitals, vitalPoints = character.characterStats.vitals,
transformationCountdown = secondsToMinutes(character.characterStats.timeUntilNextTransformation), transformationCountdown = normalizedTransformationCountdown,
injuryStatus = resolveInjuryStatus(character.characterStats.injured), injuryStatus = resolveInjuryStatus(character.characterStats.injured),
trophies = character.characterStats.trainedPp, trophies = character.characterStats.trainedPp,
currentPhaseBattlesWon = currentPhaseWins, currentPhaseBattlesWon = currentPhaseWins,
currentPhaseBattlesLost = (currentPhaseBattles - currentPhaseWins).coerceAtLeast(0), currentPhaseBattlesLost = (currentPhaseBattles - currentPhaseWins).coerceAtLeast(0),
totalBattlesWon = totalWins, totalBattlesWon = totalWins,
totalBattlesLost = (totalBattles - totalWins).coerceAtLeast(0), totalBattlesLost = (totalBattles - totalWins).coerceAtLeast(0),
activityLevel = 0, activityLevel = character.characterStats.activityLevel.coerceAtLeast(0),
heartRateCurrent = 0, heartRateCurrent = character.characterStats.heartRateCurrent.coerceAtLeast(0),
characterType = deviceType, characterType = deviceType,
isActive = true isActive = true
) )
@ -76,7 +91,8 @@ class VitalWearCharacterImporter(
) )
} }
if (importedCard.isBEm) { if (isBeCharacter) {
val abilityRarity = resolveAbilityRarity(character.characterStats.abilityRarity)
database.userCharacterDao().insertBECharacterData( database.userCharacterDao().insertBECharacterData(
BECharacterData( BECharacterData(
id = userCharacterId, id = userCharacterId,
@ -84,32 +100,40 @@ class VitalWearCharacterImporter(
trainingAp = character.characterStats.trainedAp, trainingAp = character.characterStats.trainedAp,
trainingBp = character.characterStats.trainedBp, trainingBp = character.characterStats.trainedBp,
remainingTrainingTimeInMinutes = secondsToMinutes(character.characterStats.trainingTimeRemainingInSeconds), remainingTrainingTimeInMinutes = secondsToMinutes(character.characterStats.trainingTimeRemainingInSeconds),
itemEffectMentalStateValue = 0, itemEffectMentalStateValue = character.characterStats.itemEffectMentalStateValue,
itemEffectMentalStateMinutesRemaining = 0, itemEffectMentalStateMinutesRemaining = character.characterStats.itemEffectMentalStateMinutesRemaining,
itemEffectActivityLevelValue = 0, itemEffectActivityLevelValue = character.characterStats.itemEffectActivityLevelValue,
itemEffectActivityLevelMinutesRemaining = 0, itemEffectActivityLevelMinutesRemaining = character.characterStats.itemEffectActivityLevelMinutesRemaining,
itemEffectVitalPointsChangeValue = 0, itemEffectVitalPointsChangeValue = character.characterStats.itemEffectVitalPointsChangeValue,
itemEffectVitalPointsChangeMinutesRemaining = 0, itemEffectVitalPointsChangeMinutesRemaining = character.characterStats.itemEffectVitalPointsChangeMinutesRemaining,
abilityRarity = resolveDefaultAbilityRarity(), abilityRarity = abilityRarity,
abilityType = 0, abilityType = character.characterStats.abilityType,
abilityBranch = 0, abilityBranch = character.characterStats.abilityBranch,
abilityReset = 0, abilityReset = character.characterStats.abilityReset,
rank = 0, rank = character.characterStats.rank,
itemType = 0, itemType = character.characterStats.itemType,
itemMultiplier = 0, itemMultiplier = character.characterStats.itemMultiplier,
itemRemainingTime = 0, itemRemainingTime = character.characterStats.itemRemainingTime,
otp0 = "", otp0 = "",
otp1 = "", otp1 = "",
minorVersion = 0, minorVersion = character.characterStats.firmwareMinorVersion,
majorVersion = 0 majorVersion = character.characterStats.firmwareMajorVersion
) )
) )
} else { } else {
database.userCharacterDao().insertVBCharacterData( database.userCharacterDao().insertVBCharacterData(
VBCharacterData( VBCharacterData(
id = userCharacterId, id = userCharacterId,
generation = max(character.transformationHistoryCount - 1, 0), generation = if (character.characterStats.generation > 0) {
totalTrophies = character.characterStats.trainedPp.coerceAtLeast(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( return ImportResult(
success = true, success = true,
message = "Imported ${importedCard.name} slot $slotId from VitalWear." message = "Imported ${importedCard.name} slot $slotId from VitalWear."
@ -203,7 +236,14 @@ class VitalWearCharacterImporter(
if (seconds <= 0L) { if (seconds <= 0L) {
return 0 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 { private fun resolveInjuryStatus(injured: Boolean): NfcCharacter.InjuryStatus {
@ -225,6 +265,11 @@ class VitalWearCharacterImporter(
return enumValues<NfcCharacter.AbilityRarity>().first() return enumValues<NfcCharacter.AbilityRarity>().first()
} }
private fun resolveAbilityRarity(rawValue: Int): NfcCharacter.AbilityRarity {
val rarities = enumValues<NfcCharacter.AbilityRarity>()
return rarities.getOrElse(rawValue) { resolveDefaultAbilityRarity() }
}
private fun markSeen(cardName: String, slotId: Int, cardId: Long, timestamp: Long) { private fun markSeen(cardName: String, slotId: Int, cardId: Long, timestamp: Long) {
database.dexDao().insertCharacter(slotId, cardId, timestamp) database.dexDao().insertCharacter(slotId, cardId, timestamp)
transferSeenDao.markSeen(cardName, slotId, timestamp) transferSeenDao.markSeen(cardName, slotId, timestamp)

View File

@ -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
}
}

View File

@ -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<out String>?, selection: String?,
selectionArgs: Array<out String>?, 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<out String>?): Int = 0
override fun update(uri: Uri, values: ContentValues?, selection: String?,
selectionArgs: Array<out String>?): Int = 0
}

View File

@ -8,6 +8,12 @@ message Character {
int32 card_id = 2; int32 card_id = 2;
message CharacterStats { message CharacterStats {
enum TransferDeviceType {
TRANSFER_DEVICE_TYPE_UNSPECIFIED = 0;
TRANSFER_DEVICE_TYPE_VB = 1;
TRANSFER_DEVICE_TYPE_BE = 2;
}
int32 slot_id = 1; int32 slot_id = 1;
int32 vitals = 2; int32 vitals = 2;
int64 training_time_remaining_in_seconds = 3; int64 training_time_remaining_in_seconds = 3;
@ -23,6 +29,29 @@ message Character {
int32 total_wins = 13; int32 total_wins = 13;
int32 current_phase_wins = 14; int32 current_phase_wins = 14;
int32 mood = 15; 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; CharacterStats character_stats = 3;

View File

@ -58,6 +58,12 @@
<string name="scan_nfc_must_be_enabled">O NFC precisa estar ativado.</string> <string name="scan_nfc_must_be_enabled">O NFC precisa estar ativado.</string>
<string name="scan_target_device_full">O dispositivo de destino está cheio (ativo + backup). Remova um personagem antes de enviar.</string> <string name="scan_target_device_full">O dispositivo de destino está cheio (ativo + backup). Remova um personagem antes de enviar.</string>
<string name="scan_transfer_skipped_storage_state">Transferência ignorada. O envio só é permitido quando o brinquedo tem um personagem ativo e o slot de backup está vazio.</string> <string name="scan_transfer_skipped_storage_state">Transferência ignorada. O envio só é permitido quando o brinquedo tem um personagem ativo e o slot de backup está vazio.</string>
<string name="scan_transfer_waiting_tap">Pronto. Encoste e mantenha o celular e o relógio juntos até concluir.</string>
<string name="scan_transfer_detected_keep_tap">Conexão detectada. Mantenha os dispositivos juntos...</string>
<string name="scan_transfer_in_progress">Transferindo agora. Não afaste o celular ainda.</string>
<string name="scan_transfer_complete_remove">Transferência concluída. Agora você pode afastar o celular.</string>
<string name="scan_transfer_failed_try_again">Falha na transferência. Reposicione os dispositivos e tente novamente.</string>
<string name="scan_transfer_cancelled">Transferência cancelada.</string>
<string name="settings_title">Configurações</string> <string name="settings_title">Configurações</string>

View File

@ -62,6 +62,12 @@
<string name="scan_nfc_must_be_enabled">NFC must be enabled</string> <string name="scan_nfc_must_be_enabled">NFC must be enabled</string>
<string name="scan_target_device_full">Target device is full (active + backup). Remove one character before sending.</string> <string name="scan_target_device_full">Target device is full (active + backup). Remove one character before sending.</string>
<string name="scan_transfer_skipped_storage_state">Transfer skipped. Send is allowed only when the toy has an active character and an empty backup slot.</string> <string name="scan_transfer_skipped_storage_state">Transfer skipped. Send is allowed only when the toy has an active character and an empty backup slot.</string>
<string name="scan_transfer_waiting_tap">Ready. Tap and hold phone/watch together until complete.</string>
<string name="scan_transfer_detected_keep_tap">Connection detected. Keep devices together...</string>
<string name="scan_transfer_in_progress">Transferring now. Do not remove phone yet.</string>
<string name="scan_transfer_complete_remove">Transfer complete. You can remove the phone now.</string>
<string name="scan_transfer_failed_try_again">Transfer failed. Reposition devices and try again.</string>
<string name="scan_transfer_cancelled">Transfer cancelled.</string>
<string name="settings_title">Settings</string> <string name="settings_title">Settings</string>

View File

@ -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,
)
}
}

View File

@ -1,11 +1,61 @@
package com.github.nacabaro.vbhelper.source 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.domain.card.Card
import com.github.nacabaro.vbhelper.utils.DeviceType
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull import org.junit.Assert.assertNotNull
import org.junit.Test import org.junit.Test
class VitalWearCharacterImporterTest { 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 @Test
fun selectImportedCard_prefersUniqueCardIdWhenNameWasRenamed() { fun selectImportedCard_prefersUniqueCardIdWhenNameWasRenamed() {
val selected = selectImportedCard( val selected = selectImportedCard(

File diff suppressed because one or more lines are too long

View File

@ -16,7 +16,7 @@ protobufGradlePlugin = "0.9.6"
protobufJavalite = "4.33.4" protobufJavalite = "4.33.4"
roomRuntime = "2.8.4" roomRuntime = "2.8.4"
vbNfcReader = "0.2.0-SNAPSHOT" vbNfcReader = "0.2.0-SNAPSHOT"
dimReader = "2.1.0" dimReader = "2.0.0"
[libraries] [libraries]
androidx-compose-material = { module = "androidx.compose.material:material" } androidx-compose-material = { module = "androidx.compose.material:material" }

View File

@ -22,3 +22,15 @@ dependencyResolutionManagement {
rootProject.name = "VBHelper" rootProject.name = "VBHelper"
include(":app") 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(":"))
}
}