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,
this,
this::registerActivityLifecycleListener,
this::unregisterActivityLifecycleListener
this::unregisterActivityLifecycleListener,
application.container.db,
)
val settingsScreenController = SettingsScreenControllerImpl(this)
val itemsScreenController = ItemsScreenControllerImpl(this)

View File

@ -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
@ -15,12 +16,16 @@ import com.github.cfogrady.vbnfc.be.BENfcCharacter
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.database.AppDatabase
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.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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.stateIn
@ -32,8 +37,10 @@ class ScanScreenControllerImpl(
private val componentActivity: ComponentActivity,
private val registerActivityLifecycleListener: (String, ActivityLifecycleListener)->Unit,
private val unregisterActivityLifecycleListener: (String)->Unit,
private val database: AppDatabase,
): ScanScreenController {
private var lastScannedCharacter: NfcCharacter? = null
private var pendingExportCharacterId: Long? = null
private val nfcAdapter: NfcAdapter
init {
@ -46,7 +53,9 @@ class ScanScreenControllerImpl(
}
override fun onClickRead(secrets: Secrets, onComplete: ()->Unit, onMultipleCards: (List<Card>) -> Unit) {
handleTag(secrets) { tagCommunicator ->
handleTag(
secrets,
handlerFunc = { tagCommunicator ->
try {
val character = tagCommunicator.receiveCharacter()
val resultMessage = characterFromNfc(character) { cards, nfcCharacter ->
@ -63,7 +72,27 @@ class ScanScreenControllerImpl(
onComplete.invoke()
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() {
@ -83,29 +112,45 @@ class ScanScreenControllerImpl(
unregisterActivityLifecycleListener.invoke(key)
}
// EXTRACTED DIRECTLY FROM EXAMPLE APP
private fun handleTag(secrets: Secrets, handlerFunc: (TagCommunicator)->String) {
private fun handleTag(
secrets: Secrets,
handlerFunc: (TagCommunicator) -> String,
hceHandler: ((IsoDep) -> Unit)? = 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,
nfcAdapter.enableReaderMode(
componentActivity,
buildOnReadTag(secrets, handlerFunc, hceHandler),
NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK,
options
)
}
}
// EXTRACTED DIRECTLY FROM EXAMPLE APP
private fun buildOnReadTag(secrets: Secrets, handlerFunc: (TagCommunicator)->String): (Tag)->Unit {
return { tag->
private fun buildOnReadTag(
secrets: Secrets,
handlerFunc: (TagCommunicator) -> String,
hceHandler: ((IsoDep) -> Unit)? = null,
): (Tag) -> Unit {
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)
if (nfcData == null) {
componentActivity.runOnUiThread {
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_tag_not_vb), Toast.LENGTH_SHORT).show()
}
}
} else {
nfcData.connect()
nfcData.use {
val tagCommunicator = TagCommunicator.getInstance(nfcData, secrets.getCryptographicTransformerMap())
@ -116,6 +161,8 @@ class ScanScreenControllerImpl(
}
}
}
}
}
private fun checkSecrets() {
componentActivity.lifecycleScope.launch(Dispatchers.IO) {
@ -132,16 +179,16 @@ class ScanScreenControllerImpl(
nfcCharacter: NfcCharacter,
onComplete: () -> Unit
) {
handleTag(secrets) { tagCommunicator ->
handleTag(
secrets,
handlerFunc = { tagCommunicator ->
try {
if (nfcCharacter is VBNfcCharacter) {
Log.d("SendCharacter", "VBNfcCharacter")
val castNfcCharacter: VBNfcCharacter = nfcCharacter
tagCommunicator.sendCharacter(castNfcCharacter)
tagCommunicator.sendCharacter(nfcCharacter)
} else if (nfcCharacter is BENfcCharacter) {
Log.d("SendCharacter", "BENfcCharacter")
val castNfcCharacter: BENfcCharacter = nfcCharacter
tagCommunicator.sendCharacter(castNfcCharacter)
tagCommunicator.sendCharacter(nfcCharacter)
}
onComplete.invoke()
componentActivity.getString(R.string.scan_sent_character_success)
@ -149,7 +196,12 @@ class ScanScreenControllerImpl(
Log.e("TAG", e.stackTraceToString())
componentActivity.getString(R.string.scan_error_generic)
}
},
hceHandler = { _ ->
// Character was already sent in onClickCheckCard's HCE handler.
onComplete()
}
)
}
override fun onClickCheckCard(
@ -157,11 +209,42 @@ class ScanScreenControllerImpl(
nfcCharacter: NfcCharacter,
onComplete: () -> Unit
) {
handleTag(secrets) { tagCommunicator ->
handleTag(
secrets,
handlerFunc = { tagCommunicator ->
tagCommunicator.prepareDIMForCharacter(nfcCharacter.dimId)
onComplete.invoke()
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
@ -181,10 +264,8 @@ class ScanScreenControllerImpl(
}
override suspend fun characterToNfc(characterId: Long): NfcCharacter {
val nfcGenerator = ToNfcConverter(
componentActivity = componentActivity
)
pendingExportCharacterId = characterId
val nfcGenerator = ToNfcConverter(componentActivity = componentActivity)
val character = nfcGenerator.characterToNfc(characterId)
Log.d("CharacterType", character.toString())
return character

View File

@ -15,14 +15,13 @@ class VitalWearCharacterExporter(
private val context: Context,
private val database: AppDatabase
) {
fun buildShareIntent(characterId: Long): Intent {
return runBlocking {
fun buildCharacterProto(characterId: Long): Character = 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()
Character.newBuilder()
.setCardId(card.cardId)
.setCardName(card.name)
.setCharacterStats(
@ -61,6 +60,11 @@ class VitalWearCharacterExporter(
}
)
.build()
}
fun buildShareIntent(characterId: Long): Intent {
return runBlocking {
val proto = buildCharacterProto(characterId)
val exportDir = File(context.cacheDir, "exports").apply { mkdirs() }
val exportFile = File(exportDir, "vbhelper_character_$characterId.vitalwear")

View File

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