This patch enables transfer between vitalwear and VBH

This commit is contained in:
Taveon Nelson
2026-04-21 14:36:27 -05:00
parent e787ea4a19
commit e0a086d9aa
24 changed files with 1350 additions and 338 deletions
-5
View File
@@ -54,11 +54,6 @@
<data android:scheme="http" android:host="localhost" android:port="8080" android:pathPrefix="/authenticate" />
<data android:scheme="http" android:host="127.0.0.1" android:port="8080" android:pathPrefix="/authenticate" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/x-vitalwear-character" />
</intent-filter>
</activity>
</application>
@@ -1,16 +1,12 @@
package com.github.nacabaro.vbhelper
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.util.Log
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.Composable
import androidx.lifecycle.lifecycleScope
import com.github.cfogrady.vitalwear.protos.Character
import com.github.nacabaro.vbhelper.navigation.AppNavigation
import com.github.nacabaro.vbhelper.di.VBHelper
import com.github.nacabaro.vbhelper.navigation.AppNavigationHandlers
@@ -22,10 +18,7 @@ import com.github.nacabaro.vbhelper.screens.adventureScreen.AdventureScreenContr
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.source.VitalWearCharacterImporter
import com.github.nacabaro.vbhelper.ui.theme.VBHelperTheme
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
@@ -82,7 +75,6 @@ class MainActivity : ComponentActivity() {
}
Log.i("MainActivity", "Activity onCreated")
handleImportIntent(intent)
}
override fun onPause() {
@@ -105,54 +97,6 @@ class MainActivity : ComponentActivity() {
super.onNewIntent(intent)
setIntent(intent)
initialRoute = getInitialRouteFromIntent(intent)
// Optionally, you may want to trigger navigation here if needed
handleImportIntent(intent)
}
private fun handleImportIntent(intent: Intent?) {
val importUri = extractVitalWearImportUri(intent) ?: return
val application = applicationContext as VBHelper
lifecycleScope.launch(Dispatchers.IO) {
val result = runCatching {
contentResolver.openInputStream(importUri)?.use { inputStream ->
val character = Character.parseFrom(inputStream)
VitalWearCharacterImporter(application.container.db).importCharacter(character)
} ?: VitalWearCharacterImporter.ImportResult(
success = false,
message = "VitalWear import file could not be opened."
)
}.getOrElse {
VitalWearCharacterImporter.ImportResult(
success = false,
message = "VitalWear import failed: ${it.message ?: "Unknown error"}"
)
}
runOnUiThread {
Toast.makeText(this@MainActivity, result.message, Toast.LENGTH_LONG).show()
}
}
}
private fun extractVitalWearImportUri(intent: Intent?): Uri? {
if (intent == null) {
return null
}
val isVitalWearImport = intent.type == VITALWEAR_CHARACTER_MIME
if (!isVitalWearImport) {
return null
}
return when (intent.action) {
Intent.ACTION_SEND -> {
@Suppress("DEPRECATION")
intent.getParcelableExtra(Intent.EXTRA_STREAM)
}
Intent.ACTION_VIEW -> intent.data
else -> null
}
}
private fun getInitialRouteFromIntent(intent: Intent?): String? {
@@ -192,8 +136,4 @@ class MainActivity : ComponentActivity() {
initialRoute = initialRoute
)
}
companion object {
private const val VITALWEAR_CHARACTER_MIME = "application/x-vitalwear-character"
}
}
@@ -13,6 +13,9 @@ interface CardDao {
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertNewCard(card: Card): Long
@Query("SELECT * FROM Card")
fun getAllCards(): List<Card>
@Query("SELECT * FROM Card WHERE cardId = :id")
fun getCardByCardId(id: Int): List<Card>
@@ -0,0 +1,17 @@
package com.github.nacabaro.vbhelper.daos
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.github.nacabaro.vbhelper.domain.device_data.VitalWearCharacterSettings
@Dao
interface VitalWearSettingsDao {
@Query("SELECT * FROM VitalWearCharacterSettings WHERE characterId = :characterId LIMIT 1")
suspend fun getByCharacterId(characterId: Long): VitalWearCharacterSettings?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(settings: VitalWearCharacterSettings)
}
@@ -2,6 +2,8 @@ package com.github.nacabaro.vbhelper.database
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.migration.Migration
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
@@ -13,6 +15,7 @@ import com.github.nacabaro.vbhelper.daos.ItemDao
import com.github.nacabaro.vbhelper.daos.SpecialMissionDao
import com.github.nacabaro.vbhelper.daos.SpriteDao
import com.github.nacabaro.vbhelper.daos.UserCharacterDao
import com.github.nacabaro.vbhelper.daos.VitalWearSettingsDao
import com.github.nacabaro.vbhelper.domain.card.Background
import com.github.nacabaro.vbhelper.domain.card.CardCharacter
import com.github.nacabaro.vbhelper.domain.card.Card
@@ -29,10 +32,11 @@ 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.VBCharacterData
import com.github.nacabaro.vbhelper.domain.device_data.VitalsHistory
import com.github.nacabaro.vbhelper.domain.device_data.VitalWearCharacterSettings
import com.github.nacabaro.vbhelper.domain.items.Items
@Database(
version = 1,
version = 2,
entities = [
Card::class,
CardProgress::class,
@@ -50,7 +54,8 @@ import com.github.nacabaro.vbhelper.domain.items.Items
Items::class,
Adventure::class,
Background::class,
PossibleTransformations::class
PossibleTransformations::class,
VitalWearCharacterSettings::class,
]
)
abstract class AppDatabase : RoomDatabase() {
@@ -65,4 +70,39 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun specialMissionDao(): SpecialMissionDao
abstract fun cardAdventureDao(): CardAdventureDao
abstract fun cardFusionsDao(): CardFusionsDao
abstract fun vitalWearSettingsDao(): VitalWearSettingsDao
companion object {
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS `VitalWearCharacterSettings` (
`characterId` INTEGER NOT NULL,
`trainingInBackground` INTEGER NOT NULL,
`allowedBattles` INTEGER NOT NULL,
`accumulatedDailyInjuries` INTEGER NOT NULL,
PRIMARY KEY(`characterId`)
)
""".trimIndent()
)
// Clean existing duplicates before adding the unique index.
db.execSQL(
"""
DELETE FROM `CardCharacter`
WHERE `id` NOT IN (
SELECT MIN(`id`) FROM `CardCharacter` GROUP BY `cardId`, `charaIndex`
)
""".trimIndent()
)
db.execSQL(
"""
CREATE UNIQUE INDEX IF NOT EXISTS `index_CardCharacter_cardId_charaIndex`
ON `CardCharacter` (`cardId`, `charaIndex`)
""".trimIndent()
)
}
}
}
}
@@ -31,6 +31,7 @@ class DefaultAppContainer(private val context: Context) : AppContainer {
"internalDb"
)
.createFromAsset("items.db")
.addMigrations(AppDatabase.MIGRATION_1_2)
.build()
}
@@ -2,11 +2,15 @@ package com.github.nacabaro.vbhelper.domain.card
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import com.github.cfogrady.vbnfc.data.NfcCharacter
import com.github.nacabaro.vbhelper.domain.characters.Sprite
@Entity(
indices = [
Index(value = ["cardId", "charaIndex"], unique = true)
],
foreignKeys = [
ForeignKey(
entity = Card::class,
@@ -24,9 +28,8 @@ import com.github.nacabaro.vbhelper.domain.characters.Sprite
)
/*
* Character represents a character on a card. There should only be one of these per dimId
* and monIndex.
* TODO: Customs will mean this should be unique per cardName and monIndex
* Character represents a character on a specific card slot.
* Uniqueness is enforced per cardId + charaIndex.
*/
data class CardCharacter (
@PrimaryKey(autoGenerate = true) val id: Long = 0,
@@ -0,0 +1,13 @@
package com.github.nacabaro.vbhelper.domain.device_data
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity
data class VitalWearCharacterSettings(
@PrimaryKey val characterId: Long,
val trainingInBackground: Boolean = false,
val allowedBattles: Int = 1,
val accumulatedDailyInjuries: Int = 0,
)
@@ -41,8 +41,6 @@ import com.github.nacabaro.vbhelper.source.StorageRepository
import com.github.nacabaro.vbhelper.R
import com.github.nacabaro.vbhelper.dtos.CardDtos
import com.github.nacabaro.vbhelper.source.CardRepository
import com.github.nacabaro.vbhelper.source.VitalWearCharacterExporter
import android.widget.Toast
import com.github.nacabaro.vbhelper.utils.BitmapData
import kotlinx.coroutines.flow.flowOf
import kotlin.collections.emptyList
@@ -178,27 +176,6 @@ fun HomeScreen(
)
}
Button(
onClick = {
try {
val intent = VitalWearCharacterExporter(application, application.container.db)
.buildShareIntent(activeMon!!.id)
.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
application.startActivity(intent)
} catch (e: Exception) {
Toast.makeText(
application,
"Could not send character to VitalWear: ${e.message}",
Toast.LENGTH_LONG
).show()
}
},
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(text = "Send to VitalWear")
}
}
}
}
@@ -0,0 +1,8 @@
package com.github.nacabaro.vbhelper.screens.scanScreen
enum class DetectedTransport {
UNKNOWN,
NFC_A,
ISO_DEP,
}
@@ -24,6 +24,7 @@ import com.github.nacabaro.vbhelper.source.isMissingSecrets
import com.github.nacabaro.vbhelper.source.proto.Secrets
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.withContext
import com.github.nacabaro.vbhelper.R
@@ -131,6 +132,7 @@ fun ScanScreenPreview() {
navController = rememberNavController(),
scanScreenController = object: ScanScreenController {
override val secretsFlow = MutableStateFlow<Secrets>(Secrets.getDefaultInstance())
override val detectedTransportFlow: StateFlow<DetectedTransport> = MutableStateFlow(DetectedTransport.UNKNOWN)
override fun unregisterActivityLifecycleListener(key: String) { }
override fun registerActivityLifecycleListener(
key: String,
@@ -141,7 +143,7 @@ fun ScanScreenPreview() {
override fun flushCharacter(cardId: Long) {}
override fun onClickRead(secrets: Secrets, onComplete: ()->Unit, onMultipleCards: (List<Card>) -> Unit) {}
override fun onClickCheckCard(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: () -> Unit) {}
override fun onClickWrite(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: () -> Unit) {}
override fun onClickWrite(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: (ScanScreenController.WriteResult) -> Unit) {}
override fun cancelRead() {}
override fun characterFromNfc(nfcCharacter: NfcCharacter, onMultipleCards: (List<Card>, NfcCharacter) -> Unit): String { return "" }
override suspend fun characterToNfc(characterId: Long): NfcCharacter? { return null }
@@ -5,12 +5,21 @@ import com.github.nacabaro.vbhelper.ActivityLifecycleListener
import com.github.nacabaro.vbhelper.domain.card.Card
import com.github.nacabaro.vbhelper.source.proto.Secrets
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
interface ScanScreenController {
val secretsFlow: Flow<Secrets>
val detectedTransportFlow: StateFlow<DetectedTransport>
enum class WriteResult {
MOVE_CONFIRMED,
COPIED,
BLOCKED_DEVICE_FULL,
}
fun onClickRead(secrets: Secrets, onComplete: ()->Unit, onMultipleCards: (List<Card>) -> Unit)
fun onClickCheckCard(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: () -> Unit)
fun onClickWrite(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: () -> Unit)
fun onClickWrite(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: (WriteResult) -> Unit)
fun cancelRead()
@@ -3,6 +3,7 @@ package com.github.nacabaro.vbhelper.screens.scanScreen
import android.content.Intent
import android.nfc.NfcAdapter
import android.nfc.Tag
import android.nfc.tech.IsoDep
import android.nfc.tech.NfcA
import android.os.Bundle
import android.provider.Settings
@@ -16,16 +17,26 @@ import com.github.cfogrady.vbnfc.data.NfcCharacter
import com.github.cfogrady.vbnfc.vb.VBNfcCharacter
import com.github.nacabaro.vbhelper.ActivityLifecycleListener
import com.github.nacabaro.vbhelper.domain.card.Card
import com.github.nacabaro.vbhelper.di.VBHelper
import com.github.nacabaro.vbhelper.screens.scanScreen.converters.FromNfcConverter
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.source.getCryptographicTransformerMap
import com.github.nacabaro.vbhelper.source.isMissingSecrets
import com.github.nacabaro.vbhelper.source.proto.Secrets
import com.github.nacabaro.vbhelper.transfer.hce.VitalWearHceReaderClient
import com.github.nacabaro.vbhelper.R
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import com.github.nacabaro.vbhelper.R
import kotlinx.coroutines.runBlocking
import java.util.Arrays
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
class ScanScreenControllerImpl(
override val secretsFlow: Flow<Secrets>,
@@ -33,49 +44,201 @@ class ScanScreenControllerImpl(
private val registerActivityLifecycleListener: (String, ActivityLifecycleListener)->Unit,
private val unregisterActivityLifecycleListener: (String)->Unit,
): ScanScreenController {
override val detectedTransportFlow: StateFlow<DetectedTransport>
get() = _detectedTransportFlow
private val _detectedTransportFlow = MutableStateFlow(DetectedTransport.UNKNOWN)
private var lastScannedCharacter: NfcCharacter? = null
private var lastRequestedCharacterId: Long? = null
private val nfcAdapter: NfcAdapter
private val isHandlingTag = AtomicBoolean(false)
private var lastTagId: ByteArray? = null
private var lastTagHandledAtMs: Long = 0L
private val tagStateLock = Any()
private val readerSessionCounter = AtomicLong(0L)
@Volatile private var activeReaderSessionId: Long = 0L
companion object {
private const val TAG_DEBOUNCE_MS = 1500L
private val VITALWEAR_AID = byteArrayOf(
0xF0.toByte(), 0x56, 0x49, 0x54, 0x41, 0x4C, 0x57, 0x45, 0x41, 0x52
)
private const val SW_OK = 0x9000
}
init {
val maybeNfcAdapter = NfcAdapter.getDefaultAdapter(componentActivity)
if (maybeNfcAdapter == null) {
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_no_nfc_on_device), Toast.LENGTH_SHORT).show()
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_no_nfc_on_device), Toast.LENGTH_SHORT).show()
}
nfcAdapter = maybeNfcAdapter
checkSecrets()
}
override fun onClickRead(secrets: Secrets, onComplete: ()->Unit, onMultipleCards: (List<Card>) -> Unit) {
handleTag(secrets) { tagCommunicator ->
try {
val character = tagCommunicator.receiveCharacter()
val resultMessage = characterFromNfc(character) { cards, nfcCharacter ->
lastScannedCharacter = nfcCharacter
onMultipleCards(cards)
// ---- Read (phone receives character FROM watch or bracelet) --------------------
override fun onClickRead(secrets: Secrets, onComplete: () -> Unit, onMultipleCards: (List<Card>) -> Unit) {
_detectedTransportFlow.value = DetectedTransport.UNKNOWN
handleTag(
secrets,
nfcAHandler = { tagCommunicator ->
try {
val character = tagCommunicator.receiveCharacter()
val resultMessage = characterFromNfc(character) { cards, nfcCharacter ->
lastScannedCharacter = nfcCharacter
onMultipleCards(cards)
}
onComplete.invoke()
resultMessage
} catch (e: Exception) {
Log.e("NFC_READ", "Error reading character from NFC", e)
componentActivity.runOnUiThread {
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_error_generic) + ": " + (e.message ?: e.javaClass.simpleName), Toast.LENGTH_LONG).show()
}
onComplete.invoke()
componentActivity.getString(R.string.scan_error_generic)
}
onComplete.invoke()
resultMessage
} catch (e: Exception) {
Log.e("NFC_READ", "Error reading character from NFC", e)
componentActivity.runOnUiThread {
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_error_generic) + ": " + (e.message ?: e.javaClass.simpleName), Toast.LENGTH_LONG).show()
},
isoDepHandler = { isoDep ->
val application = componentActivity.applicationContext as VBHelper
val importer = VitalWearCharacterImporter(application.container.db)
try {
var importResult: VitalWearCharacterImporter.ImportResult? = null
val moved = VitalWearHceReaderClient(isoDep).moveCharacterFromWatch { character ->
val result = importer.importCharacter(character)
importResult = result
result.success
}
onComplete.invoke()
if (moved) {
importResult?.message ?: componentActivity.getString(R.string.scan_sent_character_success)
} else {
importResult?.message
?: "VitalWear import was rejected. Source character remains on the watch."
}
} catch (readError: Exception) {
Log.e("NFC_READ", "HCE read failed; watch may be armed as destination", readError)
onComplete.invoke()
"No source character detected on watch. If the watch is waiting to receive, use VBH to Watch."
}
onComplete.invoke()
componentActivity.getString(R.string.scan_error_generic)
}
}
)
}
// ---- Write (phone sends character TO watch or bracelet) ------------------------
override fun onClickWrite(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: (ScanScreenController.WriteResult) -> Unit) {
_detectedTransportFlow.value = DetectedTransport.UNKNOWN
handleTag(
secrets,
nfcAHandler = { tagCommunicator ->
try {
val initialSlotState = readNfcASlotState(tagCommunicator)
if (initialSlotState.isFull()) {
onComplete.invoke(ScanScreenController.WriteResult.BLOCKED_DEVICE_FULL)
return@handleTag componentActivity.getString(R.string.scan_target_device_full)
}
val migrationCheck = verifyActiveToBackupMigration(tagCommunicator, initialSlotState)
if (!migrationCheck.canProceed) {
onComplete.invoke(ScanScreenController.WriteResult.COPIED)
return@handleTag migrationCheck.message
}
when (nfcCharacter) {
is VBNfcCharacter -> tagCommunicator.sendCharacter(nfcCharacter)
is BENfcCharacter -> tagCommunicator.sendCharacter(nfcCharacter)
}
onComplete.invoke(ScanScreenController.WriteResult.MOVE_CONFIRMED)
componentActivity.getString(R.string.scan_sent_character_success)
} catch (e: Throwable) {
Log.e("TAG", e.stackTraceToString())
componentActivity.getString(R.string.scan_error_generic)
}
},
isoDepHandler = { isoDep ->
val characterId = lastRequestedCharacterId
?: throw IllegalStateException("No character id available for VitalWear HCE write")
val application = componentActivity.applicationContext as VBHelper
val hceClient = VitalWearHceReaderClient(isoDep)
try {
val proto = runBlocking {
VitalWearCharacterExporter(application.container.db)
.buildCharacterProto(characterId)
}
hceClient.sendCharacterToWatch(proto)
onComplete.invoke(ScanScreenController.WriteResult.MOVE_CONFIRMED)
componentActivity.getString(R.string.scan_sent_character_success)
} catch (writeError: Throwable) {
Log.e("NFC_WRITE_HCE", "HCE write failed; attempting opposite direction", writeError)
runCatching {
var importResult: VitalWearCharacterImporter.ImportResult? = null
val moved = hceClient.moveCharacterFromWatch { character ->
val result = VitalWearCharacterImporter(application.container.db).importCharacter(character)
importResult = result
result.success
}
onComplete.invoke(ScanScreenController.WriteResult.COPIED)
if (moved) {
importResult?.message
?: "Detected source on watch and imported it instead of sending."
} else {
importResult?.message
?: "Watch is in source mode, but import was rejected."
}
}.getOrElse { readFallbackError ->
Log.e("NFC_WRITE_HCE", "HCE opposite-direction fallback failed", readFallbackError)
componentActivity.getString(R.string.scan_error_generic)
}
}
}
)
}
// ---- Check card (NFC-A only; ISO-DEP watches don't need a DIM prep) -----------
override fun onClickCheckCard(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: () -> Unit) {
_detectedTransportFlow.value = DetectedTransport.UNKNOWN
handleTag(
secrets,
nfcAHandler = { tagCommunicator ->
tagCommunicator.prepareDIMForCharacter(nfcCharacter.dimId)
onComplete.invoke()
componentActivity.getString(R.string.scan_sent_dim_success)
},
// HCE equivalent of the prep step: verify watch is armed for phone->watch transfer.
isoDepHandler = { isoDep ->
runCatching {
VitalWearHceReaderClient(isoDep).verifyWatchReadyToReceive()
}.fold(
onSuccess = {
onComplete.invoke()
"Watch ready. Tap again to send character."
},
onFailure = { error ->
Log.e("NFC_HCE", "Watch is not ready to receive character", error)
"Watch is not ready. On watch: Transfer > Receive from VBH, then tap again."
}
)
}
)
}
// ---- Cancel / lifecycle -------------------------------------------------------
override fun cancelRead() {
if(nfcAdapter.isEnabled) {
nfcAdapter.disableReaderMode(componentActivity)
_detectedTransportFlow.value = DetectedTransport.UNKNOWN
activeReaderSessionId = readerSessionCounter.incrementAndGet()
isHandlingTag.set(false)
synchronized(tagStateLock) {
lastTagId = null
lastTagHandledAtMs = 0L
}
disableReaderModeSafely()
}
override fun registerActivityLifecycleListener(
key: String,
activityLifecycleListener: ActivityLifecycleListener
) {
override fun registerActivityLifecycleListener(key: String, activityLifecycleListener: ActivityLifecycleListener) {
registerActivityLifecycleListener.invoke(key, activityLifecycleListener)
}
@@ -83,43 +246,184 @@ class ScanScreenControllerImpl(
unregisterActivityLifecycleListener.invoke(key)
}
// EXTRACTED DIRECTLY FROM EXAMPLE APP
private fun handleTag(secrets: Secrets, handlerFunc: (TagCommunicator)->String) {
// ---- NFC adapter wiring -------------------------------------------------------
/**
* Arms the NFC reader for both NFC-A (bracelet) and ISO-DEP (VitalWear HCE).
* [isoDepHandler] is optional; when null the reader only accepts NFC-A.
*/
private fun handleTag(
secrets: Secrets,
nfcAHandler: (TagCommunicator) -> String,
isoDepHandler: ((IsoDep) -> String)? = null
) {
if (!nfcAdapter.isEnabled) {
showWirelessSettings()
} else {
val options = Bundle()
// Work around for some broken Nfc firmware implementations that poll the card too fast
options.putInt(NfcAdapter.EXTRA_READER_PRESENCE_CHECK_DELAY, 250)
nfcAdapter.enableReaderMode(componentActivity, buildOnReadTag(secrets, handlerFunc), NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK,
options
)
return
}
val options = Bundle()
// Work around for some broken Nfc firmware implementations that poll the card too fast
options.putInt(NfcAdapter.EXTRA_READER_PRESENCE_CHECK_DELAY, 250)
val flags = NfcAdapter.FLAG_READER_NFC_A or
NfcAdapter.FLAG_READER_NFC_B or
NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK
val sessionId = readerSessionCounter.incrementAndGet()
activeReaderSessionId = sessionId
isHandlingTag.set(false)
synchronized(tagStateLock) {
lastTagId = null
lastTagHandledAtMs = 0L
}
disableReaderModeSafely()
nfcAdapter.enableReaderMode(
componentActivity,
buildOnReadTag(secrets, nfcAHandler, isoDepHandler, sessionId),
flags,
options
)
}
private fun buildOnReadTag(
secrets: Secrets,
nfcAHandler: (TagCommunicator) -> String,
isoDepHandler: ((IsoDep) -> String)?,
sessionId: Long,
): (Tag) -> Unit {
return { tag ->
if (activeReaderSessionId == sessionId) {
val now = System.currentTimeMillis()
val shouldHandleTag = synchronized(tagStateLock) {
val sameRecentTag = lastTagId != null && tag.id != null && Arrays.equals(lastTagId, tag.id) &&
(now - lastTagHandledAtMs) < TAG_DEBOUNCE_MS
if (sameRecentTag || !isHandlingTag.compareAndSet(false, true)) {
false
} else {
lastTagId = tag.id?.clone()
lastTagHandledAtMs = now
true
}
}
if (shouldHandleTag) {
// Detect transport once per tap and lock to that route for this transfer.
val isoDep = IsoDep.get(tag)
val hasIsoDepRoute = isoDep != null && isoDepHandler != null
val hasNfcARoute = NfcA.get(tag) != null
try {
// Prefer ISO-DEP whenever present so HCE sessions do not accidentally fall through to NFC-A.
if (hasIsoDepRoute) {
val isoDepTarget = isoDep!!
val isoDepAction = isoDepHandler!!
val confirmedVitalWear = runCatching { isVitalWearHceTarget(isoDepTarget) }.getOrDefault(false)
if (!confirmedVitalWear) {
Log.w("NFC_HCE", "IsoDep tag did not confirm VitalWear AID; attempting ISO-DEP handler without NFC-A fallback")
}
try {
_detectedTransportFlow.value = DetectedTransport.ISO_DEP
isoDepTarget.connect()
isoDepTarget.use {
val successText = isoDepAction.invoke(isoDepTarget)
componentActivity.runOnUiThread {
Toast.makeText(componentActivity, successText, Toast.LENGTH_SHORT).show()
}
}
} catch (e: Throwable) {
Log.e("NFC_HCE", "IsoDep transfer failed", e)
componentActivity.runOnUiThread {
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_error_generic), Toast.LENGTH_SHORT).show()
}
}
} else if (hasNfcARoute) {
if (!handleNfcATag(tag, secrets, nfcAHandler)) {
componentActivity.runOnUiThread {
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_tag_not_vb), Toast.LENGTH_SHORT).show()
}
}
} else {
_detectedTransportFlow.value = DetectedTransport.UNKNOWN
componentActivity.runOnUiThread {
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_tag_not_vb), Toast.LENGTH_SHORT).show()
}
}
} finally {
if (activeReaderSessionId == sessionId) {
disableReaderModeSafely()
isHandlingTag.set(false)
}
}
}
}
}
}
// EXTRACTED DIRECTLY FROM EXAMPLE APP
private fun buildOnReadTag(secrets: Secrets, handlerFunc: (TagCommunicator)->String): (Tag)->Unit {
return { tag->
val nfcData = NfcA.get(tag)
if (nfcData == null) {
componentActivity.runOnUiThread {
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_tag_not_vb), Toast.LENGTH_SHORT).show()
}
}
private fun handleNfcATag(
tag: Tag,
secrets: Secrets,
nfcAHandler: (TagCommunicator) -> String,
): Boolean {
val nfcData = NfcA.get(tag) ?: return false
_detectedTransportFlow.value = DetectedTransport.NFC_A
return try {
nfcData.connect()
nfcData.use {
val tagCommunicator = TagCommunicator.getInstance(nfcData, secrets.getCryptographicTransformerMap())
val successText = handlerFunc(tagCommunicator)
val successText = nfcAHandler(tagCommunicator)
componentActivity.runOnUiThread {
Toast.makeText(componentActivity, successText, Toast.LENGTH_SHORT).show()
}
}
true
} catch (e: Throwable) {
val staleTagSession = e is SecurityException && (e.message?.contains("out of date", ignoreCase = true) == true)
if (staleTagSession) {
Log.w("NFC_A", "Ignoring stale NFC-A tag session; waiting for a fresh tap", e)
componentActivity.runOnUiThread {
Toast.makeText(componentActivity, "Tag session expired. Please tap again.", Toast.LENGTH_SHORT).show()
}
return false
}
Log.e("NFC_A", "NfcA transfer failed", e)
componentActivity.runOnUiThread {
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_error_generic), Toast.LENGTH_SHORT).show()
}
true
}
}
private fun disableReaderModeSafely() {
if (!nfcAdapter.isEnabled) return
runCatching { nfcAdapter.disableReaderMode(componentActivity) }
}
private fun isVitalWearHceTarget(isoDep: IsoDep): Boolean {
return runCatching {
val originalTimeout = isoDep.timeout
try {
isoDep.timeout = 500
if (!isoDep.isConnected) {
isoDep.connect()
}
val selectApdu = byteArrayOf(0x00, 0xA4.toByte(), 0x04, 0x00, VITALWEAR_AID.size.toByte()) + VITALWEAR_AID
val response = isoDep.transceive(selectApdu)
statusWord(response) == SW_OK
} finally {
isoDep.timeout = originalTimeout
runCatching { if (isoDep.isConnected) isoDep.close() }
}
}.getOrDefault(false)
}
private fun statusWord(response: ByteArray): Int {
if (response.size < 2) return -1
return ((response[response.size - 2].toInt() and 0xFF) shl 8) or
(response[response.size - 1].toInt() and 0xFF)
}
// ---- Misc ---------------------------------------------------------------------
private fun checkSecrets() {
componentActivity.lifecycleScope.launch(Dispatchers.IO) {
if(secretsFlow.stateIn(componentActivity.lifecycleScope).value.isMissingSecrets()) {
if (secretsFlow.stateIn(componentActivity.lifecycleScope).value.isMissingSecrets()) {
componentActivity.runOnUiThread {
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_missing_secrets), Toast.LENGTH_SHORT).show()
}
@@ -127,74 +431,153 @@ class ScanScreenControllerImpl(
}
}
override fun onClickWrite(
secrets: Secrets,
nfcCharacter: NfcCharacter,
onComplete: () -> Unit
) {
handleTag(secrets) { tagCommunicator ->
try {
if (nfcCharacter is VBNfcCharacter) {
Log.d("SendCharacter", "VBNfcCharacter")
val castNfcCharacter: VBNfcCharacter = nfcCharacter
tagCommunicator.sendCharacter(castNfcCharacter)
} else if (nfcCharacter is BENfcCharacter) {
Log.d("SendCharacter", "BENfcCharacter")
val castNfcCharacter: BENfcCharacter = nfcCharacter
tagCommunicator.sendCharacter(castNfcCharacter)
}
onComplete.invoke()
componentActivity.getString(R.string.scan_sent_character_success)
} catch (e: Throwable) {
Log.e("TAG", e.stackTraceToString())
componentActivity.getString(R.string.scan_error_generic)
}
}
}
override fun onClickCheckCard(
secrets: Secrets,
nfcCharacter: NfcCharacter,
onComplete: () -> Unit
) {
handleTag(secrets) { tagCommunicator ->
tagCommunicator.prepareDIMForCharacter(nfcCharacter.dimId)
onComplete.invoke()
componentActivity.getString(R.string.scan_sent_dim_success)
}
}
// EXTRACTED DIRECTLY FROM EXAMPLE APP
private fun showWirelessSettings() {
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_nfc_must_be_enabled), Toast.LENGTH_SHORT).show()
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_nfc_must_be_enabled), Toast.LENGTH_SHORT).show()
componentActivity.startActivity(Intent(Settings.ACTION_WIRELESS_SETTINGS))
}
override fun characterFromNfc(
nfcCharacter: NfcCharacter,
onMultipleCards: (List<Card>, NfcCharacter) -> Unit
): String {
val nfcConverter = FromNfcConverter(
componentActivity = componentActivity
)
return nfcConverter.addCharacter(nfcCharacter, onMultipleCards)
/**
* Best-effort, non-destructive slot-capacity check for NFC-A devices.
*/
private fun readNfcASlotState(tagCommunicator: TagCommunicator): NfcASlotState {
val communicatorClass = tagCommunicator.javaClass
val countMethod = communicatorClass.methods.firstOrNull {
it.parameterCount == 0 &&
Number::class.java.isAssignableFrom(it.returnType) &&
it.name.contains("count", ignoreCase = true) &&
it.name.contains("character", ignoreCase = true)
}
val count = if (countMethod != null) {
runCatching { (countMethod.invoke(tagCommunicator) as Number).toInt() }.getOrNull()
} else null
val activeMethod = communicatorClass.methods.firstOrNull {
it.parameterCount == 0 && it.returnType == java.lang.Boolean.TYPE &&
(it.name.contains("active", ignoreCase = true) || it.name.contains("current", ignoreCase = true)) &&
it.name.contains("character", ignoreCase = true)
}
val backupMethod = communicatorClass.methods.firstOrNull {
it.parameterCount == 0 && it.returnType == java.lang.Boolean.TYPE &&
it.name.contains("backup", ignoreCase = true) &&
it.name.contains("character", ignoreCase = true)
}
val activePresent = if (activeMethod != null) {
runCatching { activeMethod.invoke(tagCommunicator) as Boolean }.getOrNull()
} else null
val backupPresent = if (backupMethod != null) {
runCatching { backupMethod.invoke(tagCommunicator) as Boolean }.getOrNull()
} else null
if (count == null && activePresent == null && backupPresent == null) {
Log.w("NFC_A", "Unable to introspect NFC-A slot occupancy; defaulting to non-blocking write path")
}
return NfcASlotState(count = count, activePresent = activePresent, backupPresent = backupPresent)
}
/**
* Attempts to mirror official toy behavior by moving the active character to backup.
* If the library already does this internally, this is a harmless no-op.
*/
private fun verifyActiveToBackupMigration(
tagCommunicator: TagCommunicator,
beforeState: NfcASlotState,
): SlotMigrationCheck {
val migrationResult = moveActiveToBackupIfSupported(tagCommunicator)
if (migrationResult.attempted && !migrationResult.success) {
return SlotMigrationCheck(
canProceed = false,
message = "Transfer blocked. Could not move active character to backup safely."
)
}
// If we could not introspect occupancy at all, keep legacy behavior and proceed.
if (!beforeState.hasOccupancySignal()) {
return SlotMigrationCheck(canProceed = true, message = "")
}
val afterState = readNfcASlotState(tagCommunicator)
val migrationVerified = afterState.backupPresent == true ||
(beforeState.count != null && afterState.count != null && afterState.count >= beforeState.count)
if (!migrationVerified) {
return SlotMigrationCheck(
canProceed = false,
message = "Transfer blocked. Backup slot could not be verified after migration."
)
}
return SlotMigrationCheck(canProceed = true, message = "")
}
private fun moveActiveToBackupIfSupported(tagCommunicator: TagCommunicator): MigrationInvokeResult {
val communicatorClass = tagCommunicator.javaClass
val candidate = communicatorClass.methods.firstOrNull {
it.parameterCount == 0 &&
(
(it.name.contains("move", ignoreCase = true) && it.name.contains("backup", ignoreCase = true)) ||
(it.name.contains("shift", ignoreCase = true) && it.name.contains("backup", ignoreCase = true)) ||
(it.name.contains("promote", ignoreCase = true) && it.name.contains("backup", ignoreCase = true))
)
}
if (candidate == null) {
return MigrationInvokeResult(attempted = false, success = true)
}
return runCatching { candidate.invoke(tagCommunicator) }
.fold(
onSuccess = { MigrationInvokeResult(attempted = true, success = true) },
onFailure = {
Log.w("NFC_A", "Failed to move active character to backup", it)
MigrationInvokeResult(attempted = true, success = false)
}
)
}
private data class NfcASlotState(
val count: Int?,
val activePresent: Boolean?,
val backupPresent: Boolean?,
) {
fun hasOccupancySignal(): Boolean {
return count != null || activePresent != null || backupPresent != null
}
fun isFull(): Boolean {
if (count != null) {
return count >= 2
}
if (activePresent != null && backupPresent != null) {
return activePresent && backupPresent
}
return false
}
}
private data class SlotMigrationCheck(
val canProceed: Boolean,
val message: String,
)
private data class MigrationInvokeResult(
val attempted: Boolean,
val success: Boolean,
)
override fun characterFromNfc(nfcCharacter: NfcCharacter, onMultipleCards: (List<Card>, NfcCharacter) -> Unit): String {
return FromNfcConverter(componentActivity = componentActivity).addCharacter(nfcCharacter, onMultipleCards)
}
override suspend fun characterToNfc(characterId: Long): NfcCharacter {
val nfcGenerator = ToNfcConverter(
componentActivity = componentActivity
)
val character = nfcGenerator.characterToNfc(characterId)
lastRequestedCharacterId = characterId
val character = ToNfcConverter(componentActivity = componentActivity).characterToNfc(characterId)
Log.d("CharacterType", character.toString())
return character
}
override fun flushCharacter(cardId: Long) {
val nfcConverter = FromNfcConverter(
componentActivity = componentActivity
)
val nfcConverter = FromNfcConverter(componentActivity = componentActivity)
componentActivity.lifecycleScope.launch(Dispatchers.IO) {
if (lastScannedCharacter != null) {
nfcConverter.addCharacterUsingCard(lastScannedCharacter!!, cardId)
@@ -12,9 +12,11 @@ import com.github.cfogrady.vbnfc.vb.VBNfcCharacter
import com.github.nacabaro.vbhelper.database.AppDatabase
import com.github.nacabaro.vbhelper.di.VBHelper
import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter
import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData
import com.github.nacabaro.vbhelper.dtos.CharacterDtos
import com.github.nacabaro.vbhelper.utils.DeviceType
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import java.util.Date
class ToNfcConverter(
@@ -39,7 +41,10 @@ class ToNfcConverter(
.characterDao()
.getCharacterInfo(userCharacter.charId)
return if (userCharacter.characterType == DeviceType.BEDevice)
val card = database.cardDao().getCardByCharacterIdSync(characterId)
val shouldEncodeAsBem = card?.isBEm ?: (userCharacter.characterType == DeviceType.BEDevice)
return if (shouldEncodeAsBem)
nfcToBENfc(characterId, characterInfo, userCharacter)
else
nfcToVBNfc(characterId, characterInfo, userCharacter)
@@ -55,7 +60,12 @@ class ToNfcConverter(
val vbData = database
.userCharacterDao()
.getVbData(characterId)
.first()
.firstOrNull()
?: VBCharacterData(
id = characterId,
generation = 0,
totalTrophies = userCharacter.trophies
)
val paddedTransformationArray = generateTransformationHistory(characterId, 9)
@@ -1,5 +1,6 @@
package com.github.nacabaro.vbhelper.screens.scanScreen.screens
import android.util.Log
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
@@ -15,6 +16,7 @@ import com.github.nacabaro.vbhelper.screens.scanScreen.SCAN_SCREEN_ACTIVITY_LIFE
import com.github.nacabaro.vbhelper.screens.scanScreen.ScanScreenController
import com.github.nacabaro.vbhelper.R
private const val TAG = "ReadingScreen"
@Composable
fun ReadingScreen(
@@ -30,55 +32,72 @@ fun ReadingScreen(
var isDoneReadingCharacter by remember { mutableStateOf(false) }
var cardSelectScreen by remember { mutableStateOf(false) }
fun startReadIfNeeded() {
val availableSecrets = secrets
if (availableSecrets == null) {
Log.d(TAG, "startReadIfNeeded: skipped (no secrets)")
return
}
if (!readingScreen || isDoneReadingCharacter || cardSelectScreen) {
Log.d(
TAG,
"startReadIfNeeded: skipped (readingScreen=$readingScreen, done=$isDoneReadingCharacter, cardSelect=$cardSelectScreen)"
)
return
}
Log.d(TAG, "startReadIfNeeded: arming onClickRead")
scanScreenController.onClickRead(
secrets = availableSecrets,
onComplete = {
Log.d(TAG, "onClickRead.onComplete: marking read complete")
readingScreen = false
isDoneReadingCharacter = true
},
onMultipleCards = { cards ->
Log.d(TAG, "onClickRead.onMultipleCards: showing card select (count=${cards.size})")
cardsRead = cards
readingScreen = false
cardSelectScreen = true
isDoneReadingCharacter = true
}
)
}
DisposableEffect(readingScreen) {
if(readingScreen) {
Log.d(TAG, "DisposableEffect: readingScreen=true, registering lifecycle listener")
scanScreenController.registerActivityLifecycleListener(
SCAN_SCREEN_ACTIVITY_LIFECYCLE_LISTENER,
object: ActivityLifecycleListener {
override fun onPause() {
Log.d(TAG, "lifecycle.onPause: cancelRead")
scanScreenController.cancelRead()
}
override fun onResume() {
scanScreenController.onClickRead(
secrets = secrets!!,
onComplete = {
isDoneReadingCharacter = true
},
onMultipleCards = { cards ->
cardsRead = cards
readingScreen = false
cardSelectScreen = true
isDoneReadingCharacter = true
}
)
Log.d(TAG, "lifecycle.onResume: attempting startReadIfNeeded")
startReadIfNeeded()
}
}
)
scanScreenController.onClickRead(
secrets = secrets!!,
onComplete = {
isDoneReadingCharacter = true
},
onMultipleCards = { cards ->
cardsRead = cards
readingScreen = false
cardSelectScreen = true
isDoneReadingCharacter = true
}
)
startReadIfNeeded()
}
onDispose {
if(readingScreen) {
Log.d(TAG, "DisposableEffect.onDispose: unregister listener + cancelRead")
scanScreenController.unregisterActivityLifecycleListener(
SCAN_SCREEN_ACTIVITY_LIFECYCLE_LISTENER
)
scanScreenController.cancelRead()
} else {
Log.d(TAG, "DisposableEffect.onDispose: readingScreen=false, nothing to cancel")
}
}
}
if (isDoneReadingCharacter && !cardSelectScreen) {
Log.d(TAG, "state gate: done read without cardSelect, calling onComplete")
readingScreen = false
onComplete()
}
@@ -86,9 +105,11 @@ fun ReadingScreen(
if (!readingScreen) {
ReadCharacterScreen(
onClickConfirm = {
Log.d(TAG, "ReadCharacterScreen.onClickConfirm: entering reading screen")
readingScreen = true
},
onClickCancel = {
Log.d(TAG, "ReadCharacterScreen.onClickCancel: user cancelled")
onCancel()
}
)
@@ -96,6 +117,7 @@ fun ReadingScreen(
if (readingScreen) {
ActionScreen(topBannerText = stringResource(R.string.reading_character_title),) {
Log.d(TAG, "ActionScreen.onCancel: cancelRead + onCancel")
readingScreen = false
scanScreenController.cancelRead()
onCancel()
@@ -104,6 +126,7 @@ fun ReadingScreen(
ChooseCard(
cards = cardsRead!!,
onCardSelected = { card ->
Log.d(TAG, "ChooseCard.onCardSelected: selected cardId=${card.id}")
cardSelectScreen = false
scanScreenController.flushCharacter(card.id)
}
@@ -14,6 +14,7 @@ import com.github.nacabaro.vbhelper.ActivityLifecycleListener
import com.github.nacabaro.vbhelper.di.VBHelper
import com.github.nacabaro.vbhelper.screens.scanScreen.SCAN_SCREEN_ACTIVITY_LIFECYCLE_LISTENER
import com.github.nacabaro.vbhelper.screens.scanScreen.ScanScreenController
import com.github.nacabaro.vbhelper.screens.scanScreen.ScanScreenController.WriteResult
import com.github.nacabaro.vbhelper.source.StorageRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -38,6 +39,7 @@ fun WritingScreen(
var writingConfirmScreen by remember { mutableStateOf(false) }
var isDoneSendingCard by remember { mutableStateOf(false) }
var isDoneWritingCharacter by remember { mutableStateOf(false) }
var writeResult by remember { mutableStateOf<WriteResult?>(null) }
DisposableEffect(writing) {
if (writing) {
@@ -54,7 +56,8 @@ fun WritingScreen(
isDoneSendingCard = true
}
} else if (!isDoneWritingCharacter) {
scanScreenController.onClickWrite(secrets!!, nfcCharacter) {
scanScreenController.onClickWrite(secrets!!, nfcCharacter) { result ->
writeResult = result
isDoneWritingCharacter = true
}
}
@@ -68,7 +71,8 @@ fun WritingScreen(
isDoneSendingCard = true
}
} else if (!isDoneWritingCharacter) {
scanScreenController.onClickWrite(secrets!!, nfcCharacter) {
scanScreenController.onClickWrite(secrets!!, nfcCharacter) { result ->
writeResult = result
isDoneWritingCharacter = true
}
}
@@ -129,8 +133,9 @@ fun WritingScreen(
LaunchedEffect(isDoneSendingCard, isDoneWritingCharacter) {
withContext(Dispatchers.IO) {
if (isDoneSendingCard && isDoneWritingCharacter) {
storageRepository
.deleteCharacter(characterId)
if (writeResult == WriteResult.MOVE_CONFIRMED) {
storageRepository.deleteCharacter(characterId)
}
completedWriting = true
}
}
@@ -1,86 +1,68 @@
package com.github.nacabaro.vbhelper.source
import android.content.ClipData
import android.content.Context
import android.content.Intent
import androidx.core.content.FileProvider
import com.github.cfogrady.vitalwear.protos.Character
import com.github.nacabaro.vbhelper.database.AppDatabase
import com.github.nacabaro.vbhelper.utils.DeviceType
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.runBlocking
import java.io.File
class VitalWearCharacterExporter(
private val context: Context,
private val database: AppDatabase
) {
fun buildShareIntent(characterId: Long): Intent {
return runBlocking {
val characterWithSprites = database.userCharacterDao().getCharacterWithSprites(characterId)
val userCharacter = database.userCharacterDao().getCharacter(characterId)
val card = database.cardDao().getCardByCharacterIdSync(characterId)
?: error("Card not found for character $characterId")
val cardProgress = database.cardProgressDao().getCardProgressSync(card.id) ?: 0
val proto = Character.newBuilder()
.setCardId(card.cardId)
.setCardName(card.name)
.setCharacterStats(
Character.CharacterStats.newBuilder()
.setSlotId(database.userCharacterDao().getCharacterInfo(characterId).charaIndex)
.setVitals(characterWithSprites.vitalPoints)
.setTrainingTimeRemainingInSeconds(resolveTrainingSeconds(characterId, userCharacter.characterType))
.setTimeUntilNextTransformation(characterWithSprites.transformationCountdown.toLong() * 60L)
.setTrainedBp(resolveTrainedBp(characterId, userCharacter.characterType))
.setTrainedHp(resolveTrainedHp(characterId, userCharacter.characterType))
.setTrainedAp(resolveTrainedAp(characterId, userCharacter.characterType))
.setTrainedPp(characterWithSprites.trophies)
.setInjured(characterWithSprites.injuryStatus.name.lowercase().contains("inj"))
.setAccumulatedDailyInjuries(0)
.setTotalBattles(characterWithSprites.totalBattlesWon + characterWithSprites.totalBattlesLost)
.setCurrentPhaseBattles(characterWithSprites.currentPhaseBattlesWon + characterWithSprites.currentPhaseBattlesLost)
.setTotalWins(characterWithSprites.totalBattlesWon)
.setCurrentPhaseWins(characterWithSprites.currentPhaseBattlesWon)
.setMood(characterWithSprites.mood)
/**
* Builds a Character proto from the stored character data.
* Used by the HCE ISO-DEP transfer path.
*/
suspend fun buildCharacterProto(characterId: Long): Character {
val characterWithSprites = database.userCharacterDao().getCharacterWithSprites(characterId)
val userCharacter = database.userCharacterDao().getCharacter(characterId)
val vwSettings = database.vitalWearSettingsDao().getByCharacterId(characterId)
val card = database.cardDao().getCardByCharacterIdSync(characterId)
?: error("Card not found for character $characterId")
val cardProgress = database.cardProgressDao().getCardProgressSync(card.id) ?: 0
return Character.newBuilder()
.setCardId(card.cardId)
.setCardName(card.name)
.setCharacterStats(
Character.CharacterStats.newBuilder()
.setSlotId(database.userCharacterDao().getCharacterInfo(characterId).charaIndex)
.setVitals(characterWithSprites.vitalPoints)
.setTrainingTimeRemainingInSeconds(resolveTrainingSeconds(characterId, userCharacter.characterType))
.setTimeUntilNextTransformation(characterWithSprites.transformationCountdown.toLong() * 60L)
.setTrainedBp(resolveTrainedBp(characterId, userCharacter.characterType))
.setTrainedHp(resolveTrainedHp(characterId, userCharacter.characterType))
.setTrainedAp(resolveTrainedAp(characterId, userCharacter.characterType))
.setTrainedPp(characterWithSprites.trophies)
.setInjured(characterWithSprites.injuryStatus.name.lowercase().contains("inj"))
.setAccumulatedDailyInjuries(vwSettings?.accumulatedDailyInjuries ?: 0)
.setTotalBattles(characterWithSprites.totalBattlesWon + characterWithSprites.totalBattlesLost)
.setCurrentPhaseBattles(characterWithSprites.currentPhaseBattlesWon + characterWithSprites.currentPhaseBattlesLost)
.setTotalWins(characterWithSprites.totalBattlesWon)
.setCurrentPhaseWins(characterWithSprites.currentPhaseBattlesWon)
.setMood(characterWithSprites.mood)
.build()
)
.setSettings(
Character.Settings.newBuilder()
.setTrainingInBackground(vwSettings?.trainingInBackground ?: false)
.setAllowedBattles(resolveAllowedBattles(vwSettings?.allowedBattles))
.build()
)
.putMaxAdventureCompletedByCard(card.name, (cardProgress - 1).coerceAtLeast(0))
.addAllTransformationHistory(
database.userCharacterDao().getTransformationHistoryForExport(characterId).map {
Character.TransformationEvent.newBuilder()
.setCardName(it.cardName)
.setPhase(0)
.setSlotId(it.monIndex)
.build()
)
.setSettings(
Character.Settings.newBuilder()
.setTrainingInBackground(false)
.setAllowedBattles(Character.Settings.AllowedBattles.CARD_ONLY)
.build()
)
.putMaxAdventureCompletedByCard(card.name, (cardProgress - 1).coerceAtLeast(0))
.addAllTransformationHistory(
database.userCharacterDao().getTransformationHistoryForExport(characterId).map {
Character.TransformationEvent.newBuilder()
.setCardName(it.cardName)
.setPhase(0)
.setSlotId(it.monIndex)
.build()
}
)
.build()
val exportDir = File(context.cacheDir, "exports").apply { mkdirs() }
val exportFile = File(exportDir, "vbhelper_character_$characterId.vitalwear")
exportFile.writeBytes(proto.toByteArray())
val exportUri = FileProvider.getUriForFile(context, "${context.packageName}.provider", exportFile)
Intent(Intent.ACTION_SEND).apply {
`package` = "com.github.cfogrady.vitalwear"
type = VITALWEAR_CHARACTER_MIME
putExtra(Intent.EXTRA_STREAM, exportUri)
clipData = ClipData.newRawUri("", exportUri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
}
}
)
.build()
}
private fun resolveTrainingSeconds(characterId: Long, deviceType: DeviceType): Long {
if (deviceType != DeviceType.BEDevice) {
return 0L
}
if (deviceType != DeviceType.BEDevice) return 0L
return database.userCharacterDao().getBeData(characterId).valueOrNull()?.remainingTrainingTimeInMinutes?.toLong()?.times(60L) ?: 0L
}
@@ -103,12 +85,13 @@ class VitalWearCharacterExporter(
}
private fun <T> kotlinx.coroutines.flow.Flow<T>.valueOrNull(): T? {
return runBlocking {
firstOrNull()
}
return runBlocking { firstOrNull() }
}
companion object {
const val VITALWEAR_CHARACTER_MIME = "application/x-vitalwear-character"
private fun resolveAllowedBattles(rawValue: Int?): Character.Settings.AllowedBattles {
if (rawValue == null) return Character.Settings.AllowedBattles.CARD_ONLY
return Character.Settings.AllowedBattles.forNumber(rawValue)
?: Character.Settings.AllowedBattles.CARD_ONLY
}
}
@@ -3,9 +3,13 @@ package com.github.nacabaro.vbhelper.source
import com.github.cfogrady.vbnfc.data.NfcCharacter
import com.github.cfogrady.vitalwear.protos.Character
import com.github.nacabaro.vbhelper.database.AppDatabase
import com.github.nacabaro.vbhelper.domain.card.Card
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.utils.DeviceType
import kotlinx.coroutines.runBlocking
import kotlin.math.max
class VitalWearCharacterImporter(
@@ -37,6 +41,7 @@ class VitalWearCharacterImporter(
val totalWins = character.characterStats.totalWins.coerceAtMost(totalBattles)
val currentPhaseBattles = character.characterStats.currentPhaseBattles
val currentPhaseWins = character.characterStats.currentPhaseWins.coerceAtMost(currentPhaseBattles)
val deviceType = if (importedCard.isBEm) DeviceType.BEDevice else DeviceType.VBDevice
val userCharacterId = database.userCharacterDao().insertCharacterData(
UserCharacter(
@@ -53,44 +58,70 @@ class VitalWearCharacterImporter(
totalBattlesLost = (totalBattles - totalWins).coerceAtLeast(0),
activityLevel = 0,
heartRateCurrent = 0,
characterType = DeviceType.BEDevice,
characterType = deviceType,
isActive = true
)
)
database.userCharacterDao().insertBECharacterData(
BECharacterData(
id = userCharacterId,
trainingHp = character.characterStats.trainedHp,
trainingAp = character.characterStats.trainedAp,
trainingBp = character.characterStats.trainedBp,
remainingTrainingTimeInMinutes = secondsToMinutes(character.characterStats.trainingTimeRemainingInSeconds),
itemEffectMentalStateValue = 0,
itemEffectMentalStateMinutesRemaining = 0,
itemEffectActivityLevelValue = 0,
itemEffectActivityLevelMinutesRemaining = 0,
itemEffectVitalPointsChangeValue = 0,
itemEffectVitalPointsChangeMinutesRemaining = 0,
abilityRarity = resolveDefaultAbilityRarity(),
abilityType = 0,
abilityBranch = 0,
abilityReset = 0,
rank = 0,
itemType = 0,
itemMultiplier = 0,
itemRemainingTime = 0,
otp0 = "",
otp1 = "",
minorVersion = 0,
majorVersion = 0
runBlocking {
database.vitalWearSettingsDao().upsert(
VitalWearCharacterSettings(
characterId = userCharacterId,
trainingInBackground = character.settings.trainingInBackground,
allowedBattles = character.settings.allowedBattles.number,
accumulatedDailyInjuries = character.characterStats.accumulatedDailyInjuries,
)
)
)
}
if (importedCard.isBEm) {
database.userCharacterDao().insertBECharacterData(
BECharacterData(
id = userCharacterId,
trainingHp = character.characterStats.trainedHp,
trainingAp = character.characterStats.trainedAp,
trainingBp = character.characterStats.trainedBp,
remainingTrainingTimeInMinutes = secondsToMinutes(character.characterStats.trainingTimeRemainingInSeconds),
itemEffectMentalStateValue = 0,
itemEffectMentalStateMinutesRemaining = 0,
itemEffectActivityLevelValue = 0,
itemEffectActivityLevelMinutesRemaining = 0,
itemEffectVitalPointsChangeValue = 0,
itemEffectVitalPointsChangeMinutesRemaining = 0,
abilityRarity = resolveDefaultAbilityRarity(),
abilityType = 0,
abilityBranch = 0,
abilityReset = 0,
rank = 0,
itemType = 0,
itemMultiplier = 0,
itemRemainingTime = 0,
otp0 = "",
otp1 = "",
minorVersion = 0,
majorVersion = 0
)
)
} else {
database.userCharacterDao().insertVBCharacterData(
VBCharacterData(
id = userCharacterId,
generation = max(character.transformationHistoryCount - 1, 0),
totalTrophies = character.characterStats.trainedPp.coerceAtLeast(0)
)
)
}
val now = System.currentTimeMillis()
database.dexDao().insertCharacter(slotId, importedCard.id, now)
for (transformation in character.transformationHistoryList) {
val transformationCard = resolveCard(transformation.cardName, character.cardId)
val transformationCard = resolveRelatedCard(
incomingCardName = transformation.cardName,
incomingCardId = character.cardId,
matchedRootCard = importedCard,
incomingRootCardName = character.cardName,
)
if (transformationCard != null) {
database.userCharacterDao().insertTransformation(
userCharacterId,
@@ -103,7 +134,12 @@ class VitalWearCharacterImporter(
}
for ((cardName, maxAdventureCompleted) in character.maxAdventureCompletedByCardMap) {
val adventureCard = resolveCard(cardName, null) ?: continue
val adventureCard = resolveRelatedCard(
incomingCardName = cardName,
incomingCardId = null,
matchedRootCard = importedCard,
incomingRootCardName = character.cardName,
) ?: continue
val currentStage = (maxAdventureCompleted + 1).coerceAtLeast(0)
database.cardProgressDao().updateCardProgress(
currentStage = currentStage,
@@ -120,19 +156,33 @@ class VitalWearCharacterImporter(
private fun resolveCard(character: Character) = resolveCard(character.cardName, character.cardId)
private fun resolveCard(cardName: String?, cardId: Int?): com.github.nacabaro.vbhelper.domain.card.Card? {
if (!cardName.isNullOrBlank()) {
database.cardDao().getCardByName(cardName)?.let { return it }
}
private fun resolveCard(cardName: String?, cardId: Int?): Card? {
return selectImportedCard(
candidates = database.cardDao().getAllCards(),
incomingCardName = cardName,
incomingCardId = cardId,
)
}
if (cardId != null) {
val matches = database.cardDao().getCardByCardId(cardId)
if (matches.size == 1) {
return matches.first()
private fun resolveRelatedCard(
incomingCardName: String?,
incomingCardId: Int?,
matchedRootCard: Card,
incomingRootCardName: String,
): Card? {
if (incomingCardName.isNullOrBlank()) {
return if (incomingCardId != null && incomingCardId > 0 && matchedRootCard.cardId == incomingCardId) {
matchedRootCard
} else {
null
}
}
return null
if (cardNamesMatch(incomingCardName, incomingRootCardName)) {
return matchedRootCard
}
return resolveCard(incomingCardName, incomingCardId)
}
private fun secondsToMinutes(seconds: Long): Int {
@@ -161,3 +211,55 @@ class VitalWearCharacterImporter(
return enumValues<NfcCharacter.AbilityRarity>().first()
}
}
internal fun selectImportedCard(
candidates: List<Card>,
incomingCardName: String?,
incomingCardId: Int?,
): Card? {
val requestedName = incomingCardName?.takeIf { it.isNotBlank() }
val idMatches = if (incomingCardId != null && incomingCardId > 0) {
candidates.filter { it.cardId == incomingCardId }
} else {
emptyList()
}
requestedName?.let { requestedNameValue ->
candidates.firstOrNull {
it.name == requestedNameValue && (incomingCardId == null || incomingCardId <= 0 || it.cardId == incomingCardId)
}?.let { return it }
val ignoreCaseMatches = candidates.filter {
it.name.equals(requestedNameValue, ignoreCase = true) &&
(incomingCardId == null || incomingCardId <= 0 || it.cardId == incomingCardId)
}
if (ignoreCaseMatches.size == 1) {
return ignoreCaseMatches.single()
}
}
if (idMatches.size == 1) {
return idMatches.single()
}
requestedName?.let { requestedNameValue ->
val normalizedMatches = candidates.filter {
cardNamesMatch(it.name, requestedNameValue) &&
(incomingCardId == null || incomingCardId <= 0 || it.cardId == incomingCardId)
}
if (normalizedMatches.size == 1) {
return normalizedMatches.single()
}
}
return null
}
internal fun cardNamesMatch(left: String, right: String): Boolean {
return left.equals(right, ignoreCase = true) || left.toNormalizedCardLookupKey() == right.toNormalizedCardLookupKey()
}
private fun String.toNormalizedCardLookupKey(): String {
return lowercase().filter { it.isLetterOrDigit() }
}
@@ -56,6 +56,8 @@
<string name="scan_error_generic">Ops!</string>
<string name="scan_sent_dim_success">DIM enviado com sucesso!</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_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="settings_title">Configurações</string>
+2
View File
@@ -60,6 +60,8 @@
<string name="scan_error_generic">Whoops</string>
<string name="scan_sent_dim_success">Sent DIM successfully!</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_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="settings_title">Settings</string>