VBH is the companion app now

Got tired of having to reinstall all three apps so i ported the companion apps features to vitalwear, now you only need VBH and Vitalwear.

I've also added emblems to items in icons so user isnt confused.
This commit is contained in:
Taveon Nelson 2026-05-18 04:57:57 -05:00
parent f829efae68
commit 8fd4f6c414
15 changed files with 414 additions and 28 deletions

View File

@ -54,6 +54,7 @@
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:label="@string/app_name" android:label="@string/app_name"
android:launchMode="singleTask"
android:theme="@style/Theme.VBHelper"> android:theme="@style/Theme.VBHelper">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />

View File

@ -19,11 +19,14 @@ import com.github.nacabaro.vbhelper.screens.cardScreen.CardScreenControllerImpl
import com.github.nacabaro.vbhelper.screens.spriteViewer.SpriteViewerControllerImpl import com.github.nacabaro.vbhelper.screens.spriteViewer.SpriteViewerControllerImpl
import com.github.nacabaro.vbhelper.screens.storageScreen.StorageScreenControllerImpl import com.github.nacabaro.vbhelper.screens.storageScreen.StorageScreenControllerImpl
import com.github.nacabaro.vbhelper.ui.theme.VBHelperTheme import com.github.nacabaro.vbhelper.ui.theme.VBHelperTheme
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val onActivityLifecycleListeners = HashMap<String, ActivityLifecycleListener>() private val onActivityLifecycleListeners = HashMap<String, ActivityLifecycleListener>()
private var initialRoute: String? = null private var initialRoute: String? = null
private val deepLinkNavigationEvents = MutableSharedFlow<String>(extraBufferCapacity = 1)
private fun registerActivityLifecycleListener(key: String, activityLifecycleListener: ActivityLifecycleListener) { private fun registerActivityLifecycleListener(key: String, activityLifecycleListener: ActivityLifecycleListener) {
if( onActivityLifecycleListeners[key] != null) { if( onActivityLifecycleListeners[key] != null) {
@ -57,6 +60,7 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge() enableEdgeToEdge()
initialRoute = getInitialRouteFromIntent(intent) initialRoute = getInitialRouteFromIntent(intent)
initialRoute?.let { deepLinkNavigationEvents.tryEmit(it) }
setContent { setContent {
VBHelperTheme { VBHelperTheme {
@ -69,7 +73,8 @@ class MainActivity : ComponentActivity() {
storageScreenController = storageScreenController, storageScreenController = storageScreenController,
spriteViewerController = spriteViewerController, spriteViewerController = spriteViewerController,
cardScreenController = cardScreenController, cardScreenController = cardScreenController,
initialRoute = initialRoute initialRoute = initialRoute,
deepLinkNavigationEvents = deepLinkNavigationEvents.asSharedFlow(),
) )
} }
} }
@ -97,6 +102,7 @@ class MainActivity : ComponentActivity() {
super.onNewIntent(intent) super.onNewIntent(intent)
setIntent(intent) setIntent(intent)
initialRoute = getInitialRouteFromIntent(intent) initialRoute = getInitialRouteFromIntent(intent)
initialRoute?.let { deepLinkNavigationEvents.tryEmit(it) }
} }
private fun getInitialRouteFromIntent(intent: Intent?): String? { private fun getInitialRouteFromIntent(intent: Intent?): String? {
@ -120,7 +126,8 @@ class MainActivity : ComponentActivity() {
homeScreenController: HomeScreenControllerImpl, homeScreenController: HomeScreenControllerImpl,
spriteViewerController: SpriteViewerControllerImpl, spriteViewerController: SpriteViewerControllerImpl,
cardScreenController: CardScreenControllerImpl, cardScreenController: CardScreenControllerImpl,
initialRoute: String? = null initialRoute: String? = null,
deepLinkNavigationEvents: kotlinx.coroutines.flow.Flow<String>,
) { ) {
AppNavigation( AppNavigation(
applicationNavigationHandlers = AppNavigationHandlers( applicationNavigationHandlers = AppNavigationHandlers(
@ -133,7 +140,8 @@ class MainActivity : ComponentActivity() {
spriteViewerController, spriteViewerController,
cardScreenController cardScreenController
), ),
initialRoute = initialRoute initialRoute = initialRoute,
navigationEvents = deepLinkNavigationEvents,
) )
} }
} }

View File

@ -0,0 +1,17 @@
package com.github.nacabaro.vbhelper.daos
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
import com.github.nacabaro.vbhelper.domain.device_data.CharacterTransferPolicy
@Dao
interface CharacterTransferPolicyDao {
@Query("SELECT * FROM CharacterTransferPolicy WHERE characterId = :characterId")
fun getByCharacterId(characterId: Long): CharacterTransferPolicy?
@Upsert
fun upsert(policy: CharacterTransferPolicy)
}

View File

@ -7,6 +7,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
import com.github.nacabaro.vbhelper.daos.AdventureDao import com.github.nacabaro.vbhelper.daos.AdventureDao
import com.github.nacabaro.vbhelper.daos.CardAdventureDao import com.github.nacabaro.vbhelper.daos.CardAdventureDao
import com.github.nacabaro.vbhelper.daos.CharacterDao import com.github.nacabaro.vbhelper.daos.CharacterDao
import com.github.nacabaro.vbhelper.daos.CharacterTransferPolicyDao
import com.github.nacabaro.vbhelper.daos.DexDao import com.github.nacabaro.vbhelper.daos.DexDao
import com.github.nacabaro.vbhelper.daos.CardDao import com.github.nacabaro.vbhelper.daos.CardDao
import com.github.nacabaro.vbhelper.daos.CardFusionsDao import com.github.nacabaro.vbhelper.daos.CardFusionsDao
@ -27,6 +28,7 @@ import com.github.nacabaro.vbhelper.domain.characters.Sprite
import com.github.nacabaro.vbhelper.domain.characters.Adventure import com.github.nacabaro.vbhelper.domain.characters.Adventure
import com.github.nacabaro.vbhelper.domain.characters.Dex import com.github.nacabaro.vbhelper.domain.characters.Dex
import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData
import com.github.nacabaro.vbhelper.domain.device_data.CharacterTransferPolicy
import com.github.nacabaro.vbhelper.domain.device_data.SpecialMissions import com.github.nacabaro.vbhelper.domain.device_data.SpecialMissions
import com.github.nacabaro.vbhelper.domain.device_data.TransformationHistory import com.github.nacabaro.vbhelper.domain.device_data.TransformationHistory
import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter
@ -36,7 +38,7 @@ import com.github.nacabaro.vbhelper.domain.device_data.VitalWearCharacterSetting
import com.github.nacabaro.vbhelper.domain.items.Items import com.github.nacabaro.vbhelper.domain.items.Items
@Database( @Database(
version = 5, version = 6,
entities = [ entities = [
Card::class, Card::class,
CardProgress::class, CardProgress::class,
@ -50,6 +52,7 @@ import com.github.nacabaro.vbhelper.domain.items.Items
SpecialMissions::class, SpecialMissions::class,
TransformationHistory::class, TransformationHistory::class,
VitalsHistory::class, VitalsHistory::class,
CharacterTransferPolicy::class,
Dex::class, Dex::class,
Items::class, Items::class,
Adventure::class, Adventure::class,
@ -71,6 +74,7 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun cardAdventureDao(): CardAdventureDao abstract fun cardAdventureDao(): CardAdventureDao
abstract fun cardFusionsDao(): CardFusionsDao abstract fun cardFusionsDao(): CardFusionsDao
abstract fun vitalWearSettingsDao(): VitalWearSettingsDao abstract fun vitalWearSettingsDao(): VitalWearSettingsDao
abstract fun characterTransferPolicyDao(): CharacterTransferPolicyDao
companion object { companion object {
val MIGRATION_1_2 = object : Migration(1, 2) { val MIGRATION_1_2 = object : Migration(1, 2) {
@ -139,5 +143,27 @@ abstract class AppDatabase : RoomDatabase() {
} }
} }
val MIGRATION_5_6 = object : Migration(5, 6) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS `CharacterTransferPolicy` (
`characterId` INTEGER NOT NULL,
`nativeDeviceType` TEXT NOT NULL,
`preferredHceExportFormat` TEXT NOT NULL,
`preferredNfcaExportFormat` TEXT NOT NULL,
`lastObservedImportFormat` TEXT,
`lastTransferTransport` TEXT,
`lastTransferTarget` TEXT,
`preserveVbRoundTrip` INTEGER NOT NULL,
`preserveBeRoundTrip` INTEGER NOT NULL,
PRIMARY KEY(`characterId`),
FOREIGN KEY(`characterId`) REFERENCES `UserCharacter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE
)
""".trimIndent()
)
}
}
} }
} }

View File

@ -0,0 +1,32 @@
package com.github.nacabaro.vbhelper.domain.device_data
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
import com.github.nacabaro.vbhelper.transfer.ExportFormat
import com.github.nacabaro.vbhelper.transfer.TransferTarget
import com.github.nacabaro.vbhelper.transfer.TransferTransport
import com.github.nacabaro.vbhelper.utils.DeviceType
@Entity(
foreignKeys = [
ForeignKey(
entity = UserCharacter::class,
parentColumns = ["id"],
childColumns = ["characterId"],
onDelete = ForeignKey.CASCADE,
)
]
)
data class CharacterTransferPolicy(
@PrimaryKey val characterId: Long,
val nativeDeviceType: DeviceType,
val preferredHceExportFormat: ExportFormat,
val preferredNfcaExportFormat: ExportFormat,
val lastObservedImportFormat: ExportFormat?,
val lastTransferTransport: TransferTransport?,
val lastTransferTarget: TransferTarget?,
val preserveVbRoundTrip: Boolean,
val preserveBeRoundTrip: Boolean,
)

View File

@ -6,6 +6,7 @@ import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -39,6 +40,7 @@ import com.github.nacabaro.vbhelper.screens.settingsScreen.CreditsScreen
import com.github.nacabaro.vbhelper.screens.spriteViewer.SpriteViewerControllerImpl import com.github.nacabaro.vbhelper.screens.spriteViewer.SpriteViewerControllerImpl
import com.github.nacabaro.vbhelper.screens.storageScreen.StorageScreenControllerImpl import com.github.nacabaro.vbhelper.screens.storageScreen.StorageScreenControllerImpl
import com.github.nacabaro.vbhelper.source.StorageRepository import com.github.nacabaro.vbhelper.source.StorageRepository
import kotlinx.coroutines.flow.Flow
data class AppNavigationHandlers( data class AppNavigationHandlers(
val settingsScreenController: SettingsScreenControllerImpl, val settingsScreenController: SettingsScreenControllerImpl,
@ -54,10 +56,23 @@ data class AppNavigationHandlers(
@Composable @Composable
fun AppNavigation( fun AppNavigation(
applicationNavigationHandlers: AppNavigationHandlers, applicationNavigationHandlers: AppNavigationHandlers,
initialRoute: String? = null initialRoute: String? = null,
navigationEvents: Flow<String>? = null,
) { ) {
val navController = rememberNavController() val navController = rememberNavController()
LaunchedEffect(navController, navigationEvents) {
navigationEvents?.collect { route ->
navController.navigate(route) {
launchSingleTop = true
restoreState = true
popUpTo(navController.graph.startDestinationId) {
saveState = true
}
}
}
}
Scaffold( Scaffold(
bottomBar = { bottomBar = {
BottomNavigationBar(navController = navController) BottomNavigationBar(navController = navController)

View File

@ -31,9 +31,32 @@ fun BottomNavigationBar(navController: NavController) {
selected = currentRoute == item.route, selected = currentRoute == item.route,
onClick = { onClick = {
if (item == NavigationItems.Home) { if (item == NavigationItems.Home) {
// Home should always show the Home root, not a restored nested route
// like Settings that was opened from Home previously.
val poppedToHome = navController.popBackStack(
NavigationItems.Home.route,
inclusive = false,
)
if (!poppedToHome) {
navController.navigate(NavigationItems.Home.route) { navController.navigate(NavigationItems.Home.route) {
popUpTo(0) { inclusive = false } popUpTo(navController.graph.startDestinationId) { inclusive = false }
launchSingleTop = true launchSingleTop = true
restoreState = false
}
}
} else if (item == NavigationItems.Storage) {
// Adventure is launched from Storage; tapping Storage again should always
// bring the user back to the Storage root instead of appearing stuck.
val poppedToStorage = navController.popBackStack(
NavigationItems.Storage.route,
inclusive = false,
)
if (!poppedToStorage) {
navController.navigate(NavigationItems.Storage.route) {
popUpTo(navController.graph.startDestinationId) { saveState = true }
launchSingleTop = true
restoreState = true
}
} }
} else { } else {
navController.navigate(item.route) { navController.navigate(item.route) {

View File

@ -22,9 +22,12 @@ import com.github.nacabaro.vbhelper.screens.scanScreen.converters.FromNfcConvert
import com.github.nacabaro.vbhelper.screens.scanScreen.converters.ToNfcConverter import com.github.nacabaro.vbhelper.screens.scanScreen.converters.ToNfcConverter
import com.github.nacabaro.vbhelper.source.VitalWearCharacterExporter import com.github.nacabaro.vbhelper.source.VitalWearCharacterExporter
import com.github.nacabaro.vbhelper.source.VitalWearCharacterImporter import com.github.nacabaro.vbhelper.source.VitalWearCharacterImporter
import com.github.nacabaro.vbhelper.transfer.ExportFormat
import com.github.nacabaro.vbhelper.source.getCryptographicTransformerMap import com.github.nacabaro.vbhelper.source.getCryptographicTransformerMap
import com.github.nacabaro.vbhelper.source.isMissingSecrets import com.github.nacabaro.vbhelper.source.isMissingSecrets
import com.github.nacabaro.vbhelper.source.proto.Secrets import com.github.nacabaro.vbhelper.source.proto.Secrets
import com.github.nacabaro.vbhelper.transfer.TransferTarget
import com.github.nacabaro.vbhelper.transfer.TransferTransport
import com.github.nacabaro.vbhelper.transfer.hce.VitalWearHceReaderClient import com.github.nacabaro.vbhelper.transfer.hce.VitalWearHceReaderClient
import com.github.nacabaro.vbhelper.R import com.github.nacabaro.vbhelper.R
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -213,7 +216,42 @@ 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 (writeError: Throwable) { } catch (writeError: Throwable) {
Log.e("NFC_WRITE_A", "NFC-A write failed; attempting opposite direction", writeError) Log.e("NFC_WRITE_A", "NFC-A write failed; trying alternate payload format", writeError)
val characterId = lastRequestedCharacterId
val alternateFormat = when (nfcCharacter) {
is BENfcCharacter -> ExportFormat.VB
is VBNfcCharacter -> ExportFormat.BE
else -> null
}
val alternateSent = if (characterId != null && alternateFormat != null) {
runCatching {
val alternateCharacter = runBlocking {
ToNfcConverter(componentActivity = componentActivity).characterToNfc(
characterId = characterId,
target = TransferTarget.REAL_BRACELET,
transport = TransferTransport.NFCA,
forcedFormat = alternateFormat,
)
}
when (alternateCharacter) {
is VBNfcCharacter -> tagCommunicator.sendCharacter(alternateCharacter)
is BENfcCharacter -> tagCommunicator.sendCharacter(alternateCharacter)
}
true
}.getOrElse { alternateError ->
Log.e("NFC_WRITE_A", "Alternate payload write failed", alternateError)
false
}
} else {
false
}
if (alternateSent) {
onComplete.invoke(ScanScreenController.WriteResult.MOVE_CONFIRMED)
"Sent character after converting payload for the tapped bracelet."
} else {
Log.e("NFC_WRITE_A", "NFC-A write failed; attempting opposite direction fallback", writeError)
runCatching { runCatching {
val receivedCharacter = tagCommunicator.receiveCharacter() val receivedCharacter = tagCommunicator.receiveCharacter()
val fallbackMessage = importCharacterFromNfcAFallback(receivedCharacter) val fallbackMessage = importCharacterFromNfcAFallback(receivedCharacter)
@ -224,6 +262,7 @@ class ScanScreenControllerImpl(
componentActivity.getString(R.string.scan_error_generic) componentActivity.getString(R.string.scan_error_generic)
} }
} }
}
}, },
isoDepHandler = { isoDep -> isoDepHandler = { isoDep ->
val characterId = lastRequestedCharacterId val characterId = lastRequestedCharacterId
@ -707,7 +746,11 @@ class ScanScreenControllerImpl(
override suspend fun characterToNfc(characterId: Long): NfcCharacter { override suspend fun characterToNfc(characterId: Long): NfcCharacter {
lastRequestedCharacterId = characterId lastRequestedCharacterId = characterId
val character = ToNfcConverter(componentActivity = componentActivity).characterToNfc(characterId) val character = ToNfcConverter(componentActivity = componentActivity).characterToNfc(
characterId = characterId,
target = TransferTarget.REAL_BRACELET,
transport = TransferTransport.NFCA,
)
Log.d("CharacterType", character.toString()) Log.d("CharacterType", character.toString())
return character return character
} }

View File

@ -11,6 +11,8 @@ import com.github.nacabaro.vbhelper.domain.device_data.SpecialMissions
import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter import com.github.nacabaro.vbhelper.domain.device_data.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.VitalsHistory import com.github.nacabaro.vbhelper.domain.device_data.VitalsHistory
import com.github.nacabaro.vbhelper.transfer.CharacterTransferPolicyResolver
import com.github.nacabaro.vbhelper.transfer.ExportFormat
import com.github.nacabaro.vbhelper.utils.DeviceType import com.github.nacabaro.vbhelper.utils.DeviceType
import java.util.GregorianCalendar import java.util.GregorianCalendar
@ -20,6 +22,7 @@ class FromNfcConverter (
private val application = componentActivity.applicationContext as VBHelper private val application = componentActivity.applicationContext as VBHelper
private val database = application.container.db private val database = application.container.db
private val transferSeenDao = application.container.transferSeenDao private val transferSeenDao = application.container.transferSeenDao
private val transferPolicyResolver = CharacterTransferPolicyResolver(database.characterTransferPolicyDao())
fun addCharacterUsingCard( fun addCharacterUsingCard(
@ -134,6 +137,18 @@ class FromNfcConverter (
) )
} }
database
.characterTransferPolicyDao()
.upsert(
transferPolicyResolver.policyForNfcaImport(
characterId = characterId,
observedFormat = when (nfcCharacter) {
is BENfcCharacter -> ExportFormat.BE
else -> ExportFormat.VB
}
)
)
addTransformationHistoryToDatabase( addTransformationHistoryToDatabase(
characterId = characterId, characterId = characterId,
nfcCharacter = nfcCharacter, nfcCharacter = nfcCharacter,

View File

@ -15,6 +15,10 @@ import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData
import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter import com.github.nacabaro.vbhelper.domain.device_data.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
import com.github.nacabaro.vbhelper.transfer.CharacterTransferPolicyResolver
import com.github.nacabaro.vbhelper.transfer.ExportFormat
import com.github.nacabaro.vbhelper.transfer.TransferTarget
import com.github.nacabaro.vbhelper.transfer.TransferTransport
import com.github.nacabaro.vbhelper.utils.DeviceType import com.github.nacabaro.vbhelper.utils.DeviceType
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
@ -30,11 +34,15 @@ class ToNfcConverter(
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
private val transferPolicyResolver = CharacterTransferPolicyResolver(database.characterTransferPolicyDao())
suspend fun characterToNfc( suspend fun characterToNfc(
characterId: Long characterId: Long,
target: TransferTarget = TransferTarget.REAL_BRACELET,
transport: TransferTransport = TransferTransport.NFCA,
forcedFormat: ExportFormat? = null,
): NfcCharacter { ): NfcCharacter {
val app = componentActivity.applicationContext as VBHelper val app = componentActivity.applicationContext as VBHelper
val database = app.container.db val database = app.container.db
@ -46,15 +54,20 @@ class ToNfcConverter(
val characterInfo = database val characterInfo = database
.characterDao() .characterDao()
.getCharacterInfo(userCharacter.charId) .getCharacterInfo(userCharacter.charId)
val card = database
.cardDao()
.getCardByCharacterIdSync(characterId)
?: error("Card not found for character $characterId")
// Use the character's own type as the authoritative signal for encoding. val exportFormat = forcedFormat ?: transferPolicyResolver.resolveExportFormat(
// The card's isBEm flag only indicates the *card* is a BEM card, not that the characterId = characterId,
// stored UserCharacter has a BECharacterData row. Mixing those two signals was characterType = userCharacter.characterType,
// the root cause of a NoSuchElementException when a VB-type character was transport = transport,
// associated with a BE-type card. target = target,
val shouldEncodeAsBem = userCharacter.characterType == DeviceType.BEDevice isBemCard = card.isBEm,
)
return if (shouldEncodeAsBem) return if (exportFormat == ExportFormat.BE)
nfcToBENfc(characterId, characterInfo, userCharacter) nfcToBENfc(characterId, characterInfo, userCharacter)
else else
nfcToVBNfc(characterId, characterInfo, userCharacter) nfcToVBNfc(characterId, characterInfo, userCharacter)

View File

@ -3,6 +3,10 @@ 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.domain.device_data.BECharacterData
import com.github.nacabaro.vbhelper.transfer.CharacterTransferPolicyResolver
import com.github.nacabaro.vbhelper.transfer.TransferTarget
import com.github.nacabaro.vbhelper.transfer.TransferTransport
import com.github.nacabaro.vbhelper.transfer.toTransferDeviceType
import com.github.nacabaro.vbhelper.utils.DeviceType import com.github.nacabaro.vbhelper.utils.DeviceType
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
@ -30,6 +34,8 @@ internal fun resolveTrainingSeconds(
class VitalWearCharacterExporter( class VitalWearCharacterExporter(
private val database: AppDatabase private val database: AppDatabase
) { ) {
private val transferPolicyResolver = CharacterTransferPolicyResolver(database.characterTransferPolicyDao())
/** /**
* Builds a Character proto from the stored character data. * Builds a Character proto from the stored character data.
* Used by the HCE ISO-DEP transfer path. * Used by the HCE ISO-DEP transfer path.
@ -44,6 +50,13 @@ class VitalWearCharacterExporter(
val cardProgress = database.cardProgressDao().getCardProgressSync(card.id) ?: 0 val cardProgress = database.cardProgressDao().getCardProgressSync(card.id) ?: 0
val beData = database.userCharacterDao().getBeDataOrNull(characterId) val beData = database.userCharacterDao().getBeDataOrNull(characterId)
val vbData = database.userCharacterDao().getVbDataOrNull(characterId) val vbData = database.userCharacterDao().getVbDataOrNull(characterId)
val exportFormat = transferPolicyResolver.resolveExportFormat(
characterId = characterId,
characterType = userCharacter.characterType,
transport = TransferTransport.HCE,
target = TransferTarget.VITAL_WEAR,
isBemCard = card.isBEm,
)
val normalizedTransformationCountdownMinutes = normalizeTransformationCountdownMinutes( val normalizedTransformationCountdownMinutes = normalizeTransformationCountdownMinutes(
transformationCountdownMinutes = userCharacter.transformationCountdown, transformationCountdownMinutes = userCharacter.transformationCountdown,
hasPossibleTransformations = hasPossibleTransformations(userCharacter.charId), hasPossibleTransformations = hasPossibleTransformations(userCharacter.charId),
@ -68,7 +81,7 @@ class VitalWearCharacterExporter(
.setTotalWins(characterWithSprites.totalBattlesWon) .setTotalWins(characterWithSprites.totalBattlesWon)
.setCurrentPhaseWins(characterWithSprites.currentPhaseBattlesWon) .setCurrentPhaseWins(characterWithSprites.currentPhaseBattlesWon)
.setMood(characterWithSprites.mood) .setMood(characterWithSprites.mood)
.setDeviceType(userCharacter.characterType.toTransferDeviceType()) .setDeviceType(exportFormat.toTransferDeviceType())
.setAgeInDays(userCharacter.ageInDays.coerceAtLeast(0)) .setAgeInDays(userCharacter.ageInDays.coerceAtLeast(0))
.setActivityLevel(userCharacter.activityLevel.coerceAtLeast(0)) .setActivityLevel(userCharacter.activityLevel.coerceAtLeast(0))
.setHeartRateCurrent(userCharacter.heartRateCurrent.coerceAtLeast(0)) .setHeartRateCurrent(userCharacter.heartRateCurrent.coerceAtLeast(0))

View File

@ -9,6 +9,8 @@ import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData
import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter import com.github.nacabaro.vbhelper.domain.device_data.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.transfer.CharacterTransferPolicyResolver
import com.github.nacabaro.vbhelper.transfer.ExportFormat
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 import kotlinx.coroutines.runBlocking
@ -18,6 +20,8 @@ class VitalWearCharacterImporter(
private val database: AppDatabase, private val database: AppDatabase,
private val transferSeenDao: SharedTransferSeenDao, private val transferSeenDao: SharedTransferSeenDao,
) { ) {
private val transferPolicyResolver = CharacterTransferPolicyResolver(database.characterTransferPolicyDao())
data class ImportResult( data class ImportResult(
val success: Boolean, val success: Boolean,
val message: String val message: String
@ -58,6 +62,11 @@ class VitalWearCharacterImporter(
fallbackIsBeCharacter = fallbackIsBeCharacter, fallbackIsBeCharacter = fallbackIsBeCharacter,
) )
val isBeCharacter = deviceType == DeviceType.BEDevice val isBeCharacter = deviceType == DeviceType.BEDevice
val observedImportFormat = resolveObservedHceImportFormat(
transferDeviceType = character.characterStats.deviceType,
importedCard = importedCard,
hasBePayloadStats = hasBePayloadStats,
)
val userCharacterId = database.userCharacterDao().insertCharacterData( val userCharacterId = database.userCharacterDao().insertCharacterData(
UserCharacter( UserCharacter(
@ -138,6 +147,17 @@ class VitalWearCharacterImporter(
) )
} }
runBlocking {
database.characterTransferPolicyDao().upsert(
transferPolicyResolver.policyForHceImport(
characterId = userCharacterId,
importedCardIsBem = importedCard.isBEm,
resolvedDeviceType = deviceType,
observedFormat = observedImportFormat,
)
)
}
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
markSeen(importedCard.name, slotId, importedCard.id, now) markSeen(importedCard.name, slotId, importedCard.id, now)
@ -270,6 +290,23 @@ class VitalWearCharacterImporter(
return rarities.getOrElse(rawValue) { resolveDefaultAbilityRarity() } return rarities.getOrElse(rawValue) { resolveDefaultAbilityRarity() }
} }
private fun resolveObservedHceImportFormat(
transferDeviceType: Character.CharacterStats.TransferDeviceType,
importedCard: Card,
hasBePayloadStats: Boolean,
): ExportFormat {
return when (transferDeviceType) {
Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_BE -> ExportFormat.BE
Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_VB -> ExportFormat.VB
Character.CharacterStats.TransferDeviceType.UNRECOGNIZED,
Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_UNSPECIFIED -> if (importedCard.isBEm || hasBePayloadStats) {
ExportFormat.BE
} else {
ExportFormat.VB
}
}
}
private fun markSeen(cardName: String, slotId: Int, cardId: Long, timestamp: Long) { 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,112 @@
package com.github.nacabaro.vbhelper.transfer
import com.github.nacabaro.vbhelper.daos.CharacterTransferPolicyDao
import com.github.nacabaro.vbhelper.domain.device_data.CharacterTransferPolicy
import com.github.nacabaro.vbhelper.utils.DeviceType
class CharacterTransferPolicyResolver(
private val policyDao: CharacterTransferPolicyDao,
) {
fun resolveExportFormat(
characterId: Long,
characterType: DeviceType,
transport: TransferTransport,
target: TransferTarget,
isBemCard: Boolean,
): ExportFormat {
val policy = policyDao.getByCharacterId(characterId)
return when (transport) {
TransferTransport.HCE -> policy?.preferredHceExportFormat
?: defaultHceExportFormat(characterType, isBemCard, target)
TransferTransport.NFCA -> policy?.preferredNfcaExportFormat
?: defaultNfcaExportFormat(characterType, isBemCard, target)
}
}
fun policyForNfcaImport(
characterId: Long,
observedFormat: ExportFormat,
): CharacterTransferPolicy {
val nativeDeviceType = observedFormat.toNativeDeviceType()
return CharacterTransferPolicy(
characterId = characterId,
nativeDeviceType = nativeDeviceType,
preferredHceExportFormat = defaultHceExportFormat(nativeDeviceType, nativeDeviceType == DeviceType.BEDevice, TransferTarget.VITAL_WEAR),
preferredNfcaExportFormat = observedFormat,
lastObservedImportFormat = observedFormat,
lastTransferTransport = TransferTransport.NFCA,
lastTransferTarget = TransferTarget.REAL_BRACELET,
preserveVbRoundTrip = observedFormat == ExportFormat.VB,
preserveBeRoundTrip = observedFormat == ExportFormat.BE,
)
}
fun policyForHceImport(
characterId: Long,
importedCardIsBem: Boolean,
resolvedDeviceType: DeviceType,
observedFormat: ExportFormat,
): CharacterTransferPolicy {
val nativeDeviceType = when {
importedCardIsBem -> DeviceType.BEDevice
resolvedDeviceType == DeviceType.BEDevice && observedFormat == ExportFormat.BE -> DeviceType.VBDevice
resolvedDeviceType == DeviceType.VitalWear -> DeviceType.VBDevice
else -> resolvedDeviceType
}
val preferredNfcaExportFormat = if (importedCardIsBem || nativeDeviceType == DeviceType.BEDevice) {
ExportFormat.BE
} else {
ExportFormat.VB
}
return CharacterTransferPolicy(
characterId = characterId,
nativeDeviceType = nativeDeviceType,
preferredHceExportFormat = defaultHceExportFormat(nativeDeviceType, importedCardIsBem, TransferTarget.VITAL_WEAR),
preferredNfcaExportFormat = preferredNfcaExportFormat,
lastObservedImportFormat = observedFormat,
lastTransferTransport = TransferTransport.HCE,
lastTransferTarget = TransferTarget.VITAL_WEAR,
preserveVbRoundTrip = preferredNfcaExportFormat == ExportFormat.VB,
preserveBeRoundTrip = importedCardIsBem || observedFormat == ExportFormat.BE,
)
}
private fun defaultHceExportFormat(
characterType: DeviceType,
isBemCard: Boolean,
target: TransferTarget,
): ExportFormat {
if (target != TransferTarget.VITAL_WEAR) {
return defaultNfcaExportFormat(characterType, isBemCard, target)
}
// VitalWear is the HCE endpoint. Default to BE projection so HCE transfers can carry the
// richer payload, while NFCA policy still controls what is sent back to real bracelets.
return ExportFormat.BE
}
private fun defaultNfcaExportFormat(
characterType: DeviceType,
isBemCard: Boolean,
target: TransferTarget,
): ExportFormat {
require(target == TransferTarget.REAL_BRACELET) {
"NFCA exports are only supported for real bracelets."
}
return if (isBemCard || characterType == DeviceType.BEDevice) {
ExportFormat.BE
} else {
ExportFormat.VB
}
}
private fun ExportFormat.toNativeDeviceType(): DeviceType {
return when (this) {
ExportFormat.BE -> DeviceType.BEDevice
ExportFormat.VB -> DeviceType.VBDevice
}
}
}

View File

@ -0,0 +1,31 @@
package com.github.nacabaro.vbhelper.transfer
import com.github.cfogrady.vitalwear.protos.Character
/**
* Real bracelets talk over NFC-A while VitalWear uses HCE / ISO-DEP.
* Keep transport, target, and payload format separate so one canonical character can be
* projected differently depending on where it is being transferred.
*/
enum class TransferTransport {
NFCA,
HCE,
}
enum class TransferTarget {
REAL_BRACELET,
VITAL_WEAR,
}
enum class ExportFormat {
VB,
BE,
}
fun ExportFormat.toTransferDeviceType(): Character.CharacterStats.TransferDeviceType {
return when (this) {
ExportFormat.VB -> Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_VB
ExportFormat.BE -> Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_BE
}
}

File diff suppressed because one or more lines are too long