speeding up HCE

This commit is contained in:
Taveon Nelson 2026-05-18 17:28:24 -05:00
parent 431be5faea
commit 1a83527590
3 changed files with 368 additions and 63 deletions

View File

@ -3,6 +3,7 @@ package com.github.nacabaro.vbhelper.screens.scanScreen.converters
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import com.github.cfogrady.vbnfc.be.BENfcCharacter 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.SpecialMission
import com.github.cfogrady.vbnfc.vb.VBNfcCharacter import com.github.cfogrady.vbnfc.vb.VBNfcCharacter
import com.github.nacabaro.vbhelper.di.VBHelper import com.github.nacabaro.vbhelper.di.VBHelper
import com.github.nacabaro.vbhelper.domain.card.Card import com.github.nacabaro.vbhelper.domain.card.Card
@ -268,8 +269,8 @@ class FromNfcConverter (
SpecialMissions( SpecialMissions(
characterId = characterId, characterId = characterId,
watchId = watchId, watchId = watchId,
missionType = com.github.cfogrady.vbnfc.data.MissionType.NONE, missionType = SpecialMission.Type.NONE,
status = com.github.cfogrady.vbnfc.data.MissionStatus.UNAVAILABLE, status = SpecialMission.Status.UNAVAILABLE,
goal = 0, goal = 0,
progress = 0, progress = 0,
timeElapsedInMinutes = 0, timeElapsedInMinutes = 0,

View File

@ -1,5 +1,6 @@
package com.github.nacabaro.vbhelper.transfer.hce package com.github.nacabaro.vbhelper.transfer.hce
import android.nfc.tech.IsoDep import android.nfc.tech.IsoDep
import android.os.SystemClock
import android.util.Log import android.util.Log
import com.github.cfogrady.vitalwear.protos.Character import com.github.cfogrady.vitalwear.protos.Character
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@ -8,7 +9,15 @@ import kotlinx.coroutines.runBlocking
* Phone-side ISO-DEP client that drives the VitalWear HCE session. * Phone-side ISO-DEP client that drives the VitalWear HCE session.
* Mirrors the APDU protocol defined in VitalWearHceProtocol on the watch. * Mirrors the APDU protocol defined in VitalWearHceProtocol on the watch.
*/ */
class VitalWearHceReaderClient(private val isoDep: IsoDep) { class VitalWearHceReaderClient(
private val isoDep: IsoDep,
private val fastMode: Boolean = true,
) {
companion object {
private const val MIN_CHUNK_SIZE = 256
private val bestChunkByDevice = linkedMapOf<String, Int>()
}
init { init {
// Import + DB writes can make COMMIT slower than default transceive time on some devices. // Import + DB writes can make COMMIT slower than default transceive time on some devices.
isoDep.timeout = 10_000 isoDep.timeout = 10_000
@ -32,78 +41,182 @@ class VitalWearHceReaderClient(private val isoDep: IsoDep) {
private val STATUS_SUCCESS: Byte = 0x04 private val STATUS_SUCCESS: Byte = 0x04
private val STATUS_FAILURE: Byte = 0x05 private val STATUS_FAILURE: Byte = 0x05
private val STATUS_SYNCING: Byte = 0x03 private val STATUS_SYNCING: Byte = 0x03
private val STATUS_ARMED_SEND: Byte = 0x01
private val STATUS_ARMED_RECEIVE: Byte = 0x02
private val STATUS_IDLE: Byte = 0x00
private val CONFIRMATION_MAX_POLLS = 84 // ~21s with adaptive delay schedule
private val PREFERRED_MAX_CHUNK_SIZE = 2048
// Fast mode is the default; ceremony APDUs are only enabled when fastMode is false.
private val ENABLE_TRANSFER_CEREMONY = !fastMode
private data class ReadPayloadResult(
val character: Character,
val session: VitalWearHceSessionInfo,
)
private data class HceTransferMetrics(
val direction: String,
val payloadBytes: Int,
val negotiatedChunkBytes: Int,
val apduCountTotal: Int,
val apduCountData: Int,
val statusPollCount: Int,
val tSelectNegotiateMs: Long,
val tChunkLoopMs: Long,
val tCommitAckMs: Long,
val tConfirmMs: Long,
val tTotalMs: Long,
val result: String,
) {
fun log() {
Log.i(
"HCE_CLIENT_METRICS",
"dir=$direction payloadBytes=$payloadBytes chunk=$negotiatedChunkBytes apduTotal=$apduCountTotal " +
"apduData=$apduCountData polls=$statusPollCount selectNegotiateMs=$tSelectNegotiateMs " +
"chunkLoopMs=$tChunkLoopMs commitAckMs=$tCommitAckMs confirmMs=$tConfirmMs totalMs=$tTotalMs result=$result"
)
}
}
/** MOVE READ: read character bytes first, let caller import/validate, then COMMIT only on success. /** MOVE READ: read character bytes first, let caller import/validate, then COMMIT only on success.
* If [onCharacterRead] returns false, COMMIT is skipped so source can remain on the watch. * If [onCharacterRead] returns false, COMMIT is skipped so source can remain on the watch.
*/ */
fun moveCharacterFromWatch(onCharacterRead: (Character) -> Boolean): Boolean { fun moveCharacterFromWatch(onCharacterRead: (Character) -> Boolean): Boolean {
val character = readCharacterFromWatchWithoutCommit() val readResult = readCharacterFromWatchWithoutCommit()
if (!onCharacterRead(character)) { if (!onCharacterRead(readResult.character)) {
return false return false
} }
if (!readResult.session.canCommit()) {
error("Session does not allow COMMIT for watch->phone transfer")
}
requireOk(sendApdu(INS_COMMIT, byteArrayOf()), "COMMIT") requireOk(sendApdu(INS_COMMIT, byteArrayOf()), "COMMIT")
return true return true
} }
/** READ: watch has called armSend() — phone reads the character off the watch. */ /** READ: watch has called armSend() — phone reads the character off the watch. */
fun receiveCharacterFromWatch(): Character { fun receiveCharacterFromWatch(): Character {
val character = readCharacterFromWatchWithoutCommit() val readResult = readCharacterFromWatchWithoutCommit()
if (!readResult.session.canCommit()) {
error("Session does not allow COMMIT for watch->phone transfer")
}
requireOk(sendApdu(INS_COMMIT, byteArrayOf()), "COMMIT") requireOk(sendApdu(INS_COMMIT, byteArrayOf()), "COMMIT")
return character return readResult.character
} }
private fun readCharacterFromWatchWithoutCommit(): Character { private fun readCharacterFromWatchWithoutCommit(): ReadPayloadResult {
val transferStartMs = SystemClock.elapsedRealtime()
var apduCount = 0
var dataApduCount = 0
selectAid() selectAid()
val negResponse = sendApdu(INS_NEGOTIATE, byteArrayOf(MODE_WATCH_TO_PHONE, VERSION)) apduCount++
requireOk(negResponse, "NEGOTIATE") val session = negotiateSession(MODE_WATCH_TO_PHONE, desiredMaxChunkSize())
val maxChunk = ((negResponse[2].toInt() and 0xFF) shl 8) or (negResponse[3].toInt() and 0xFF) apduCount++
val payloadLen = val negotiatedMs = SystemClock.elapsedRealtime()
((negResponse[4].toInt() and 0xFF) shl 24) or check(session.isWatchToPhone()) { "NEGOTIATE direction mismatch for WATCH_TO_PHONE" }
((negResponse[5].toInt() and 0xFF) shl 16) or Log.d("HCE_CLIENT", "Receiving ${session.payloadLength} bytes in chunks of ${session.maxChunkSize}")
((negResponse[6].toInt() and 0xFF) shl 8) or val payload = ByteArray(session.payloadLength)
(negResponse[7].toInt() and 0xFF)
Log.d("HCE_CLIENT", "Receiving $payloadLen bytes in chunks of $maxChunk")
val payload = ByteArray(payloadLen)
var offset = 0 var offset = 0
while (offset < payloadLen) { while (offset < session.payloadLength) {
val chunkResponse = sendApdu(INS_READ_CHUNK, intToBytes(offset)) val chunkResponse = sendApdu(INS_READ_CHUNK, intToBytes(offset))
apduCount++
dataApduCount++
requireOk(chunkResponse, "READ_CHUNK @ $offset") requireOk(chunkResponse, "READ_CHUNK @ $offset")
val chunkData = chunkResponse.dropLast(2).toByteArray() val chunkData = chunkResponse.dropLast(2).toByteArray()
chunkData.copyInto(payload, offset) chunkData.copyInto(payload, offset)
offset += chunkData.size offset += chunkData.size
} }
return Character.parseFrom(payload) val chunksDoneMs = SystemClock.elapsedRealtime()
HceTransferMetrics(
direction = "WATCH_TO_PHONE_READ",
payloadBytes = payload.size,
negotiatedChunkBytes = session.maxChunkSize,
apduCountTotal = apduCount,
apduCountData = dataApduCount,
statusPollCount = 0,
tSelectNegotiateMs = negotiatedMs - transferStartMs,
tChunkLoopMs = chunksDoneMs - negotiatedMs,
tCommitAckMs = 0L,
tConfirmMs = 0L,
tTotalMs = chunksDoneMs - transferStartMs,
result = "read_complete",
).log()
return ReadPayloadResult(
character = Character.parseFrom(payload),
session = session,
)
} }
/** WRITE: watch has called armReceive() — phone pushes a character to the watch. */ /** WRITE: watch has called armReceive() — phone pushes a character to the watch. */
fun sendCharacterToWatch(character: Character, closeSyncUiAfterCommit: Boolean = true) { fun sendCharacterToWatch(character: Character, closeSyncUiAfterCommit: Boolean = true) {
val session = sendCharacterToWatchInternal(
character = character,
closeSyncUiAfterCommit = closeSyncUiAfterCommit,
requestedChunkSize = desiredMaxChunkSize(),
)
rememberBestChunk(session.maxChunkSize)
}
private fun sendCharacterToWatchInternal(
character: Character,
closeSyncUiAfterCommit: Boolean,
requestedChunkSize: Int,
): VitalWearHceSessionInfo {
val transferStartMs = SystemClock.elapsedRealtime()
var apduCount = 0
var dataApduCount = 0
selectAid() selectAid()
apduCount++
// --- True to Toy Ceremony --- if (ENABLE_TRANSFER_CEREMONY) {
// 1. Haptic Lock-on // Optional toy-like ceremony; kept behind a toggle to avoid transfer overhead.
sendApdu(INS_VIBRATE, byteArrayOf()) sendApdu(INS_VIBRATE, byteArrayOf())
apduCount++
// 2. Visual Sync (Start Beam) sendApdu(INS_SYNC_UI, byteArrayOf(0x01))
sendApdu(INS_SYNC_UI, byteArrayOf(0x01)) apduCount++
}
val payload = character.toByteArray() val payload = character.toByteArray()
val negResponse = sendApdu(INS_NEGOTIATE, byteArrayOf(MODE_PHONE_TO_WATCH, VERSION)) val session = negotiateSession(MODE_PHONE_TO_WATCH, requestedChunkSize)
requireOk(negResponse, "NEGOTIATE") apduCount++
val maxChunk = ((negResponse[2].toInt() and 0xFF) shl 8) or (negResponse[3].toInt() and 0xFF) val negotiatedMs = SystemClock.elapsedRealtime()
Log.d("HCE_CLIENT", "Sending ${payload.size} bytes in chunks of $maxChunk") check(session.isPhoneToWatch()) { "NEGOTIATE direction mismatch for PHONE_TO_WATCH" }
Log.d("HCE_CLIENT", "Sending ${payload.size} bytes in chunks of ${session.maxChunkSize}")
var offset = 0 var offset = 0
while (offset < payload.size) { while (offset < payload.size) {
val chunkEnd = (offset + maxChunk).coerceAtMost(payload.size) val chunkEnd = (offset + session.maxChunkSize).coerceAtMost(payload.size)
val chunk = payload.copyOfRange(offset, chunkEnd) val chunk = payload.copyOfRange(offset, chunkEnd)
requireOk(sendApdu(INS_WRITE_CHUNK, intToBytes(offset) + chunk), "WRITE_CHUNK @ $offset") requireOk(sendApdu(INS_WRITE_CHUNK, intToBytes(offset) + chunk), "WRITE_CHUNK @ $offset")
apduCount++
dataApduCount++
offset = chunkEnd offset = chunkEnd
} }
requireOk(sendApdu(INS_COMMIT, byteArrayOf()), "COMMIT") val chunksDoneMs = SystemClock.elapsedRealtime()
if (!session.canCommit()) {
// 3. Visual Sync (End Beam) error("Session does not allow COMMIT for phone->watch transfer")
if (closeSyncUiAfterCommit) {
sendApdu(INS_SYNC_UI, byteArrayOf(0x00))
} }
val commitStartMs = SystemClock.elapsedRealtime()
requireOk(sendApdu(INS_COMMIT, byteArrayOf()), "COMMIT")
apduCount++
val commitDoneMs = SystemClock.elapsedRealtime()
if (ENABLE_TRANSFER_CEREMONY && closeSyncUiAfterCommit) {
sendApdu(INS_SYNC_UI, byteArrayOf(0x00))
apduCount++
}
HceTransferMetrics(
direction = "PHONE_TO_WATCH_WRITE",
payloadBytes = payload.size,
negotiatedChunkBytes = session.maxChunkSize,
apduCountTotal = apduCount,
apduCountData = dataApduCount,
statusPollCount = 0,
tSelectNegotiateMs = negotiatedMs - transferStartMs,
tChunkLoopMs = chunksDoneMs - negotiatedMs,
tCommitAckMs = commitDoneMs - commitStartMs,
tConfirmMs = 0L,
tTotalMs = commitDoneMs - transferStartMs,
result = "commit_acked",
).log()
return session
} }
/** /**
@ -111,28 +224,87 @@ class VitalWearHceReaderClient(private val isoDep: IsoDep) {
* Returns true only when the watch reports import success. * Returns true only when the watch reports import success.
*/ */
fun sendCharacterToWatchAndConfirm(character: Character): Boolean { fun sendCharacterToWatchAndConfirm(character: Character): Boolean {
sendCharacterToWatch(character, closeSyncUiAfterCommit = false) var lastError: Exception? = null
val confirmed = runBlocking { for (candidateChunk in chunkCandidates()) {
repeat(12) { val confirmStartMs = SystemClock.elapsedRealtime()
val statusResponse = sendApdu(INS_STATUS, byteArrayOf()) val session = try {
requireOk(statusResponse, "STATUS") sendCharacterToWatchInternal(
val statusByte = statusResponse.dropLast(2).firstOrNull() character = character,
Log.d("HCE_CLIENT", "STATUS poll[$it]: statusByte=${statusByte?.toInt()?.and(0xFF)}") closeSyncUiAfterCommit = false,
if (statusByte == STATUS_SUCCESS) { requestedChunkSize = candidateChunk,
return@runBlocking true )
} } catch (e: Exception) {
if (statusByte == STATUS_FAILURE) { lastError = e
return@runBlocking false Log.w("HCE_CLIENT", "Chunk attempt failed before confirmation at chunk=$candidateChunk", e)
} continue
delay(250) }
if (!session.canPollStatus()) {
return false
}
var polls = 0
var apduCount = 0
var result = "timeout"
val confirmed = runBlocking {
repeat(CONFIRMATION_MAX_POLLS) {
val statusResponse = sendApdu(INS_STATUS, byteArrayOf())
apduCount++
polls++
requireOk(statusResponse, "STATUS")
val statusByte = statusResponse.dropLast(2).firstOrNull()
Log.d("HCE_CLIENT", "STATUS poll[$it]: statusByte=${statusByte?.toInt()?.and(0xFF)}")
if (statusByte == STATUS_SUCCESS) {
result = "success"
return@runBlocking true
}
if (statusByte == STATUS_FAILURE) {
result = "failure"
return@runBlocking false
}
if (
statusByte == STATUS_SYNCING ||
statusByte == STATUS_ARMED_RECEIVE ||
statusByte == STATUS_ARMED_SEND ||
statusByte == STATUS_IDLE
) {
delay(delayForPoll(it))
return@repeat
}
// Unknown status byte: keep waiting briefly instead of failing early.
delay(delayForPoll(it))
}
false
}
if (ENABLE_TRANSFER_CEREMONY) {
// End the sync UI after we have consumed terminal status.
runCatching {
sendApdu(INS_SYNC_UI, byteArrayOf(0x00))
}
}
HceTransferMetrics(
direction = "PHONE_TO_WATCH_CONFIRM",
payloadBytes = character.serializedSize,
negotiatedChunkBytes = session.maxChunkSize,
apduCountTotal = apduCount,
apduCountData = 0,
statusPollCount = polls,
tSelectNegotiateMs = 0L,
tChunkLoopMs = 0L,
tCommitAckMs = 0L,
tConfirmMs = SystemClock.elapsedRealtime() - confirmStartMs,
tTotalMs = SystemClock.elapsedRealtime() - confirmStartMs,
result = "$result(requested=$candidateChunk)",
).log()
if (confirmed) {
rememberBestChunk(session.maxChunkSize)
return true
}
// Explicit failure means import rejected payload; retrying chunk size won't help.
if (result == "failure") {
return false
} }
false
} }
// End the sync UI after we have consumed terminal status. lastError?.let { throw it }
runCatching { return false
sendApdu(INS_SYNC_UI, byteArrayOf(0x00))
}
return confirmed
} }
/** /**
@ -141,9 +313,8 @@ class VitalWearHceReaderClient(private val isoDep: IsoDep) {
*/ */
fun verifyWatchReadyToReceive(): Boolean { fun verifyWatchReadyToReceive(): Boolean {
selectAid() selectAid()
val negResponse = sendApdu(INS_NEGOTIATE, byteArrayOf(MODE_PHONE_TO_WATCH, VERSION)) val session = negotiateSession(MODE_PHONE_TO_WATCH, desiredMaxChunkSize())
requireOk(negResponse, "NEGOTIATE") return session.canCommit()
return true
} }
private fun selectAid() { private fun selectAid() {
@ -152,9 +323,102 @@ class VitalWearHceReaderClient(private val isoDep: IsoDep) {
if (statusWord(response) != SW_OK) error("SELECT AID failed: ${response.toHex()}") if (statusWord(response) != SW_OK) error("SELECT AID failed: ${response.toHex()}")
} }
private fun sendApdu(ins: Byte, data: ByteArray): ByteArray { private fun sendApdu(ins: Byte, data: ByteArray): ByteArray {
val apdu = byteArrayOf(CLA, ins, 0x00, 0x00, data.size.toByte()) + data val apdu = if (data.size <= 0xFF) {
byteArrayOf(CLA, ins, 0x00, 0x00, data.size.toByte()) + data
} else {
byteArrayOf(
CLA,
ins,
0x00,
0x00,
0x00,
((data.size ushr 8) and 0xFF).toByte(),
(data.size and 0xFF).toByte(),
) + data
}
return isoDep.transceive(apdu) return isoDep.transceive(apdu)
} }
private fun negotiateSession(mode: Byte, requestedChunkSize: Int): VitalWearHceSessionInfo {
val negResponse = sendApdu(
INS_NEGOTIATE,
byteArrayOf(mode, VERSION) + intToShortBytes(requestedChunkSize)
)
requireOk(negResponse, "NEGOTIATE")
val negotiatedMode = when (negResponse[1]) {
MODE_WATCH_TO_PHONE -> VitalWearHceTransferDirection.WATCH_TO_PHONE
MODE_PHONE_TO_WATCH -> VitalWearHceTransferDirection.PHONE_TO_WATCH
else -> error("NEGOTIATE returned unknown direction: ${negResponse[1]}")
}
val maxChunk = ((negResponse[2].toInt() and 0xFF) shl 8) or (negResponse[3].toInt() and 0xFF)
val payloadLen =
((negResponse[4].toInt() and 0xFF) shl 24) or
((negResponse[5].toInt() and 0xFF) shl 16) or
((negResponse[6].toInt() and 0xFF) shl 8) or
(negResponse[7].toInt() and 0xFF)
return VitalWearHceSession(
direction = negotiatedMode,
maxChunkSize = maxChunk,
payloadLength = payloadLen,
)
}
private fun desiredMaxChunkSize(): Int {
val transceiveLimit = runCatching { isoDep.maxTransceiveLength }.getOrNull()
val maxByDevice = if (transceiveLimit != null && transceiveLimit > 0) {
// Extended APDU write overhead: 7-byte header + 4-byte offset.
(transceiveLimit - 11).coerceAtLeast(1)
} else {
PREFERRED_MAX_CHUNK_SIZE
}
val cappedByDevice = maxByDevice.coerceAtMost(PREFERRED_MAX_CHUNK_SIZE)
val cachedBest = cachedBestChunk()
return if (cachedBest == null) {
cappedByDevice
} else {
cachedBest.coerceIn(MIN_CHUNK_SIZE, cappedByDevice)
}
}
private fun chunkCandidates(): List<Int> {
val maxChunk = desiredMaxChunkSize()
val baselineCandidates = listOf(maxChunk, 2048, 1536, 1024, 768, 512, MIN_CHUNK_SIZE)
return baselineCandidates
.map { it.coerceAtMost(maxChunk) }
.filter { it in MIN_CHUNK_SIZE..maxChunk }
.distinct()
}
private fun deviceKey(): String {
val tagId = isoDep.tag?.id ?: return "unknown-device"
return tagId.toHex()
}
private fun cachedBestChunk(): Int? {
synchronized(bestChunkByDevice) {
return bestChunkByDevice[deviceKey()]
}
}
private fun rememberBestChunk(chunkSize: Int) {
synchronized(bestChunkByDevice) {
bestChunkByDevice[deviceKey()] = chunkSize
}
}
private fun delayForPoll(pollIndex: Int): Long {
return when {
pollIndex < 20 -> 80L
pollIndex < 56 -> 175L
else -> 300L
}
}
private fun intToShortBytes(value: Int): ByteArray = byteArrayOf(
((value ushr 8) and 0xFF).toByte(),
(value and 0xFF).toByte()
)
private fun requireOk(response: ByteArray, context: String) { private fun requireOk(response: ByteArray, context: String) {
if (statusWord(response) != SW_OK) error("$context failed: ${response.toHex()}") if (statusWord(response) != SW_OK) error("$context failed: ${response.toHex()}")
} }

View File

@ -0,0 +1,40 @@
package com.github.nacabaro.vbhelper.transfer.hce
internal enum class VitalWearHceTransferDirection {
WATCH_TO_PHONE,
PHONE_TO_WATCH,
}
internal interface VitalWearHceSessionInfo {
val direction: VitalWearHceTransferDirection
val maxChunkSize: Int
val payloadLength: Int
fun isWatchToPhone(): Boolean {
return direction == VitalWearHceTransferDirection.WATCH_TO_PHONE
}
fun isPhoneToWatch(): Boolean {
return direction == VitalWearHceTransferDirection.PHONE_TO_WATCH
}
fun canCommit(): Boolean {
return true
}
fun canPollStatus(): Boolean {
// Import confirmation polling only applies for phone->watch transfers.
return isPhoneToWatch()
}
}
internal data class VitalWearHceSession(
override val direction: VitalWearHceTransferDirection,
override val maxChunkSize: Int,
override val payloadLength: Int,
) : VitalWearHceSessionInfo