diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/MainActivity.kt b/app/src/main/java/com/github/nacabaro/vbhelper/MainActivity.kt index 9d178a1..b397b1c 100644 --- a/app/src/main/java/com/github/nacabaro/vbhelper/MainActivity.kt +++ b/app/src/main/java/com/github/nacabaro/vbhelper/MainActivity.kt @@ -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) diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/ScanScreenControllerImpl.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/ScanScreenControllerImpl.kt index eb52382..de4d476 100644 --- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/ScanScreenControllerImpl.kt +++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/ScanScreenControllerImpl.kt @@ -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,24 +53,46 @@ class ScanScreenControllerImpl( } override fun onClickRead(secrets: Secrets, onComplete: ()->Unit, onMultipleCards: (List) -> Unit) { - handleTag(secrets) { tagCommunicator -> - try { - val character = tagCommunicator.receiveCharacter() - val resultMessage = characterFromNfc(character) { cards, nfcCharacter -> - lastScannedCharacter = nfcCharacter - onMultipleCards(cards) + handleTag( + secrets, + handlerFunc = { 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() + }, + 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() } - onComplete.invoke() - componentActivity.getString(R.string.scan_error_generic) } - } + ) } override fun cancelRead() { @@ -83,35 +112,53 @@ 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-> - 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() - } - } - nfcData.connect() - nfcData.use { - val tagCommunicator = TagCommunicator.getInstance(nfcData, secrets.getCryptographicTransformerMap()) - val successText = handlerFunc(tagCommunicator) - componentActivity.runOnUiThread { - Toast.makeText(componentActivity, successText, Toast.LENGTH_SHORT).show() + 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()) + val successText = handlerFunc(tagCommunicator) + componentActivity.runOnUiThread { + Toast.makeText(componentActivity, successText, Toast.LENGTH_SHORT).show() + } + } } } } @@ -132,24 +179,29 @@ class ScanScreenControllerImpl( 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) + handleTag( + secrets, + handlerFunc = { tagCommunicator -> + try { + if (nfcCharacter is VBNfcCharacter) { + Log.d("SendCharacter", "VBNfcCharacter") + tagCommunicator.sendCharacter(nfcCharacter) + } else if (nfcCharacter is BENfcCharacter) { + Log.d("SendCharacter", "BENfcCharacter") + tagCommunicator.sendCharacter(nfcCharacter) + } + 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) } - 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) + }, + 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 -> - tagCommunicator.prepareDIMForCharacter(nfcCharacter.dimId) - onComplete.invoke() - componentActivity.getString(R.string.scan_sent_dim_success) - } + 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 diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterExporter.kt b/app/src/main/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterExporter.kt index bc3ad95..f2223b5 100644 --- a/app/src/main/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterExporter.kt +++ b/app/src/main/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterExporter.kt @@ -15,52 +15,56 @@ class VitalWearCharacterExporter( private val context: Context, private val database: AppDatabase ) { + 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 + 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) + .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() + } + 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) - .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 proto = buildCharacterProto(characterId) val exportDir = File(context.cacheDir, "exports").apply { mkdirs() } val exportFile = File(exportDir, "vbhelper_character_$characterId.vitalwear") diff --git a/settings.gradle.kts b/settings.gradle.kts index 917b611..e09a855 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -17,6 +17,7 @@ dependencyResolutionManagement { mavenLocal() google() mavenCentral() + maven { url = uri("https://jitpack.io") } } }