mirror of
https://github.com/nacabaro/vbhelper.git
synced 2026-07-30 00:31:54 +00:00
speeding up HCE
This commit is contained in:
parent
431be5faea
commit
1a83527590
@ -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,
|
||||||
|
|||||||
@ -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()}")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Loading…
x
Reference in New Issue
Block a user