diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 05da0ad..b2c32af 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -54,6 +54,7 @@ android:name=".MainActivity" android:exported="true" android:label="@string/app_name" + android:launchMode="singleTask" android:theme="@style/Theme.VBHelper"> diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/MainActivity.kt b/app/src/main/java/com/github/nacabaro/vbhelper/MainActivity.kt index 4fd2e1f..3be27d1 100644 --- a/app/src/main/java/com/github/nacabaro/vbhelper/MainActivity.kt +++ b/app/src/main/java/com/github/nacabaro/vbhelper/MainActivity.kt @@ -19,11 +19,14 @@ import com.github.nacabaro.vbhelper.screens.cardScreen.CardScreenControllerImpl import com.github.nacabaro.vbhelper.screens.spriteViewer.SpriteViewerControllerImpl import com.github.nacabaro.vbhelper.screens.storageScreen.StorageScreenControllerImpl import com.github.nacabaro.vbhelper.ui.theme.VBHelperTheme +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow class MainActivity : ComponentActivity() { private val onActivityLifecycleListeners = HashMap() private var initialRoute: String? = null + private val deepLinkNavigationEvents = MutableSharedFlow(extraBufferCapacity = 1) private fun registerActivityLifecycleListener(key: String, activityLifecycleListener: ActivityLifecycleListener) { if( onActivityLifecycleListeners[key] != null) { @@ -57,6 +60,7 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() initialRoute = getInitialRouteFromIntent(intent) + initialRoute?.let { deepLinkNavigationEvents.tryEmit(it) } setContent { VBHelperTheme { @@ -69,7 +73,8 @@ class MainActivity : ComponentActivity() { storageScreenController = storageScreenController, spriteViewerController = spriteViewerController, cardScreenController = cardScreenController, - initialRoute = initialRoute + initialRoute = initialRoute, + deepLinkNavigationEvents = deepLinkNavigationEvents.asSharedFlow(), ) } } @@ -97,6 +102,7 @@ class MainActivity : ComponentActivity() { super.onNewIntent(intent) setIntent(intent) initialRoute = getInitialRouteFromIntent(intent) + initialRoute?.let { deepLinkNavigationEvents.tryEmit(it) } } private fun getInitialRouteFromIntent(intent: Intent?): String? { @@ -120,7 +126,8 @@ class MainActivity : ComponentActivity() { homeScreenController: HomeScreenControllerImpl, spriteViewerController: SpriteViewerControllerImpl, cardScreenController: CardScreenControllerImpl, - initialRoute: String? = null + initialRoute: String? = null, + deepLinkNavigationEvents: kotlinx.coroutines.flow.Flow, ) { AppNavigation( applicationNavigationHandlers = AppNavigationHandlers( @@ -133,7 +140,8 @@ class MainActivity : ComponentActivity() { spriteViewerController, cardScreenController ), - initialRoute = initialRoute + initialRoute = initialRoute, + navigationEvents = deepLinkNavigationEvents, ) } } diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/daos/CharacterTransferPolicyDao.kt b/app/src/main/java/com/github/nacabaro/vbhelper/daos/CharacterTransferPolicyDao.kt new file mode 100644 index 0000000..7f65e07 --- /dev/null +++ b/app/src/main/java/com/github/nacabaro/vbhelper/daos/CharacterTransferPolicyDao.kt @@ -0,0 +1,17 @@ +package com.github.nacabaro.vbhelper.daos + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Upsert +import com.github.nacabaro.vbhelper.domain.device_data.CharacterTransferPolicy + +@Dao +interface CharacterTransferPolicyDao { + @Query("SELECT * FROM CharacterTransferPolicy WHERE characterId = :characterId") + fun getByCharacterId(characterId: Long): CharacterTransferPolicy? + + @Upsert + fun upsert(policy: CharacterTransferPolicy) +} + + diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/database/AppDatabase.kt b/app/src/main/java/com/github/nacabaro/vbhelper/database/AppDatabase.kt index b2067e5..06b77b8 100644 --- a/app/src/main/java/com/github/nacabaro/vbhelper/database/AppDatabase.kt +++ b/app/src/main/java/com/github/nacabaro/vbhelper/database/AppDatabase.kt @@ -7,6 +7,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase import com.github.nacabaro.vbhelper.daos.AdventureDao import com.github.nacabaro.vbhelper.daos.CardAdventureDao import com.github.nacabaro.vbhelper.daos.CharacterDao +import com.github.nacabaro.vbhelper.daos.CharacterTransferPolicyDao import com.github.nacabaro.vbhelper.daos.DexDao import com.github.nacabaro.vbhelper.daos.CardDao import com.github.nacabaro.vbhelper.daos.CardFusionsDao @@ -27,6 +28,7 @@ import com.github.nacabaro.vbhelper.domain.characters.Sprite import com.github.nacabaro.vbhelper.domain.characters.Adventure import com.github.nacabaro.vbhelper.domain.characters.Dex import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData +import com.github.nacabaro.vbhelper.domain.device_data.CharacterTransferPolicy import com.github.nacabaro.vbhelper.domain.device_data.SpecialMissions import com.github.nacabaro.vbhelper.domain.device_data.TransformationHistory import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter @@ -36,7 +38,7 @@ import com.github.nacabaro.vbhelper.domain.device_data.VitalWearCharacterSetting import com.github.nacabaro.vbhelper.domain.items.Items @Database( - version = 5, + version = 6, entities = [ Card::class, CardProgress::class, @@ -50,6 +52,7 @@ import com.github.nacabaro.vbhelper.domain.items.Items SpecialMissions::class, TransformationHistory::class, VitalsHistory::class, + CharacterTransferPolicy::class, Dex::class, Items::class, Adventure::class, @@ -71,6 +74,7 @@ abstract class AppDatabase : RoomDatabase() { abstract fun cardAdventureDao(): CardAdventureDao abstract fun cardFusionsDao(): CardFusionsDao abstract fun vitalWearSettingsDao(): VitalWearSettingsDao + abstract fun characterTransferPolicyDao(): CharacterTransferPolicyDao companion object { val MIGRATION_1_2 = object : Migration(1, 2) { @@ -139,5 +143,27 @@ abstract class AppDatabase : RoomDatabase() { } } + val MIGRATION_5_6 = object : Migration(5, 6) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `CharacterTransferPolicy` ( + `characterId` INTEGER NOT NULL, + `nativeDeviceType` TEXT NOT NULL, + `preferredHceExportFormat` TEXT NOT NULL, + `preferredNfcaExportFormat` TEXT NOT NULL, + `lastObservedImportFormat` TEXT, + `lastTransferTransport` TEXT, + `lastTransferTarget` TEXT, + `preserveVbRoundTrip` INTEGER NOT NULL, + `preserveBeRoundTrip` INTEGER NOT NULL, + PRIMARY KEY(`characterId`), + FOREIGN KEY(`characterId`) REFERENCES `UserCharacter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE + ) + """.trimIndent() + ) + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/CharacterTransferPolicy.kt b/app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/CharacterTransferPolicy.kt new file mode 100644 index 0000000..0a541d6 --- /dev/null +++ b/app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/CharacterTransferPolicy.kt @@ -0,0 +1,32 @@ +package com.github.nacabaro.vbhelper.domain.device_data + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.PrimaryKey +import com.github.nacabaro.vbhelper.transfer.ExportFormat +import com.github.nacabaro.vbhelper.transfer.TransferTarget +import com.github.nacabaro.vbhelper.transfer.TransferTransport +import com.github.nacabaro.vbhelper.utils.DeviceType + +@Entity( + foreignKeys = [ + ForeignKey( + entity = UserCharacter::class, + parentColumns = ["id"], + childColumns = ["characterId"], + onDelete = ForeignKey.CASCADE, + ) + ] +) +data class CharacterTransferPolicy( + @PrimaryKey val characterId: Long, + val nativeDeviceType: DeviceType, + val preferredHceExportFormat: ExportFormat, + val preferredNfcaExportFormat: ExportFormat, + val lastObservedImportFormat: ExportFormat?, + val lastTransferTransport: TransferTransport?, + val lastTransferTarget: TransferTarget?, + val preserveVbRoundTrip: Boolean, + val preserveBeRoundTrip: Boolean, +) + diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/navigation/AppNavigation.kt b/app/src/main/java/com/github/nacabaro/vbhelper/navigation/AppNavigation.kt index 86b474d..2b3d047 100644 --- a/app/src/main/java/com/github/nacabaro/vbhelper/navigation/AppNavigation.kt +++ b/app/src/main/java/com/github/nacabaro/vbhelper/navigation/AppNavigation.kt @@ -6,6 +6,7 @@ import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -39,6 +40,7 @@ import com.github.nacabaro.vbhelper.screens.settingsScreen.CreditsScreen import com.github.nacabaro.vbhelper.screens.spriteViewer.SpriteViewerControllerImpl import com.github.nacabaro.vbhelper.screens.storageScreen.StorageScreenControllerImpl import com.github.nacabaro.vbhelper.source.StorageRepository +import kotlinx.coroutines.flow.Flow data class AppNavigationHandlers( val settingsScreenController: SettingsScreenControllerImpl, @@ -54,10 +56,23 @@ data class AppNavigationHandlers( @Composable fun AppNavigation( applicationNavigationHandlers: AppNavigationHandlers, - initialRoute: String? = null + initialRoute: String? = null, + navigationEvents: Flow? = null, ) { val navController = rememberNavController() + LaunchedEffect(navController, navigationEvents) { + navigationEvents?.collect { route -> + navController.navigate(route) { + launchSingleTop = true + restoreState = true + popUpTo(navController.graph.startDestinationId) { + saveState = true + } + } + } + } + Scaffold( bottomBar = { BottomNavigationBar(navController = navController) diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/navigation/BottomNavigationBar.kt b/app/src/main/java/com/github/nacabaro/vbhelper/navigation/BottomNavigationBar.kt index 16e6728..672ffd7 100644 --- a/app/src/main/java/com/github/nacabaro/vbhelper/navigation/BottomNavigationBar.kt +++ b/app/src/main/java/com/github/nacabaro/vbhelper/navigation/BottomNavigationBar.kt @@ -31,9 +31,32 @@ fun BottomNavigationBar(navController: NavController) { selected = currentRoute == item.route, onClick = { if (item == NavigationItems.Home) { - navController.navigate(NavigationItems.Home.route) { - popUpTo(0) { inclusive = false } - launchSingleTop = true + // Home should always show the Home root, not a restored nested route + // like Settings that was opened from Home previously. + val poppedToHome = navController.popBackStack( + NavigationItems.Home.route, + inclusive = false, + ) + if (!poppedToHome) { + navController.navigate(NavigationItems.Home.route) { + popUpTo(navController.graph.startDestinationId) { inclusive = false } + launchSingleTop = true + restoreState = false + } + } + } else if (item == NavigationItems.Storage) { + // Adventure is launched from Storage; tapping Storage again should always + // bring the user back to the Storage root instead of appearing stuck. + val poppedToStorage = navController.popBackStack( + NavigationItems.Storage.route, + inclusive = false, + ) + if (!poppedToStorage) { + navController.navigate(NavigationItems.Storage.route) { + popUpTo(navController.graph.startDestinationId) { saveState = true } + launchSingleTop = true + restoreState = true + } } } else { navController.navigate(item.route) { diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/ScanScreenControllerImpl.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/ScanScreenControllerImpl.kt index 81a9d0a..ff3506f 100644 --- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/ScanScreenControllerImpl.kt +++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/ScanScreenControllerImpl.kt @@ -22,9 +22,12 @@ import com.github.nacabaro.vbhelper.screens.scanScreen.converters.FromNfcConvert import com.github.nacabaro.vbhelper.screens.scanScreen.converters.ToNfcConverter import com.github.nacabaro.vbhelper.source.VitalWearCharacterExporter import com.github.nacabaro.vbhelper.source.VitalWearCharacterImporter +import com.github.nacabaro.vbhelper.transfer.ExportFormat import com.github.nacabaro.vbhelper.source.getCryptographicTransformerMap import com.github.nacabaro.vbhelper.source.isMissingSecrets import com.github.nacabaro.vbhelper.source.proto.Secrets +import com.github.nacabaro.vbhelper.transfer.TransferTarget +import com.github.nacabaro.vbhelper.transfer.TransferTransport import com.github.nacabaro.vbhelper.transfer.hce.VitalWearHceReaderClient import com.github.nacabaro.vbhelper.R import kotlinx.coroutines.Dispatchers @@ -213,15 +216,51 @@ class ScanScreenControllerImpl( onComplete.invoke(ScanScreenController.WriteResult.MOVE_CONFIRMED) componentActivity.getString(R.string.scan_sent_character_success) } catch (writeError: Throwable) { - Log.e("NFC_WRITE_A", "NFC-A write failed; attempting opposite direction", writeError) - runCatching { - val receivedCharacter = tagCommunicator.receiveCharacter() - val fallbackMessage = importCharacterFromNfcAFallback(receivedCharacter) - onComplete.invoke(ScanScreenController.WriteResult.COPIED) - fallbackMessage - }.getOrElse { readFallbackError -> - Log.e("NFC_WRITE_A", "NFC-A opposite-direction fallback failed", readFallbackError) - componentActivity.getString(R.string.scan_error_generic) + Log.e("NFC_WRITE_A", "NFC-A write failed; trying alternate payload format", writeError) + val characterId = lastRequestedCharacterId + val alternateFormat = when (nfcCharacter) { + is BENfcCharacter -> ExportFormat.VB + is VBNfcCharacter -> ExportFormat.BE + else -> null + } + + val alternateSent = if (characterId != null && alternateFormat != null) { + runCatching { + val alternateCharacter = runBlocking { + ToNfcConverter(componentActivity = componentActivity).characterToNfc( + characterId = characterId, + target = TransferTarget.REAL_BRACELET, + transport = TransferTransport.NFCA, + forcedFormat = alternateFormat, + ) + } + when (alternateCharacter) { + is VBNfcCharacter -> tagCommunicator.sendCharacter(alternateCharacter) + is BENfcCharacter -> tagCommunicator.sendCharacter(alternateCharacter) + } + true + }.getOrElse { alternateError -> + Log.e("NFC_WRITE_A", "Alternate payload write failed", alternateError) + false + } + } else { + false + } + + if (alternateSent) { + onComplete.invoke(ScanScreenController.WriteResult.MOVE_CONFIRMED) + "Sent character after converting payload for the tapped bracelet." + } else { + Log.e("NFC_WRITE_A", "NFC-A write failed; attempting opposite direction fallback", writeError) + runCatching { + val receivedCharacter = tagCommunicator.receiveCharacter() + val fallbackMessage = importCharacterFromNfcAFallback(receivedCharacter) + onComplete.invoke(ScanScreenController.WriteResult.COPIED) + fallbackMessage + }.getOrElse { readFallbackError -> + Log.e("NFC_WRITE_A", "NFC-A opposite-direction fallback failed", readFallbackError) + componentActivity.getString(R.string.scan_error_generic) + } } } }, @@ -707,7 +746,11 @@ class ScanScreenControllerImpl( override suspend fun characterToNfc(characterId: Long): NfcCharacter { lastRequestedCharacterId = characterId - val character = ToNfcConverter(componentActivity = componentActivity).characterToNfc(characterId) + val character = ToNfcConverter(componentActivity = componentActivity).characterToNfc( + characterId = characterId, + target = TransferTarget.REAL_BRACELET, + transport = TransferTransport.NFCA, + ) Log.d("CharacterType", character.toString()) return character } diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/converters/FromNfcConverter.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/converters/FromNfcConverter.kt index 2cde698..6f20581 100644 --- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/converters/FromNfcConverter.kt +++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/converters/FromNfcConverter.kt @@ -11,6 +11,8 @@ import com.github.nacabaro.vbhelper.domain.device_data.SpecialMissions import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData import com.github.nacabaro.vbhelper.domain.device_data.VitalsHistory +import com.github.nacabaro.vbhelper.transfer.CharacterTransferPolicyResolver +import com.github.nacabaro.vbhelper.transfer.ExportFormat import com.github.nacabaro.vbhelper.utils.DeviceType import java.util.GregorianCalendar @@ -20,6 +22,7 @@ class FromNfcConverter ( private val application = componentActivity.applicationContext as VBHelper private val database = application.container.db private val transferSeenDao = application.container.transferSeenDao + private val transferPolicyResolver = CharacterTransferPolicyResolver(database.characterTransferPolicyDao()) fun addCharacterUsingCard( @@ -134,6 +137,18 @@ class FromNfcConverter ( ) } + database + .characterTransferPolicyDao() + .upsert( + transferPolicyResolver.policyForNfcaImport( + characterId = characterId, + observedFormat = when (nfcCharacter) { + is BENfcCharacter -> ExportFormat.BE + else -> ExportFormat.VB + } + ) + ) + addTransformationHistoryToDatabase( characterId = characterId, nfcCharacter = nfcCharacter, diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/converters/ToNfcConverter.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/converters/ToNfcConverter.kt index 09fd168..86d3d52 100644 --- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/converters/ToNfcConverter.kt +++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/converters/ToNfcConverter.kt @@ -15,6 +15,10 @@ import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData import com.github.nacabaro.vbhelper.dtos.CharacterDtos +import com.github.nacabaro.vbhelper.transfer.CharacterTransferPolicyResolver +import com.github.nacabaro.vbhelper.transfer.ExportFormat +import com.github.nacabaro.vbhelper.transfer.TransferTarget +import com.github.nacabaro.vbhelper.transfer.TransferTransport import com.github.nacabaro.vbhelper.utils.DeviceType import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull @@ -30,11 +34,15 @@ class ToNfcConverter( private val application: VBHelper = componentActivity.applicationContext as VBHelper private val database: AppDatabase = application.container.db + private val transferPolicyResolver = CharacterTransferPolicyResolver(database.characterTransferPolicyDao()) suspend fun characterToNfc( - characterId: Long + characterId: Long, + target: TransferTarget = TransferTarget.REAL_BRACELET, + transport: TransferTransport = TransferTransport.NFCA, + forcedFormat: ExportFormat? = null, ): NfcCharacter { val app = componentActivity.applicationContext as VBHelper val database = app.container.db @@ -46,15 +54,20 @@ class ToNfcConverter( val characterInfo = database .characterDao() .getCharacterInfo(userCharacter.charId) + val card = database + .cardDao() + .getCardByCharacterIdSync(characterId) + ?: error("Card not found for character $characterId") - // Use the character's own type as the authoritative signal for encoding. - // The card's isBEm flag only indicates the *card* is a BEM card, not that the - // stored UserCharacter has a BECharacterData row. Mixing those two signals was - // the root cause of a NoSuchElementException when a VB-type character was - // associated with a BE-type card. - val shouldEncodeAsBem = userCharacter.characterType == DeviceType.BEDevice + val exportFormat = forcedFormat ?: transferPolicyResolver.resolveExportFormat( + characterId = characterId, + characterType = userCharacter.characterType, + transport = transport, + target = target, + isBemCard = card.isBEm, + ) - return if (shouldEncodeAsBem) + return if (exportFormat == ExportFormat.BE) nfcToBENfc(characterId, characterInfo, userCharacter) else nfcToVBNfc(characterId, characterInfo, userCharacter) diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterExporter.kt b/app/src/main/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterExporter.kt index 5d49835..2f710e6 100644 --- a/app/src/main/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterExporter.kt +++ b/app/src/main/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterExporter.kt @@ -3,6 +3,10 @@ package com.github.nacabaro.vbhelper.source import com.github.cfogrady.vitalwear.protos.Character import com.github.nacabaro.vbhelper.database.AppDatabase import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData +import com.github.nacabaro.vbhelper.transfer.CharacterTransferPolicyResolver +import com.github.nacabaro.vbhelper.transfer.TransferTarget +import com.github.nacabaro.vbhelper.transfer.TransferTransport +import com.github.nacabaro.vbhelper.transfer.toTransferDeviceType import com.github.nacabaro.vbhelper.utils.DeviceType import kotlinx.coroutines.flow.firstOrNull @@ -30,6 +34,8 @@ internal fun resolveTrainingSeconds( class VitalWearCharacterExporter( private val database: AppDatabase ) { + private val transferPolicyResolver = CharacterTransferPolicyResolver(database.characterTransferPolicyDao()) + /** * Builds a Character proto from the stored character data. * Used by the HCE ISO-DEP transfer path. @@ -44,6 +50,13 @@ class VitalWearCharacterExporter( val cardProgress = database.cardProgressDao().getCardProgressSync(card.id) ?: 0 val beData = database.userCharacterDao().getBeDataOrNull(characterId) val vbData = database.userCharacterDao().getVbDataOrNull(characterId) + val exportFormat = transferPolicyResolver.resolveExportFormat( + characterId = characterId, + characterType = userCharacter.characterType, + transport = TransferTransport.HCE, + target = TransferTarget.VITAL_WEAR, + isBemCard = card.isBEm, + ) val normalizedTransformationCountdownMinutes = normalizeTransformationCountdownMinutes( transformationCountdownMinutes = userCharacter.transformationCountdown, hasPossibleTransformations = hasPossibleTransformations(userCharacter.charId), @@ -68,7 +81,7 @@ class VitalWearCharacterExporter( .setTotalWins(characterWithSprites.totalBattlesWon) .setCurrentPhaseWins(characterWithSprites.currentPhaseBattlesWon) .setMood(characterWithSprites.mood) - .setDeviceType(userCharacter.characterType.toTransferDeviceType()) + .setDeviceType(exportFormat.toTransferDeviceType()) .setAgeInDays(userCharacter.ageInDays.coerceAtLeast(0)) .setActivityLevel(userCharacter.activityLevel.coerceAtLeast(0)) .setHeartRateCurrent(userCharacter.heartRateCurrent.coerceAtLeast(0)) diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterImporter.kt b/app/src/main/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterImporter.kt index a04050d..fdd5593 100644 --- a/app/src/main/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterImporter.kt +++ b/app/src/main/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterImporter.kt @@ -9,6 +9,8 @@ import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData import com.github.nacabaro.vbhelper.domain.device_data.VitalWearCharacterSettings +import com.github.nacabaro.vbhelper.transfer.CharacterTransferPolicyResolver +import com.github.nacabaro.vbhelper.transfer.ExportFormat import com.github.nacabaro.vbhelper.utils.DeviceType import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.runBlocking @@ -18,6 +20,8 @@ class VitalWearCharacterImporter( private val database: AppDatabase, private val transferSeenDao: SharedTransferSeenDao, ) { + private val transferPolicyResolver = CharacterTransferPolicyResolver(database.characterTransferPolicyDao()) + data class ImportResult( val success: Boolean, val message: String @@ -58,6 +62,11 @@ class VitalWearCharacterImporter( fallbackIsBeCharacter = fallbackIsBeCharacter, ) val isBeCharacter = deviceType == DeviceType.BEDevice + val observedImportFormat = resolveObservedHceImportFormat( + transferDeviceType = character.characterStats.deviceType, + importedCard = importedCard, + hasBePayloadStats = hasBePayloadStats, + ) val userCharacterId = database.userCharacterDao().insertCharacterData( UserCharacter( @@ -138,6 +147,17 @@ class VitalWearCharacterImporter( ) } + runBlocking { + database.characterTransferPolicyDao().upsert( + transferPolicyResolver.policyForHceImport( + characterId = userCharacterId, + importedCardIsBem = importedCard.isBEm, + resolvedDeviceType = deviceType, + observedFormat = observedImportFormat, + ) + ) + } + val now = System.currentTimeMillis() markSeen(importedCard.name, slotId, importedCard.id, now) @@ -270,6 +290,23 @@ class VitalWearCharacterImporter( return rarities.getOrElse(rawValue) { resolveDefaultAbilityRarity() } } + private fun resolveObservedHceImportFormat( + transferDeviceType: Character.CharacterStats.TransferDeviceType, + importedCard: Card, + hasBePayloadStats: Boolean, + ): ExportFormat { + return when (transferDeviceType) { + Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_BE -> ExportFormat.BE + Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_VB -> ExportFormat.VB + Character.CharacterStats.TransferDeviceType.UNRECOGNIZED, + Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_UNSPECIFIED -> if (importedCard.isBEm || hasBePayloadStats) { + ExportFormat.BE + } else { + ExportFormat.VB + } + } + } + private fun markSeen(cardName: String, slotId: Int, cardId: Long, timestamp: Long) { database.dexDao().insertCharacter(slotId, cardId, timestamp) transferSeenDao.markSeen(cardName, slotId, timestamp) diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/transfer/CharacterTransferPolicyResolver.kt b/app/src/main/java/com/github/nacabaro/vbhelper/transfer/CharacterTransferPolicyResolver.kt new file mode 100644 index 0000000..811885a --- /dev/null +++ b/app/src/main/java/com/github/nacabaro/vbhelper/transfer/CharacterTransferPolicyResolver.kt @@ -0,0 +1,112 @@ +package com.github.nacabaro.vbhelper.transfer + +import com.github.nacabaro.vbhelper.daos.CharacterTransferPolicyDao +import com.github.nacabaro.vbhelper.domain.device_data.CharacterTransferPolicy +import com.github.nacabaro.vbhelper.utils.DeviceType + +class CharacterTransferPolicyResolver( + private val policyDao: CharacterTransferPolicyDao, +) { + fun resolveExportFormat( + characterId: Long, + characterType: DeviceType, + transport: TransferTransport, + target: TransferTarget, + isBemCard: Boolean, + ): ExportFormat { + val policy = policyDao.getByCharacterId(characterId) + return when (transport) { + TransferTransport.HCE -> policy?.preferredHceExportFormat + ?: defaultHceExportFormat(characterType, isBemCard, target) + TransferTransport.NFCA -> policy?.preferredNfcaExportFormat + ?: defaultNfcaExportFormat(characterType, isBemCard, target) + } + } + + fun policyForNfcaImport( + characterId: Long, + observedFormat: ExportFormat, + ): CharacterTransferPolicy { + val nativeDeviceType = observedFormat.toNativeDeviceType() + return CharacterTransferPolicy( + characterId = characterId, + nativeDeviceType = nativeDeviceType, + preferredHceExportFormat = defaultHceExportFormat(nativeDeviceType, nativeDeviceType == DeviceType.BEDevice, TransferTarget.VITAL_WEAR), + preferredNfcaExportFormat = observedFormat, + lastObservedImportFormat = observedFormat, + lastTransferTransport = TransferTransport.NFCA, + lastTransferTarget = TransferTarget.REAL_BRACELET, + preserveVbRoundTrip = observedFormat == ExportFormat.VB, + preserveBeRoundTrip = observedFormat == ExportFormat.BE, + ) + } + + fun policyForHceImport( + characterId: Long, + importedCardIsBem: Boolean, + resolvedDeviceType: DeviceType, + observedFormat: ExportFormat, + ): CharacterTransferPolicy { + val nativeDeviceType = when { + importedCardIsBem -> DeviceType.BEDevice + resolvedDeviceType == DeviceType.BEDevice && observedFormat == ExportFormat.BE -> DeviceType.VBDevice + resolvedDeviceType == DeviceType.VitalWear -> DeviceType.VBDevice + else -> resolvedDeviceType + } + val preferredNfcaExportFormat = if (importedCardIsBem || nativeDeviceType == DeviceType.BEDevice) { + ExportFormat.BE + } else { + ExportFormat.VB + } + + return CharacterTransferPolicy( + characterId = characterId, + nativeDeviceType = nativeDeviceType, + preferredHceExportFormat = defaultHceExportFormat(nativeDeviceType, importedCardIsBem, TransferTarget.VITAL_WEAR), + preferredNfcaExportFormat = preferredNfcaExportFormat, + lastObservedImportFormat = observedFormat, + lastTransferTransport = TransferTransport.HCE, + lastTransferTarget = TransferTarget.VITAL_WEAR, + preserveVbRoundTrip = preferredNfcaExportFormat == ExportFormat.VB, + preserveBeRoundTrip = importedCardIsBem || observedFormat == ExportFormat.BE, + ) + } + + private fun defaultHceExportFormat( + characterType: DeviceType, + isBemCard: Boolean, + target: TransferTarget, + ): ExportFormat { + if (target != TransferTarget.VITAL_WEAR) { + return defaultNfcaExportFormat(characterType, isBemCard, target) + } + + // VitalWear is the HCE endpoint. Default to BE projection so HCE transfers can carry the + // richer payload, while NFCA policy still controls what is sent back to real bracelets. + return ExportFormat.BE + } + + private fun defaultNfcaExportFormat( + characterType: DeviceType, + isBemCard: Boolean, + target: TransferTarget, + ): ExportFormat { + require(target == TransferTarget.REAL_BRACELET) { + "NFCA exports are only supported for real bracelets." + } + return if (isBemCard || characterType == DeviceType.BEDevice) { + ExportFormat.BE + } else { + ExportFormat.VB + } + } + + private fun ExportFormat.toNativeDeviceType(): DeviceType { + return when (this) { + ExportFormat.BE -> DeviceType.BEDevice + ExportFormat.VB -> DeviceType.VBDevice + } + } +} + + diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/transfer/TransferRoute.kt b/app/src/main/java/com/github/nacabaro/vbhelper/transfer/TransferRoute.kt new file mode 100644 index 0000000..72c47f2 --- /dev/null +++ b/app/src/main/java/com/github/nacabaro/vbhelper/transfer/TransferRoute.kt @@ -0,0 +1,31 @@ +package com.github.nacabaro.vbhelper.transfer + +import com.github.cfogrady.vitalwear.protos.Character + +/** + * Real bracelets talk over NFC-A while VitalWear uses HCE / ISO-DEP. + * Keep transport, target, and payload format separate so one canonical character can be + * projected differently depending on where it is being transferred. + */ +enum class TransferTransport { + NFCA, + HCE, +} + +enum class TransferTarget { + REAL_BRACELET, + VITAL_WEAR, +} + +enum class ExportFormat { + VB, + BE, +} + +fun ExportFormat.toTransferDeviceType(): Character.CharacterStats.TransferDeviceType { + return when (this) { + ExportFormat.VB -> Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_VB + ExportFormat.BE -> Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_BE + } +} + diff --git a/build/reports/problems/problems-report.html b/build/reports/problems/problems-report.html index 2f7e871..d103092 100644 --- a/build/reports/problems/problems-report.html +++ b/build/reports/problems/problems-report.html @@ -650,7 +650,7 @@ code + .copy-button {