Fix VitalWear HCE transfer in both directions

- Detect ISO-DEP tags (VitalWear watch) vs NFC-A (physical bracelets)
- Watch->Phone: use VitalWearHceReaderClient + VitalWearCharacterImporter
- Phone->Watch: use VitalWearHceReaderClient + VitalWearCharacterExporter
- Add AppDatabase to ScanScreenControllerImpl for HCE import/export
- Extract buildCharacterProto() from VitalWearCharacterExporter
- Add JitPack repository to settings.gradle.kts
This commit is contained in:
b13st 2026-06-23 13:44:26 +02:00
parent 7a8815694d
commit e549fb4469
4 changed files with 190 additions and 103 deletions

View File

@ -49,7 +49,8 @@ class MainActivity : ComponentActivity() {
application.container.dataStoreSecretsRepository.secretsFlow, application.container.dataStoreSecretsRepository.secretsFlow,
this, this,
this::registerActivityLifecycleListener, this::registerActivityLifecycleListener,
this::unregisterActivityLifecycleListener this::unregisterActivityLifecycleListener,
application.container.db,
) )
val settingsScreenController = SettingsScreenControllerImpl(this) val settingsScreenController = SettingsScreenControllerImpl(this)
val itemsScreenController = ItemsScreenControllerImpl(this) val itemsScreenController = ItemsScreenControllerImpl(this)

View File

@ -3,6 +3,7 @@ package com.github.nacabaro.vbhelper.screens.scanScreen
import android.content.Intent import android.content.Intent
import android.nfc.NfcAdapter import android.nfc.NfcAdapter
import android.nfc.Tag import android.nfc.Tag
import android.nfc.tech.IsoDep
import android.nfc.tech.NfcA import android.nfc.tech.NfcA
import android.os.Bundle import android.os.Bundle
import android.provider.Settings import android.provider.Settings
@ -15,12 +16,16 @@ import com.github.cfogrady.vbnfc.be.BENfcCharacter
import com.github.cfogrady.vbnfc.data.NfcCharacter import com.github.cfogrady.vbnfc.data.NfcCharacter
import com.github.cfogrady.vbnfc.vb.VBNfcCharacter import com.github.cfogrady.vbnfc.vb.VBNfcCharacter
import com.github.nacabaro.vbhelper.ActivityLifecycleListener import com.github.nacabaro.vbhelper.ActivityLifecycleListener
import com.github.nacabaro.vbhelper.database.AppDatabase
import com.github.nacabaro.vbhelper.domain.card.Card import com.github.nacabaro.vbhelper.domain.card.Card
import com.github.nacabaro.vbhelper.screens.scanScreen.converters.FromNfcConverter import com.github.nacabaro.vbhelper.screens.scanScreen.converters.FromNfcConverter
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.VitalWearCharacterImporter
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.hce.VitalWearHceReaderClient
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
@ -32,8 +37,10 @@ class ScanScreenControllerImpl(
private val componentActivity: ComponentActivity, private val componentActivity: ComponentActivity,
private val registerActivityLifecycleListener: (String, ActivityLifecycleListener)->Unit, private val registerActivityLifecycleListener: (String, ActivityLifecycleListener)->Unit,
private val unregisterActivityLifecycleListener: (String)->Unit, private val unregisterActivityLifecycleListener: (String)->Unit,
private val database: AppDatabase,
): ScanScreenController { ): ScanScreenController {
private var lastScannedCharacter: NfcCharacter? = null private var lastScannedCharacter: NfcCharacter? = null
private var pendingExportCharacterId: Long? = null
private val nfcAdapter: NfcAdapter private val nfcAdapter: NfcAdapter
init { init {
@ -46,7 +53,9 @@ class ScanScreenControllerImpl(
} }
override fun onClickRead(secrets: Secrets, onComplete: ()->Unit, onMultipleCards: (List<Card>) -> Unit) { override fun onClickRead(secrets: Secrets, onComplete: ()->Unit, onMultipleCards: (List<Card>) -> Unit) {
handleTag(secrets) { tagCommunicator -> handleTag(
secrets,
handlerFunc = { tagCommunicator ->
try { try {
val character = tagCommunicator.receiveCharacter() val character = tagCommunicator.receiveCharacter()
val resultMessage = characterFromNfc(character) { cards, nfcCharacter -> val resultMessage = characterFromNfc(character) { cards, nfcCharacter ->
@ -63,7 +72,27 @@ class ScanScreenControllerImpl(
onComplete.invoke() onComplete.invoke()
componentActivity.getString(R.string.scan_error_generic) componentActivity.getString(R.string.scan_error_generic)
} }
},
hceHandler = { isoDep ->
try {
val client = VitalWearHceReaderClient(isoDep)
val character = client.receiveCharacterFromWatch()
val importer = VitalWearCharacterImporter(database)
val result = importer.importCharacter(character)
Log.i("NFC_READ", "VitalWear HCE import: ${result.message}")
componentActivity.runOnUiThread {
Toast.makeText(componentActivity, result.message, Toast.LENGTH_SHORT).show()
} }
} catch (e: Exception) {
Log.e("NFC_READ", "Error reading character from VitalWear HCE", e)
componentActivity.runOnUiThread {
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_error_generic) + ": " + (e.message ?: e.javaClass.simpleName), Toast.LENGTH_LONG).show()
}
} finally {
onComplete()
}
}
)
} }
override fun cancelRead() { override fun cancelRead() {
@ -83,29 +112,45 @@ class ScanScreenControllerImpl(
unregisterActivityLifecycleListener.invoke(key) unregisterActivityLifecycleListener.invoke(key)
} }
// EXTRACTED DIRECTLY FROM EXAMPLE APP private fun handleTag(
private fun handleTag(secrets: Secrets, handlerFunc: (TagCommunicator)->String) { secrets: Secrets,
handlerFunc: (TagCommunicator) -> String,
hceHandler: ((IsoDep) -> Unit)? = null,
) {
if (!nfcAdapter.isEnabled) { if (!nfcAdapter.isEnabled) {
showWirelessSettings() showWirelessSettings()
} else { } else {
val options = Bundle() val options = Bundle()
// Work around for some broken Nfc firmware implementations that poll the card too fast // Work around for some broken Nfc firmware implementations that poll the card too fast
options.putInt(NfcAdapter.EXTRA_READER_PRESENCE_CHECK_DELAY, 250) 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, nfcAdapter.enableReaderMode(
componentActivity,
buildOnReadTag(secrets, handlerFunc, hceHandler),
NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK,
options options
) )
} }
} }
// EXTRACTED DIRECTLY FROM EXAMPLE APP private fun buildOnReadTag(
private fun buildOnReadTag(secrets: Secrets, handlerFunc: (TagCommunicator)->String): (Tag)->Unit { secrets: Secrets,
handlerFunc: (TagCommunicator) -> String,
hceHandler: ((IsoDep) -> Unit)? = null,
): (Tag) -> Unit {
return { tag -> return { tag ->
val isoDep = IsoDep.get(tag)
if (isoDep != null && hceHandler != null) {
// VitalWear watch via HCE / ISO-DEP
isoDep.connect()
isoDep.use { hceHandler(isoDep) }
} else {
// Physical Vital Bracelet via raw NFC-A
val nfcData = NfcA.get(tag) val nfcData = NfcA.get(tag)
if (nfcData == null) { if (nfcData == null) {
componentActivity.runOnUiThread { componentActivity.runOnUiThread {
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_tag_not_vb), Toast.LENGTH_SHORT).show() Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_tag_not_vb), Toast.LENGTH_SHORT).show()
} }
} } else {
nfcData.connect() nfcData.connect()
nfcData.use { nfcData.use {
val tagCommunicator = TagCommunicator.getInstance(nfcData, secrets.getCryptographicTransformerMap()) val tagCommunicator = TagCommunicator.getInstance(nfcData, secrets.getCryptographicTransformerMap())
@ -116,6 +161,8 @@ class ScanScreenControllerImpl(
} }
} }
} }
}
}
private fun checkSecrets() { private fun checkSecrets() {
componentActivity.lifecycleScope.launch(Dispatchers.IO) { componentActivity.lifecycleScope.launch(Dispatchers.IO) {
@ -132,16 +179,16 @@ class ScanScreenControllerImpl(
nfcCharacter: NfcCharacter, nfcCharacter: NfcCharacter,
onComplete: () -> Unit onComplete: () -> Unit
) { ) {
handleTag(secrets) { tagCommunicator -> handleTag(
secrets,
handlerFunc = { tagCommunicator ->
try { try {
if (nfcCharacter is VBNfcCharacter) { if (nfcCharacter is VBNfcCharacter) {
Log.d("SendCharacter", "VBNfcCharacter") Log.d("SendCharacter", "VBNfcCharacter")
val castNfcCharacter: VBNfcCharacter = nfcCharacter tagCommunicator.sendCharacter(nfcCharacter)
tagCommunicator.sendCharacter(castNfcCharacter)
} else if (nfcCharacter is BENfcCharacter) { } else if (nfcCharacter is BENfcCharacter) {
Log.d("SendCharacter", "BENfcCharacter") Log.d("SendCharacter", "BENfcCharacter")
val castNfcCharacter: BENfcCharacter = nfcCharacter tagCommunicator.sendCharacter(nfcCharacter)
tagCommunicator.sendCharacter(castNfcCharacter)
} }
onComplete.invoke() onComplete.invoke()
componentActivity.getString(R.string.scan_sent_character_success) componentActivity.getString(R.string.scan_sent_character_success)
@ -149,7 +196,12 @@ class ScanScreenControllerImpl(
Log.e("TAG", e.stackTraceToString()) Log.e("TAG", e.stackTraceToString())
componentActivity.getString(R.string.scan_error_generic) componentActivity.getString(R.string.scan_error_generic)
} }
},
hceHandler = { _ ->
// Character was already sent in onClickCheckCard's HCE handler.
onComplete()
} }
)
} }
override fun onClickCheckCard( override fun onClickCheckCard(
@ -157,11 +209,42 @@ class ScanScreenControllerImpl(
nfcCharacter: NfcCharacter, nfcCharacter: NfcCharacter,
onComplete: () -> Unit onComplete: () -> Unit
) { ) {
handleTag(secrets) { tagCommunicator -> handleTag(
secrets,
handlerFunc = { tagCommunicator ->
tagCommunicator.prepareDIMForCharacter(nfcCharacter.dimId) tagCommunicator.prepareDIMForCharacter(nfcCharacter.dimId)
onComplete.invoke() onComplete.invoke()
componentActivity.getString(R.string.scan_sent_dim_success) componentActivity.getString(R.string.scan_sent_dim_success)
},
hceHandler = { isoDep ->
// VitalWear watch: send the character proto directly via HCE.
val characterId = pendingExportCharacterId
if (characterId == null) {
Log.e("NFC_WRITE", "No pending character to send to VitalWear")
componentActivity.runOnUiThread {
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_error_generic), Toast.LENGTH_SHORT).show()
} }
onComplete()
return@handleTag
}
try {
val proto = VitalWearCharacterExporter(componentActivity, database).buildCharacterProto(characterId)
val client = VitalWearHceReaderClient(isoDep)
client.sendCharacterToWatch(proto)
Log.i("NFC_WRITE", "Character sent to VitalWear via HCE")
componentActivity.runOnUiThread {
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_sent_character_success), Toast.LENGTH_SHORT).show()
}
} catch (e: Exception) {
Log.e("NFC_WRITE", "Error sending character to VitalWear HCE", e)
componentActivity.runOnUiThread {
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_error_generic) + ": " + (e.message ?: e.javaClass.simpleName), Toast.LENGTH_LONG).show()
}
} finally {
onComplete()
}
}
)
} }
// EXTRACTED DIRECTLY FROM EXAMPLE APP // EXTRACTED DIRECTLY FROM EXAMPLE APP
@ -181,10 +264,8 @@ class ScanScreenControllerImpl(
} }
override suspend fun characterToNfc(characterId: Long): NfcCharacter { override suspend fun characterToNfc(characterId: Long): NfcCharacter {
val nfcGenerator = ToNfcConverter( pendingExportCharacterId = characterId
componentActivity = componentActivity val nfcGenerator = ToNfcConverter(componentActivity = componentActivity)
)
val character = nfcGenerator.characterToNfc(characterId) val character = nfcGenerator.characterToNfc(characterId)
Log.d("CharacterType", character.toString()) Log.d("CharacterType", character.toString())
return character return character

View File

@ -15,14 +15,13 @@ class VitalWearCharacterExporter(
private val context: Context, private val context: Context,
private val database: AppDatabase private val database: AppDatabase
) { ) {
fun buildShareIntent(characterId: Long): Intent { fun buildCharacterProto(characterId: Long): Character = runBlocking {
return runBlocking {
val characterWithSprites = database.userCharacterDao().getCharacterWithSprites(characterId) val characterWithSprites = database.userCharacterDao().getCharacterWithSprites(characterId)
val userCharacter = database.userCharacterDao().getCharacter(characterId) val userCharacter = database.userCharacterDao().getCharacter(characterId)
val card = database.cardDao().getCardByCharacterIdSync(characterId) val card = database.cardDao().getCardByCharacterIdSync(characterId)
?: error("Card not found for character $characterId") ?: error("Card not found for character $characterId")
val cardProgress = database.cardProgressDao().getCardProgressSync(card.id) ?: 0 val cardProgress = database.cardProgressDao().getCardProgressSync(card.id) ?: 0
val proto = Character.newBuilder() Character.newBuilder()
.setCardId(card.cardId) .setCardId(card.cardId)
.setCardName(card.name) .setCardName(card.name)
.setCharacterStats( .setCharacterStats(
@ -61,6 +60,11 @@ class VitalWearCharacterExporter(
} }
) )
.build() .build()
}
fun buildShareIntent(characterId: Long): Intent {
return runBlocking {
val proto = buildCharacterProto(characterId)
val exportDir = File(context.cacheDir, "exports").apply { mkdirs() } val exportDir = File(context.cacheDir, "exports").apply { mkdirs() }
val exportFile = File(exportDir, "vbhelper_character_$characterId.vitalwear") val exportFile = File(exportDir, "vbhelper_character_$characterId.vitalwear")

View File

@ -17,6 +17,7 @@ dependencyResolutionManagement {
mavenLocal() mavenLocal()
google() google()
mavenCentral() mavenCentral()
maven { url = uri("https://jitpack.io") }
} }
} }