From 5286f87505d6306162c86cfedb0c8c4e1fd87b27 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 16 Feb 2026 21:57:12 -0500 Subject: [PATCH 1/5] fix: replace blocking NFC calls with proper coroutine/suspend APIs across all platforms iOS: - Replace dispatch_semaphore bridging with suspendCancellableCoroutine in IosCardTransceiver, IosUltralightTechnology, IosVicinityTechnology, and IosFeliCaTagAdapter - Replace runBlocking in IosNfcScanner with CoroutineScope(Dispatchers.IO) + GCD semaphore to avoid blocking GCD's worker queue thread - Use DESFire native protocol directly instead of ISO 7816 SELECT, which requires AIDs registered in Info.plist (unregistered AIDs kill the session) - Add NFCPollingISO15693 to NFC session polling options for NFC-V support - Fix Xcode project paths from stale farebot-app/ to app/ Desktop: - Make NfcReaderBackend.scanLoop() a suspend function, removing runBlocking from PN53xReaderBackend and PcscReaderBackend - Wrap scan coroutine in try/finally to ensure _isScanning resets on cancel - Share a single libusb context in PN533Device instead of per-call init/exit WebUSB: - Remove flush-on-open which left dangling transferIn promises that consumed subsequent device responses - Increase transferIn buffer from 64 to 265 bytes for full PN533 frames - Pass atrRetries to setMaxRetries so InListPassiveTarget self-resolves instead of relying on client-side abort (which WebUSB can't do) DESFire: - Handle COMMAND_ABORTED (0xCA) status code as access control exception --- .gitmodules | 0 .../farebot/desktop/DesktopCardScanner.kt | 63 ++++---- .../farebot/desktop/NfcReaderBackend.kt | 2 +- .../farebot/desktop/PN53xReaderBackend.kt | 9 +- .../farebot/desktop/PcscReaderBackend.kt | 5 +- app/ios/FareBot.xcodeproj/project.pbxproj | 59 ++----- app/ios/project.yml | 3 - .../farebot/shared/nfc/IosNfcScanner.kt | 67 ++++++-- .../codebutler/farebot/web/WebCardScanner.kt | 6 +- .../farebot/card/desfire/DesfireProtocol.kt | 2 + .../card/felica/IosFeliCaTagAdapter.kt | 151 +++++++++--------- .../farebot/card/nfc/IosCardTransceiver.kt | 40 ++--- .../card/nfc/IosUltralightTechnology.kt | 68 ++++---- .../farebot/card/nfc/IosVicinityTechnology.kt | 52 +++--- .../farebot/card/nfc/pn533/PN533Device.kt | 20 ++- .../card/nfc/pn533/WebUsbPN533Transport.kt | 13 +- 16 files changed, 261 insertions(+), 299 deletions(-) delete mode 100644 .gitmodules diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/DesktopCardScanner.kt b/app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/DesktopCardScanner.kt index a5824a2fa..6031eded3 100644 --- a/app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/DesktopCardScanner.kt +++ b/app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/DesktopCardScanner.kt @@ -71,49 +71,50 @@ class DesktopCardScanner : CardScanner { scanJob = scope.launch { - val backends = discoverBackends() - val backendJobs = - backends.map { backend -> - launch { - println("[DesktopCardScanner] Starting ${backend.name} backend") - try { - backend.scanLoop( - onCardDetected = { tag -> - _scannedTags.tryEmit(tag) - }, - onCardRead = { rawCard -> - _scannedCards.tryEmit(rawCard) - }, - onError = { error -> - _scanErrors.tryEmit(error) - }, - ) - } catch (e: Exception) { - if (isActive) { - println("[DesktopCardScanner] ${backend.name} backend failed: ${e.message}") + try { + val backends = discoverBackends() + val backendJobs = + backends.map { backend -> + launch { + println("[DesktopCardScanner] Starting ${backend.name} backend") + try { + backend.scanLoop( + onCardDetected = { tag -> + _scannedTags.tryEmit(tag) + }, + onCardRead = { rawCard -> + _scannedCards.tryEmit(rawCard) + }, + onError = { error -> + _scanErrors.tryEmit(error) + }, + ) + } catch (e: Exception) { + if (isActive) { + println("[DesktopCardScanner] ${backend.name} backend failed: ${e.message}") + } + } catch (e: Error) { + // Catch LinkageError / UnsatisfiedLinkError from native libs + println("[DesktopCardScanner] ${backend.name} backend unavailable: ${e.message}") } - } catch (e: Error) { - // Catch LinkageError / UnsatisfiedLinkError from native libs - println("[DesktopCardScanner] ${backend.name} backend unavailable: ${e.message}") } } - } - backendJobs.forEach { it.join() } + backendJobs.forEach { it.join() } - // All backends exited — emit error only if none ran successfully - if (isActive) { - _scanErrors.tryEmit(Exception("All NFC reader backends failed. Is a USB NFC reader connected?")) + // All backends exited — emit error only if none ran successfully + if (isActive) { + _scanErrors.tryEmit(Exception("All NFC reader backends failed. Is a USB NFC reader connected?")) + } + } finally { + _isScanning.value = false } - _isScanning.value = false } } override fun stopActiveScan() { scanJob?.cancel() scanJob = null - _isScanning.value = false - PN533Device.shutdown() } private suspend fun discoverBackends(): List { diff --git a/app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/NfcReaderBackend.kt b/app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/NfcReaderBackend.kt index 4b5ca4a75..04605ce14 100644 --- a/app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/NfcReaderBackend.kt +++ b/app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/NfcReaderBackend.kt @@ -36,7 +36,7 @@ import com.codebutler.farebot.shared.nfc.ScannedTag interface NfcReaderBackend { val name: String - fun scanLoop( + suspend fun scanLoop( onCardDetected: (ScannedTag) -> Unit, onCardRead: (RawCard<*>) -> Unit, onError: (Throwable) -> Unit, diff --git a/app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/PN53xReaderBackend.kt b/app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/PN53xReaderBackend.kt index 6b57056f8..9b273215c 100644 --- a/app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/PN53xReaderBackend.kt +++ b/app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/PN53xReaderBackend.kt @@ -41,7 +41,6 @@ import com.codebutler.farebot.card.ultralight.UltralightCardReader import com.codebutler.farebot.shared.nfc.ISO7816Dispatcher import com.codebutler.farebot.shared.nfc.ScannedTag import kotlinx.coroutines.delay -import kotlinx.coroutines.runBlocking /** * Abstract base for PN53x-family USB reader backends. @@ -59,7 +58,7 @@ abstract class PN53xReaderBackend( tg: Int, ): CardTransceiver = PN533CardTransceiver(pn533, tg) - override fun scanLoop( + override suspend fun scanLoop( onCardDetected: (ScannedTag) -> Unit, onCardRead: (RawCard<*>) -> Unit, onError: (Throwable) -> Unit, @@ -72,10 +71,8 @@ abstract class PN53xReaderBackend( transport.flush() val pn533 = PN533(transport) try { - runBlocking { - initDevice(pn533) - pollLoop(pn533, onCardDetected, onCardRead, onError) - } + initDevice(pn533) + pollLoop(pn533, onCardDetected, onCardRead, onError) } finally { pn533.close() } diff --git a/app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/PcscReaderBackend.kt b/app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/PcscReaderBackend.kt index 01ab52d36..07000aa47 100644 --- a/app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/PcscReaderBackend.kt +++ b/app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/PcscReaderBackend.kt @@ -37,7 +37,6 @@ import com.codebutler.farebot.card.ultralight.UltralightCardReader import com.codebutler.farebot.card.vicinity.VicinityCardReader import com.codebutler.farebot.shared.nfc.ISO7816Dispatcher import com.codebutler.farebot.shared.nfc.ScannedTag -import kotlinx.coroutines.runBlocking import javax.smartcardio.CardException import javax.smartcardio.CommandAPDU import javax.smartcardio.TerminalFactory @@ -51,7 +50,7 @@ import javax.smartcardio.TerminalFactory class PcscReaderBackend : NfcReaderBackend { override val name: String = "PC/SC" - override fun scanLoop( + override suspend fun scanLoop( onCardDetected: (ScannedTag) -> Unit, onCardRead: (RawCard<*>) -> Unit, onError: (Throwable) -> Unit, @@ -96,7 +95,7 @@ class PcscReaderBackend : NfcReaderBackend { println("[PC/SC] Tag ID: ${tagId.hex()}") onCardDetected(ScannedTag(id = tagId, techList = listOf(info.cardType.name))) - val rawCard = runBlocking { readCard(info, channel, tagId) } + val rawCard = readCard(info, channel, tagId) onCardRead(rawCard) println("[PC/SC] Card read successfully") } finally { diff --git a/app/ios/FareBot.xcodeproj/project.pbxproj b/app/ios/FareBot.xcodeproj/project.pbxproj index b45acf239..da7c07602 100644 --- a/app/ios/FareBot.xcodeproj/project.pbxproj +++ b/app/ios/FareBot.xcodeproj/project.pbxproj @@ -7,46 +7,18 @@ objects = { /* Begin PBXBuildFile section */ - 7BFF0BD60CC51FB78D8A764D /* FareBotKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = E296318A4ABC8EE549B0C47E /* FareBotKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 8E11E423477F24B274729679 /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 534508E7AAA01FF336ECDC0C /* iOSApp.swift */; }; D52C887B87D2D7CD2DF7A030 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 445C357A8AB1DD9317170556 /* Assets.xcassets */; }; - EA3AC0F2B800448FB22567C4 /* FareBotKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E296318A4ABC8EE549B0C47E /* FareBotKit.framework */; }; /* End PBXBuildFile section */ -/* Begin PBXCopyFilesBuildPhase section */ - C396E052E1BD6239F169D5D4 /* Embed Frameworks */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 10; - files = ( - 7BFF0BD60CC51FB78D8A764D /* FareBotKit.framework in Embed Frameworks */, - ); - name = "Embed Frameworks"; - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXCopyFilesBuildPhase section */ - /* Begin PBXFileReference section */ 154ABFFD520502DDADF58B61 /* FareBot.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = FareBot.app; sourceTree = BUILT_PRODUCTS_DIR; }; 445C357A8AB1DD9317170556 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 534508E7AAA01FF336ECDC0C /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = ""; }; A893E13DD60D0B10ECB49A59 /* FareBot.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = FareBot.entitlements; sourceTree = ""; }; - E296318A4ABC8EE549B0C47E /* FareBotKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = FareBotKit.framework; path = "../farebot-app/build/bin/iosSimulatorArm64/debugFramework/FareBotKit.framework"; sourceTree = ""; }; E65B641D90F72BA2E1DEAFF7 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; /* End PBXFileReference section */ -/* Begin PBXFrameworksBuildPhase section */ - E1F31206D4AE717D1E2DE8D8 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - EA3AC0F2B800448FB22567C4 /* FareBotKit.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - /* Begin PBXGroup section */ 2098F79B3F3B6A526575D03F /* Products */ = { isa = PBXGroup; @@ -67,19 +39,10 @@ path = FareBot; sourceTree = ""; }; - 9B0F3B4C26A1726B3C4A2BE9 /* Frameworks */ = { - isa = PBXGroup; - children = ( - E296318A4ABC8EE549B0C47E /* FareBotKit.framework */, - ); - name = Frameworks; - sourceTree = ""; - }; E8645766090C58DFD719F43E = { isa = PBXGroup; children = ( 35C5B4B3C4B8B2643DF5E68A /* FareBot */, - 9B0F3B4C26A1726B3C4A2BE9 /* Frameworks */, 2098F79B3F3B6A526575D03F /* Products */, ); sourceTree = ""; @@ -94,8 +57,6 @@ B2007E057701C93D2F6474DC /* Build KMP Framework */, 42DDFD780701DBC1BD02AB98 /* Sources */, 5DA19835EA0C3024B2D5A4B9 /* Resources */, - E1F31206D4AE717D1E2DE8D8 /* Frameworks */, - C396E052E1BD6239F169D5D4 /* Embed Frameworks */, ); buildRules = ( ); @@ -176,7 +137,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "cd \"$SRCROOT/..\"\n./gradlew :farebot-app:embedAndSignAppleFrameworkForXcode\n"; + shellScript = "cd \"$SRCROOT/../..\"\n./gradlew :app:embedAndSignAppleFrameworkForXcode\n"; }; /* End PBXShellScriptBuildPhase section */ @@ -324,11 +285,10 @@ CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = ZJ9GEQ36AH; FRAMEWORK_SEARCH_PATHS = ( - "$(SRCROOT)/../farebot-app/build/XCFrameworks/release", - "$(SRCROOT)/../farebot-app/build/bin/iosSimulatorArm64/debugFramework", - "$(SRCROOT)/../farebot-app/build/bin/iosArm64/debugFramework", - "$(SRCROOT)/../farebot-app/build/bin/iosX64/debugFramework", - "\"../farebot-app/build/bin/iosSimulatorArm64/debugFramework\"", + "$(SRCROOT)/../../app/build/XCFrameworks/release", + "$(SRCROOT)/../../app/build/bin/iosSimulatorArm64/debugFramework", + "$(SRCROOT)/../../app/build/bin/iosArm64/debugFramework", + "$(SRCROOT)/../../app/build/bin/iosX64/debugFramework", ); INFOPLIST_FILE = FareBot/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -357,11 +317,10 @@ CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = ZJ9GEQ36AH; FRAMEWORK_SEARCH_PATHS = ( - "$(SRCROOT)/../farebot-app/build/XCFrameworks/release", - "$(SRCROOT)/../farebot-app/build/bin/iosSimulatorArm64/debugFramework", - "$(SRCROOT)/../farebot-app/build/bin/iosArm64/debugFramework", - "$(SRCROOT)/../farebot-app/build/bin/iosX64/debugFramework", - "\"../farebot-app/build/bin/iosSimulatorArm64/debugFramework\"", + "$(SRCROOT)/../../app/build/XCFrameworks/release", + "$(SRCROOT)/../../app/build/bin/iosSimulatorArm64/debugFramework", + "$(SRCROOT)/../../app/build/bin/iosArm64/debugFramework", + "$(SRCROOT)/../../app/build/bin/iosX64/debugFramework", ); INFOPLIST_FILE = FareBot/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( diff --git a/app/ios/project.yml b/app/ios/project.yml index a55dc0d18..a67bcb4b1 100644 --- a/app/ios/project.yml +++ b/app/ios/project.yml @@ -38,9 +38,6 @@ targets: SystemCapabilities: com.apple.NearFieldCommunicationTagReading: enabled: 1 - dependencies: - - framework: "../../app/build/bin/iosSimulatorArm64/debugFramework/FareBotKit.framework" - embed: true preBuildScripts: - name: "Build KMP Framework" script: | diff --git a/app/src/iosMain/kotlin/com/codebutler/farebot/shared/nfc/IosNfcScanner.kt b/app/src/iosMain/kotlin/com/codebutler/farebot/shared/nfc/IosNfcScanner.kt index 97b46328d..e11c808ab 100644 --- a/app/src/iosMain/kotlin/com/codebutler/farebot/shared/nfc/IosNfcScanner.kt +++ b/app/src/iosMain/kotlin/com/codebutler/farebot/shared/nfc/IosNfcScanner.kt @@ -24,6 +24,7 @@ package com.codebutler.farebot.shared.nfc import com.codebutler.farebot.card.RawCard import com.codebutler.farebot.card.cepas.CEPASCardReader +import com.codebutler.farebot.card.desfire.DesfireCardReader import com.codebutler.farebot.card.felica.FeliCaReader import com.codebutler.farebot.card.felica.IosFeliCaTagAdapter import com.codebutler.farebot.card.nfc.IosCardTransceiver @@ -33,19 +34,23 @@ import com.codebutler.farebot.card.nfc.toByteArray import com.codebutler.farebot.card.ultralight.UltralightCardReader import com.codebutler.farebot.card.vicinity.VicinityCardReader import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.launch import platform.CoreNFC.NFCFeliCaTagProtocol import platform.CoreNFC.NFCISO15693TagProtocol import platform.CoreNFC.NFCMiFareDESFire import platform.CoreNFC.NFCMiFareTagProtocol import platform.CoreNFC.NFCMiFareUltralight import platform.CoreNFC.NFCPollingISO14443 +import platform.CoreNFC.NFCPollingISO15693 import platform.CoreNFC.NFCPollingISO18092 import platform.CoreNFC.NFCTagReaderSession import platform.CoreNFC.NFCTagReaderSessionDelegateProtocol @@ -116,7 +121,7 @@ class IosNfcScanner : CardScanner { dispatch_async(dispatch_get_main_queue()) { val newSession = NFCTagReaderSession( - pollingOption = NFCPollingISO14443 or NFCPollingISO18092, + pollingOption = NFCPollingISO14443 or NFCPollingISO15693 or NFCPollingISO18092, delegate = scanDelegate, queue = nfcQueue, ) @@ -170,14 +175,40 @@ class IosNfcScanner : CardScanner { } session.alertMessage = "Reading card… Keep holding." - try { - val rawCard = readTag(tag) - session.alertMessage = "Done!" - session.invalidateSession() - onCardScanned(rawCard) - } catch (e: Exception) { + + // Bridge suspend card readers using coroutine + GCD semaphore. + // We use CoroutineScope(Dispatchers.IO) instead of runBlocking to avoid + // interfering with GCD's management of the workerQueue thread. + val readSemaphore = dispatch_semaphore_create(0) + var rawCard: RawCard<*>? = null + var readException: Exception? = null + + CoroutineScope(Dispatchers.IO).launch { + try { + rawCard = readTag(tag) + } catch (e: Exception) { + readException = e + } finally { + dispatch_semaphore_signal(readSemaphore) + } + } + + dispatch_semaphore_wait(readSemaphore, DISPATCH_TIME_FOREVER) + + readException?.let { e -> session.invalidateSessionWithErrorMessage("Read failed: ${e.message}") onError("Read failed: ${e.message ?: "Unknown error"}") + return@dispatch_async + } + + val card = rawCard + if (card != null) { + session.alertMessage = "Done!" + session.invalidateSession() + onCardScanned(card) + } else { + session.invalidateSessionWithErrorMessage("Read failed: no card data") + onError("Read failed: no card data") } } } @@ -197,14 +228,12 @@ class IosNfcScanner : CardScanner { override fun tagReaderSessionDidBecomeActive(session: NFCTagReaderSession) { } - private fun readTag(tag: Any): RawCard<*> = - runBlocking { - when (tag) { - is NFCFeliCaTagProtocol -> readFelicaTag(tag) - is NFCMiFareTagProtocol -> readMiFareTag(tag) - is NFCISO15693TagProtocol -> readVicinityTag(tag) - else -> throw Exception("Unsupported NFC tag type") - } + private suspend fun readTag(tag: Any): RawCard<*> = + when (tag) { + is NFCFeliCaTagProtocol -> readFelicaTag(tag) + is NFCMiFareTagProtocol -> readMiFareTag(tag) + is NFCISO15693TagProtocol -> readVicinityTag(tag) + else -> throw Exception("Unsupported NFC tag type") } private suspend fun readFelicaTag(tag: NFCFeliCaTagProtocol): RawCard<*> { @@ -228,10 +257,14 @@ class IosNfcScanner : CardScanner { val tagId = tag.identifier.toByteArray() return when (tag.mifareFamily) { NFCMiFareDESFire -> { + // Use DESFire native protocol directly. iOS requires AIDs to be + // registered in Info.plist for ISO 7816 SELECT commands — an + // unregistered AID causes Core NFC to kill the entire session. + // DESFire native protocol avoids this by not sending SELECT commands. val transceiver = IosCardTransceiver(tag) transceiver.connect() try { - ISO7816Dispatcher.readCard(tagId, transceiver) + DesfireCardReader.readCard(tagId, transceiver) } finally { if (transceiver.isConnected) { try { diff --git a/app/web/src/wasmJsMain/kotlin/com/codebutler/farebot/web/WebCardScanner.kt b/app/web/src/wasmJsMain/kotlin/com/codebutler/farebot/web/WebCardScanner.kt index b4f213586..986de24d6 100644 --- a/app/web/src/wasmJsMain/kotlin/com/codebutler/farebot/web/WebCardScanner.kt +++ b/app/web/src/wasmJsMain/kotlin/com/codebutler/farebot/web/WebCardScanner.kt @@ -118,7 +118,11 @@ class WebCardScanner : CardScanner { val fw = pn533.getFirmwareVersion() println("[WebUSB] PN53x firmware: $fw") pn533.samConfiguration() - pn533.setMaxRetries(passiveActivation = 0x02) + // Use finite ATR retries on WebUSB. WebUSB's transferIn cannot be + // cancelled, so InListPassiveTarget must self-resolve within its own + // timeout rather than relying on client-side abort. With atrRetries=2, + // the PN533 polls ~2 times (~300ms) then returns NbTg=0. + pn533.setMaxRetries(atrRetries = 0x02, passiveActivation = 0x02) while (true) { // Try ISO 14443-A (covers Classic, Ultralight, DESFire) diff --git a/card/desfire/src/commonMain/kotlin/com/codebutler/farebot/card/desfire/DesfireProtocol.kt b/card/desfire/src/commonMain/kotlin/com/codebutler/farebot/card/desfire/DesfireProtocol.kt index bd755a027..53a142952 100644 --- a/card/desfire/src/commonMain/kotlin/com/codebutler/farebot/card/desfire/DesfireProtocol.kt +++ b/card/desfire/src/commonMain/kotlin/com/codebutler/farebot/card/desfire/DesfireProtocol.kt @@ -208,6 +208,7 @@ internal class DesfireProtocol( } PERMISSION_DENIED -> throw DesfireAccessControlException("Permission denied") AUTHENTICATION_ERROR -> throw DesfireAccessControlException("Authentication error") + COMMAND_ABORTED -> throw DesfireAccessControlException("Command aborted") AID_NOT_FOUND -> throw DesfireNotFoundException("AID not found") FILE_NOT_FOUND -> throw DesfireNotFoundException("File not found") else -> throw Exception("Unknown status code: " + (status.toInt() and 0xFF).toString(16)) @@ -259,6 +260,7 @@ internal class DesfireProtocol( private val AID_NOT_FOUND: Byte = 0xA0.toByte() private val AUTHENTICATION_ERROR: Byte = 0xAE.toByte() private val ADDITIONAL_FRAME: Byte = 0xAF.toByte() + private val COMMAND_ABORTED: Byte = 0xCA.toByte() private val FILE_NOT_FOUND: Byte = 0xF0.toByte() } } diff --git a/card/felica/src/iosMain/kotlin/com/codebutler/farebot/card/felica/IosFeliCaTagAdapter.kt b/card/felica/src/iosMain/kotlin/com/codebutler/farebot/card/felica/IosFeliCaTagAdapter.kt index 2bdc92404..f704fd819 100644 --- a/card/felica/src/iosMain/kotlin/com/codebutler/farebot/card/felica/IosFeliCaTagAdapter.kt +++ b/card/felica/src/iosMain/kotlin/com/codebutler/farebot/card/felica/IosFeliCaTagAdapter.kt @@ -25,20 +25,19 @@ package com.codebutler.farebot.card.felica import com.codebutler.farebot.card.nfc.toByteArray import com.codebutler.farebot.card.nfc.toNSData import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.coroutines.suspendCancellableCoroutine import platform.CoreNFC.NFCFeliCaPollingRequestCodeNoRequest import platform.CoreNFC.NFCFeliCaPollingTimeSlotMax1 import platform.CoreNFC.NFCFeliCaTagProtocol import platform.Foundation.NSData import platform.Foundation.NSError -import platform.darwin.DISPATCH_TIME_FOREVER -import platform.darwin.dispatch_semaphore_create -import platform.darwin.dispatch_semaphore_signal -import platform.darwin.dispatch_semaphore_wait +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException /** * iOS implementation of [FeliCaTagAdapter] using Core NFC's [NFCFeliCaTagProtocol]. * - * Uses semaphore-based bridging for the async Core NFC API. + * Uses [suspendCancellableCoroutine] to bridge the async Core NFC API. */ @OptIn(ExperimentalForeignApi::class) class IosFeliCaTagAdapter( @@ -47,19 +46,22 @@ class IosFeliCaTagAdapter( override fun getIDm(): ByteArray = tag.currentIDm.toByteArray() override suspend fun getSystemCodes(): List { - val semaphore = dispatch_semaphore_create(0) - var codes: List<*>? = null - var nfcError: NSError? = null - - tag.requestSystemCodeWithCompletionHandler { systemCodes: List<*>?, error: NSError? -> - codes = systemCodes - nfcError = error - dispatch_semaphore_signal(semaphore) - } - - dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER) - - if (nfcError != null) return emptyList() + val codes = + try { + suspendCancellableCoroutine?> { cont -> + tag.requestSystemCodeWithCompletionHandler { systemCodes: List<*>?, error: NSError? -> + if (error != null) { + cont.resumeWithException( + Exception("requestSystemCode failed: ${error.localizedDescription}"), + ) + } else { + cont.resume(systemCodes) + } + } + } + } catch (_: Exception) { + return emptyList() + } return codes?.mapNotNull { item -> val data = item as? NSData ?: return@mapNotNull null @@ -73,30 +75,29 @@ class IosFeliCaTagAdapter( } override suspend fun selectSystem(systemCode: Int): ByteArray? { - val semaphore = dispatch_semaphore_create(0) - var pmmData: NSData? = null - var nfcError: NSError? = null - val systemCodeBytes = byteArrayOf( (systemCode shr 8).toByte(), (systemCode and 0xff).toByte(), ) - tag.pollingWithSystemCode( - systemCodeBytes.toNSData(), - requestCode = NFCFeliCaPollingRequestCodeNoRequest, - timeSlot = NFCFeliCaPollingTimeSlotMax1, - ) { pmm: NSData?, _: NSData?, error: NSError? -> - pmmData = pmm - nfcError = error - dispatch_semaphore_signal(semaphore) + return try { + suspendCancellableCoroutine { cont -> + tag.pollingWithSystemCode( + systemCodeBytes.toNSData(), + requestCode = NFCFeliCaPollingRequestCodeNoRequest, + timeSlot = NFCFeliCaPollingTimeSlotMax1, + ) { pmm: NSData?, _: NSData?, error: NSError? -> + if (error != null) { + cont.resume(null) + } else { + cont.resume(pmm?.toByteArray()) + } + } + } + } catch (_: Exception) { + null } - - dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER) - - if (nfcError != null) return null - return pmmData?.toByteArray() } override suspend fun getServiceCodes(): List { @@ -126,10 +127,6 @@ class IosFeliCaTagAdapter( serviceCode: Int, blockAddr: Byte, ): ByteArray? { - val semaphore = dispatch_semaphore_create(0) - var blockDataList: List<*>? = null - var nfcError: NSError? = null - // Service code list: 2 bytes, little-endian val serviceCodeData = byteArrayOf( @@ -140,29 +137,27 @@ class IosFeliCaTagAdapter( // Block list element: 2-byte format (0x80 | service_list_order, block_number) val blockListData = byteArrayOf(0x80.toByte(), blockAddr).toNSData() - tag.readWithoutEncryptionWithServiceCodeList( - listOf(serviceCodeData), - blockList = listOf(blockListData), - ) { _: Long, _: Long, dataList: List<*>?, error: NSError? -> - blockDataList = dataList - nfcError = error - dispatch_semaphore_signal(semaphore) + return try { + suspendCancellableCoroutine { cont -> + tag.readWithoutEncryptionWithServiceCodeList( + listOf(serviceCodeData), + blockList = listOf(blockListData), + ) { _: Long, _: Long, dataList: List<*>?, error: NSError? -> + if (error != null) { + cont.resume(null) + } else { + val data = dataList?.firstOrNull() as? NSData + val bytes = data?.toByteArray() + cont.resume(if (bytes != null && bytes.isNotEmpty()) bytes else null) + } + } + } + } catch (_: Exception) { + null } - - dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER) - - if (nfcError != null) return null - - val data = blockDataList?.firstOrNull() as? NSData ?: return null - val bytes = data.toByteArray() - return if (bytes.isNotEmpty()) bytes else null } - private fun requestServiceVersions(serviceCodes: List): List? { - val semaphore = dispatch_semaphore_create(0) - var versionList: List<*>? = null - var nfcError: NSError? = null - + private suspend fun requestServiceVersions(serviceCodes: List): List? { val nodeCodeList = serviceCodes.map { code -> byteArrayOf( @@ -171,24 +166,28 @@ class IosFeliCaTagAdapter( ).toNSData() } - tag.requestServiceWithNodeCodeList(nodeCodeList) { versions: List<*>?, error: NSError? -> - versionList = versions - nfcError = error - dispatch_semaphore_signal(semaphore) - } - - dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER) - - if (nfcError != null) return null - - return versionList?.map { item -> - val data = item as? NSData ?: return@map 0xFFFF - val bytes = data.toByteArray() - if (bytes.size >= 2) { - (bytes[0].toInt() and 0xff) or ((bytes[1].toInt() and 0xff) shl 8) - } else { - 0xFFFF + return try { + suspendCancellableCoroutine?> { cont -> + tag.requestServiceWithNodeCodeList(nodeCodeList) { versions: List<*>?, error: NSError? -> + if (error != null) { + cont.resume(null) + } else { + cont.resume( + versions?.map { item -> + val data = item as? NSData ?: return@map 0xFFFF + val bytes = data.toByteArray() + if (bytes.size >= 2) { + (bytes[0].toInt() and 0xff) or ((bytes[1].toInt() and 0xff) shl 8) + } else { + 0xFFFF + } + }, + ) + } + } } + } catch (_: Exception) { + null } } diff --git a/card/src/iosMain/kotlin/com/codebutler/farebot/card/nfc/IosCardTransceiver.kt b/card/src/iosMain/kotlin/com/codebutler/farebot/card/nfc/IosCardTransceiver.kt index 90c6a5e75..1e73199e2 100644 --- a/card/src/iosMain/kotlin/com/codebutler/farebot/card/nfc/IosCardTransceiver.kt +++ b/card/src/iosMain/kotlin/com/codebutler/farebot/card/nfc/IosCardTransceiver.kt @@ -23,13 +23,12 @@ package com.codebutler.farebot.card.nfc import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.coroutines.suspendCancellableCoroutine import platform.CoreNFC.NFCMiFareTagProtocol import platform.Foundation.NSData import platform.Foundation.NSError -import platform.darwin.DISPATCH_TIME_FOREVER -import platform.darwin.dispatch_semaphore_create -import platform.darwin.dispatch_semaphore_signal -import platform.darwin.dispatch_semaphore_wait +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException /** * iOS implementation of [CardTransceiver] wrapping Core NFC's [NFCMiFareTag]. @@ -39,8 +38,7 @@ import platform.darwin.dispatch_semaphore_wait * [DesfireProtocol] and [CEPASProtocol] use through [transceive]. * * Core NFC APIs are asynchronous (completion handler based). This wrapper bridges - * them to the synchronous [CardTransceiver] interface using dispatch semaphores, - * which is safe because tag reading runs on a background thread. + * them to the suspend [CardTransceiver] interface using [suspendCancellableCoroutine]. */ @OptIn(ExperimentalForeignApi::class) class IosCardTransceiver( @@ -61,27 +59,19 @@ class IosCardTransceiver( override val isConnected: Boolean get() = _isConnected - override suspend fun transceive(data: ByteArray): ByteArray { - val semaphore = dispatch_semaphore_create(0) - var result: NSData? = null - var nfcError: NSError? = null - - tag.sendMiFareCommand(data.toNSData()) { response: NSData?, error: NSError? -> - result = response - nfcError = error - dispatch_semaphore_signal(semaphore) - } - - dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER) - - nfcError?.let { - throw Exception("NFC transceive failed: ${it.localizedDescription}") + override suspend fun transceive(data: ByteArray): ByteArray = + suspendCancellableCoroutine { cont -> + tag.sendMiFareCommand(data.toNSData()) { response: NSData?, error: NSError? -> + if (error != null) { + cont.resumeWithException(Exception("NFC transceive failed: ${error.localizedDescription}")) + } else if (response != null) { + cont.resume(response.toByteArray()) + } else { + cont.resumeWithException(Exception("NFC transceive returned null response")) + } + } } - return result?.toByteArray() - ?: throw Exception("NFC transceive returned null response") - } - override val maxTransceiveLength: Int get() = 253 // ISO 7816 APDU maximum command length } diff --git a/card/src/iosMain/kotlin/com/codebutler/farebot/card/nfc/IosUltralightTechnology.kt b/card/src/iosMain/kotlin/com/codebutler/farebot/card/nfc/IosUltralightTechnology.kt index f1a9def06..496b721e0 100644 --- a/card/src/iosMain/kotlin/com/codebutler/farebot/card/nfc/IosUltralightTechnology.kt +++ b/card/src/iosMain/kotlin/com/codebutler/farebot/card/nfc/IosUltralightTechnology.kt @@ -23,14 +23,13 @@ package com.codebutler.farebot.card.nfc import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.coroutines.suspendCancellableCoroutine import platform.CoreNFC.NFCMiFareTagProtocol import platform.CoreNFC.NFCMiFareUltralight import platform.Foundation.NSData import platform.Foundation.NSError -import platform.darwin.DISPATCH_TIME_FOREVER -import platform.darwin.dispatch_semaphore_create -import platform.darwin.dispatch_semaphore_signal -import platform.darwin.dispatch_semaphore_wait +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException /** * iOS implementation of [UltralightTechnology] wrapping Core NFC's [NFCMiFareTag]. @@ -68,44 +67,33 @@ class IosUltralightTechnology( // Returns 16 bytes (4 consecutive pages of 4 bytes each). val readCommand = byteArrayOf(0x30, pageOffset.toByte()) - val semaphore = dispatch_semaphore_create(0) - var result: NSData? = null - var nfcError: NSError? = null - - tag.sendMiFareCommand(readCommand.toNSData()) { response: NSData?, error: NSError? -> - result = response - nfcError = error - dispatch_semaphore_signal(semaphore) - } - - dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER) - - nfcError?.let { - throw Exception("Ultralight read failed at page $pageOffset: ${it.localizedDescription}") + return suspendCancellableCoroutine { cont -> + tag.sendMiFareCommand(readCommand.toNSData()) { response: NSData?, error: NSError? -> + if (error != null) { + cont.resumeWithException( + Exception("Ultralight read failed at page $pageOffset: ${error.localizedDescription}"), + ) + } else if (response != null) { + cont.resume(response.toByteArray()) + } else { + cont.resumeWithException( + Exception("Ultralight read returned null at page $pageOffset"), + ) + } + } } - - return result?.toByteArray() - ?: throw Exception("Ultralight read returned null at page $pageOffset") } - override suspend fun transceive(data: ByteArray): ByteArray { - val semaphore = dispatch_semaphore_create(0) - var result: NSData? = null - var nfcError: NSError? = null - - tag.sendMiFareCommand(data.toNSData()) { response: NSData?, error: NSError? -> - result = response - nfcError = error - dispatch_semaphore_signal(semaphore) - } - - dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER) - - nfcError?.let { - throw Exception("Ultralight transceive failed: ${it.localizedDescription}") + override suspend fun transceive(data: ByteArray): ByteArray = + suspendCancellableCoroutine { cont -> + tag.sendMiFareCommand(data.toNSData()) { response: NSData?, error: NSError? -> + if (error != null) { + cont.resumeWithException(Exception("Ultralight transceive failed: ${error.localizedDescription}")) + } else if (response != null) { + cont.resume(response.toByteArray()) + } else { + cont.resumeWithException(Exception("Ultralight transceive returned null")) + } + } } - - return result?.toByteArray() - ?: throw Exception("Ultralight transceive returned null") - } } diff --git a/card/src/iosMain/kotlin/com/codebutler/farebot/card/nfc/IosVicinityTechnology.kt b/card/src/iosMain/kotlin/com/codebutler/farebot/card/nfc/IosVicinityTechnology.kt index 4910edc02..c9b331e3b 100644 --- a/card/src/iosMain/kotlin/com/codebutler/farebot/card/nfc/IosVicinityTechnology.kt +++ b/card/src/iosMain/kotlin/com/codebutler/farebot/card/nfc/IosVicinityTechnology.kt @@ -23,18 +23,17 @@ package com.codebutler.farebot.card.nfc import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.coroutines.suspendCancellableCoroutine import platform.CoreNFC.NFCISO15693TagProtocol import platform.Foundation.NSData import platform.Foundation.NSError -import platform.darwin.DISPATCH_TIME_FOREVER -import platform.darwin.dispatch_semaphore_create -import platform.darwin.dispatch_semaphore_signal -import platform.darwin.dispatch_semaphore_wait +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException /** * iOS implementation of [VicinityTechnology] using Core NFC's [NFCISO15693TagProtocol]. * - * Uses semaphore-based bridging for the async Core NFC API. + * Uses [suspendCancellableCoroutine] to bridge the async Core NFC API. */ @OptIn(ExperimentalForeignApi::class) class IosVicinityTechnology( @@ -76,30 +75,29 @@ class IosVicinityTechnology( val blockNumber = data[10].toUByte() - val semaphore = dispatch_semaphore_create(0) - var blockData: NSData? = null - var nfcError: NSError? = null - - tag.readSingleBlockWithRequestFlags( - 0x22u, - blockNumber = blockNumber, - ) { data: NSData?, error: NSError? -> - blockData = data - nfcError = error - dispatch_semaphore_signal(semaphore) - } - - dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER) - - nfcError?.let { error -> - when (error.code) { - 102L -> throw EndOfMemoryException() - 100L -> throw TagLostException() - else -> throw Exception("NFC-V read error: ${error.localizedDescription}") + val bytes = + suspendCancellableCoroutine { cont -> + tag.readSingleBlockWithRequestFlags( + 0x22u, + blockNumber = blockNumber, + ) { blockData: NSData?, error: NSError? -> + if (error != null) { + when (error.code) { + 102L -> cont.resumeWithException(EndOfMemoryException()) + 100L -> cont.resumeWithException(TagLostException()) + else -> + cont.resumeWithException( + Exception("NFC-V read error: ${error.localizedDescription}"), + ) + } + } else if (blockData != null) { + cont.resume(blockData.toByteArray()) + } else { + cont.resumeWithException(Exception("No data returned")) + } + } } - } - val bytes = blockData?.toByteArray() ?: throw Exception("No data returned") // Prepend success status byte (0x00) to match Android NfcV.transceive behavior return byteArrayOf(0x00) + bytes } diff --git a/card/src/jvmMain/kotlin/com/codebutler/farebot/card/nfc/pn533/PN533Device.kt b/card/src/jvmMain/kotlin/com/codebutler/farebot/card/nfc/pn533/PN533Device.kt index 83e7516df..2fed1c8aa 100644 --- a/card/src/jvmMain/kotlin/com/codebutler/farebot/card/nfc/pn533/PN533Device.kt +++ b/card/src/jvmMain/kotlin/com/codebutler/farebot/card/nfc/pn533/PN533Device.kt @@ -43,19 +43,22 @@ object PN533Device { private var context: Context? = null + private fun ensureContext(): Context? { + context?.let { return it } + val ctx = Context() + if (LibUsb.init(ctx) != LibUsb.SUCCESS) return null + context = ctx + return ctx + } + fun open(): Usb4JavaPN533Transport? = openAll().firstOrNull() fun openAll(): List { - val ctx = Context() - val result = LibUsb.init(ctx) - if (result != LibUsb.SUCCESS) { - return emptyList() - } + val ctx = ensureContext() ?: return emptyList() val deviceList = DeviceList() val count = LibUsb.getDeviceList(ctx, deviceList) if (count < 0) { - LibUsb.exit(ctx) return emptyList() } @@ -90,11 +93,6 @@ object PN533Device { LibUsb.freeDeviceList(deviceList, true) } - if (transports.isEmpty()) { - LibUsb.exit(ctx) - } else { - context = ctx - } return transports } diff --git a/card/src/wasmJsMain/kotlin/com/codebutler/farebot/card/nfc/pn533/WebUsbPN533Transport.kt b/card/src/wasmJsMain/kotlin/com/codebutler/farebot/card/nfc/pn533/WebUsbPN533Transport.kt index 5ab4a0b1b..141fd106e 100644 --- a/card/src/wasmJsMain/kotlin/com/codebutler/farebot/card/nfc/pn533/WebUsbPN533Transport.kt +++ b/card/src/wasmJsMain/kotlin/com/codebutler/farebot/card/nfc/pn533/WebUsbPN533Transport.kt @@ -80,11 +80,10 @@ class WebUsbPN533Transport : PN533Transport { return false } deviceOpened = true - // Drain stale data - repeat(MAX_FLUSH_READS) { - val drained = bulkRead(FLUSH_TIMEOUT_MS) - drained ?: return@repeat - } + // No flush here — WebUSB transferIn cannot be cancelled, so rapid + // reads with short timeouts leave dangling promises that consume + // subsequent device responses. The poll loop sends an ACK first + // to clear any stale PN533 command state. return true } @@ -153,8 +152,6 @@ class WebUsbPN533Transport : PN533Transport { companion object { const val TIMEOUT_MS = 5000 - const val FLUSH_TIMEOUT_MS = 100 - const val MAX_FLUSH_READS = 10 const val POLL_INTERVAL_MS = 5L const val TFI_HOST_TO_PN533: Byte = 0xD4.toByte() @@ -315,7 +312,7 @@ private fun jsWebUsbStartTransferIn(timeoutMs: Int) { var timer = setTimeout(function() { if (!window._fbUsbIn.ready) window._fbUsbIn.ready = true; }, timeoutMs); - window._fbUsb.device.transferIn(4, 64).then(function(result) { + window._fbUsb.device.transferIn(4, 265).then(function(result) { clearTimeout(timer); if (result.data && result.data.byteLength > 0) { var arr = new Uint8Array(result.data.buffer); From 3ff84ab38b7694da737f16bf19973f8fe6d824be Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Feb 2026 05:02:27 -0800 Subject: [PATCH 2/5] feat(flipper): add Flipper Zero integration for NFC dump import Add new :flipper KMP module with protobuf RPC client that communicates with Flipper Zero over USB serial and BLE. Supports browsing the NFC file system, importing card dumps, and importing MIFARE Classic key dictionaries into the app's global key store. Platform transports: - Android: USB Host API (CDC ACM) + BLE GATT - iOS: Core Bluetooth BLE - Desktop: jSerialComm USB serial - Web: Web Serial API + Web Bluetooth API Also adds: - Global MIFARE Classic key dictionary (global_keys DB table) - ClassicCardReader global key auth fallback - FlipperScreen Compose UI with file browser - FlipperViewModel with connect/import logic - FlipperNfcParser Classic key extraction from sector trailers - Comprehensive tests (unit + integration) Co-Authored-By: Claude Opus 4.6 --- app/build.gradle.kts | 1 + .../farebot/desktop/DesktopAppGraph.kt | 6 + .../farebot/app/core/di/AndroidAppGraph.kt | 7 + .../composeResources/values/strings.xml | 16 + .../farebot/persist/CardKeysPersister.kt | 6 + .../farebot/persist/db/DbCardKeysPersister.kt | 28 ++ .../com/codebutler/farebot/shared/App.kt | 20 + .../codebutler/farebot/shared/di/AppGraph.kt | 4 + .../farebot/shared/serialize/CardImporter.kt | 10 +- .../shared/serialize/FlipperNfcParser.kt | 52 ++- .../farebot/shared/ui/navigation/Screen.kt | 2 + .../farebot/shared/ui/screen/FlipperScreen.kt | 374 +++++++++++++++ .../shared/ui/screen/FlipperUiState.kt | 31 ++ .../farebot/shared/ui/screen/HomeScreen.kt | 20 + .../shared/viewmodel/FlipperViewModel.kt | 250 ++++++++++ .../codebutler/farebot/persist/db/SavedKey.sq | 19 + .../farebot/test/FlipperIntegrationTest.kt | 30 +- .../farebot/test/FlipperNfcParserTest.kt | 119 ++++- .../farebot/shared/di/IosAppGraph.kt | 6 + .../web/LocalStorageCardKeysPersister.kt | 24 + .../com/codebutler/farebot/web/WebAppGraph.kt | 6 + .../farebot/card/classic/ClassicCardReader.kt | 19 + .../card/classic/ClassicCardReaderTest.kt | 27 ++ flipper/build.gradle.kts | 37 ++ .../flipper/AndroidBleSerialTransport.kt | 186 ++++++++ .../flipper/AndroidFlipperTransportFactory.kt | 16 + .../flipper/AndroidUsbSerialTransport.kt | 179 ++++++++ .../farebot/flipper/FlipperException.kt | 32 ++ .../farebot/flipper/FlipperKeyDictParser.kt | 48 ++ .../farebot/flipper/FlipperRpcClient.kt | 433 ++++++++++++++++++ .../farebot/flipper/FlipperTransport.kt | 31 ++ .../flipper/FlipperTransportFactory.kt | 27 ++ .../com/codebutler/farebot/flipper/Varint.kt | 52 +++ .../farebot/flipper/proto/FlipperMain.kt | 56 +++ .../farebot/flipper/proto/FlipperStorage.kt | 128 ++++++ .../farebot/flipper/proto/FlipperSystem.kt | 40 ++ .../farebot/flipper/FlipperIntegrationTest.kt | 181 ++++++++ .../flipper/FlipperKeyDictParserTest.kt | 92 ++++ .../farebot/flipper/FlipperRpcClientTest.kt | 278 +++++++++++ .../farebot/flipper/MockTransport.kt | 56 +++ .../codebutler/farebot/flipper/VarintTest.kt | 72 +++ .../farebot/flipper/proto/FlipperProtoTest.kt | 95 ++++ .../farebot/flipper/IosBleSerialTransport.kt | 249 ++++++++++ .../flipper/IosFlipperTransportFactory.kt | 11 + .../flipper/JvmFlipperTransportFactory.kt | 11 + .../farebot/flipper/JvmUsbSerialTransport.kt | 70 +++ .../farebot/flipper/WebBleTransport.kt | 232 ++++++++++ .../flipper/WebFlipperTransportFactory.kt | 12 + .../farebot/flipper/WebSerialTransport.kt | 222 +++++++++ settings.gradle.kts | 1 + 50 files changed, 3879 insertions(+), 45 deletions(-) create mode 100644 app/src/commonMain/kotlin/com/codebutler/farebot/shared/ui/screen/FlipperScreen.kt create mode 100644 app/src/commonMain/kotlin/com/codebutler/farebot/shared/ui/screen/FlipperUiState.kt create mode 100644 app/src/commonMain/kotlin/com/codebutler/farebot/shared/viewmodel/FlipperViewModel.kt create mode 100644 flipper/build.gradle.kts create mode 100644 flipper/src/androidMain/kotlin/com/codebutler/farebot/flipper/AndroidBleSerialTransport.kt create mode 100644 flipper/src/androidMain/kotlin/com/codebutler/farebot/flipper/AndroidFlipperTransportFactory.kt create mode 100644 flipper/src/androidMain/kotlin/com/codebutler/farebot/flipper/AndroidUsbSerialTransport.kt create mode 100644 flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/FlipperException.kt create mode 100644 flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/FlipperKeyDictParser.kt create mode 100644 flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/FlipperRpcClient.kt create mode 100644 flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/FlipperTransport.kt create mode 100644 flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/FlipperTransportFactory.kt create mode 100644 flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/Varint.kt create mode 100644 flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/proto/FlipperMain.kt create mode 100644 flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/proto/FlipperStorage.kt create mode 100644 flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/proto/FlipperSystem.kt create mode 100644 flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/FlipperIntegrationTest.kt create mode 100644 flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/FlipperKeyDictParserTest.kt create mode 100644 flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/FlipperRpcClientTest.kt create mode 100644 flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/MockTransport.kt create mode 100644 flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/VarintTest.kt create mode 100644 flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/proto/FlipperProtoTest.kt create mode 100644 flipper/src/iosMain/kotlin/com/codebutler/farebot/flipper/IosBleSerialTransport.kt create mode 100644 flipper/src/iosMain/kotlin/com/codebutler/farebot/flipper/IosFlipperTransportFactory.kt create mode 100644 flipper/src/jvmMain/kotlin/com/codebutler/farebot/flipper/JvmFlipperTransportFactory.kt create mode 100644 flipper/src/jvmMain/kotlin/com/codebutler/farebot/flipper/JvmUsbSerialTransport.kt create mode 100644 flipper/src/wasmJsMain/kotlin/com/codebutler/farebot/flipper/WebBleTransport.kt create mode 100644 flipper/src/wasmJsMain/kotlin/com/codebutler/farebot/flipper/WebFlipperTransportFactory.kt create mode 100644 flipper/src/wasmJsMain/kotlin/com/codebutler/farebot/flipper/WebSerialTransport.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1a5ae61d9..6855ab7c2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -131,6 +131,7 @@ kotlin { api(project(":transit:warsaw")) api(project(":transit:zolotayakorona")) api(project(":transit:serialonly")) + api(project(":flipper")) api(project(":transit:krocap")) api(project(":transit:snapper")) api(project(":transit:ndef")) diff --git a/app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/DesktopAppGraph.kt b/app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/DesktopAppGraph.kt index 035932e17..32b975a54 100644 --- a/app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/DesktopAppGraph.kt +++ b/app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/DesktopAppGraph.kt @@ -15,6 +15,8 @@ import com.codebutler.farebot.shared.platform.Analytics import com.codebutler.farebot.shared.platform.AppPreferences import com.codebutler.farebot.shared.platform.JvmAppPreferences import com.codebutler.farebot.shared.platform.NoOpAnalytics +import com.codebutler.farebot.flipper.FlipperTransportFactory +import com.codebutler.farebot.flipper.JvmFlipperTransportFactory import com.codebutler.farebot.shared.serialize.CardImporter import com.codebutler.farebot.shared.serialize.FareBotSerializersModule import com.codebutler.farebot.shared.serialize.KotlinxCardSerializer @@ -87,6 +89,10 @@ abstract class DesktopAppGraph : AppGraph { json: Json, ): CardImporter = CardImporter(cardSerializer, json) + @Provides + @SingleIn(AppScope::class) + fun provideFlipperTransportFactory(): FlipperTransportFactory = JvmFlipperTransportFactory() + @Provides fun provideNullableCardScanner(scanner: CardScanner): CardScanner? = scanner } diff --git a/app/src/androidMain/kotlin/com/codebutler/farebot/app/core/di/AndroidAppGraph.kt b/app/src/androidMain/kotlin/com/codebutler/farebot/app/core/di/AndroidAppGraph.kt index 336965f50..22124edff 100644 --- a/app/src/androidMain/kotlin/com/codebutler/farebot/app/core/di/AndroidAppGraph.kt +++ b/app/src/androidMain/kotlin/com/codebutler/farebot/app/core/di/AndroidAppGraph.kt @@ -19,6 +19,8 @@ import com.codebutler.farebot.shared.nfc.CardScanner import com.codebutler.farebot.shared.platform.Analytics import com.codebutler.farebot.shared.platform.AppPreferences import com.codebutler.farebot.shared.platform.NoOpAnalytics +import com.codebutler.farebot.flipper.AndroidFlipperTransportFactory +import com.codebutler.farebot.flipper.FlipperTransportFactory import com.codebutler.farebot.shared.serialize.CardImporter import com.codebutler.farebot.shared.serialize.FareBotSerializersModule import com.codebutler.farebot.shared.serialize.KotlinxCardSerializer @@ -114,6 +116,11 @@ abstract class AndroidAppGraph : AppGraph { json: Json, ): CardImporter = CardImporter(cardSerializer, json) + @Provides + @SingleIn(AppScope::class) + fun provideFlipperTransportFactory(context: Context): FlipperTransportFactory = + AndroidFlipperTransportFactory(context) + @Provides fun provideNullableCardScanner(scanner: CardScanner): CardScanner? = scanner } diff --git a/app/src/commonMain/composeResources/values/strings.xml b/app/src/commonMain/composeResources/values/strings.xml index 2b793630f..6c73ed9c2 100644 --- a/app/src/commonMain/composeResources/values/strings.xml +++ b/app/src/commonMain/composeResources/values/strings.xml @@ -103,4 +103,20 @@ Today Yesterday + + + Flipper Zero + Connecting\u2026 + Connecting to Flipper Zero\u2026 + Disconnect + Connect your Flipper Zero to browse and import NFC card dumps. + Connect via USB + Connect via Bluetooth + No NFC files found + Up + Import Selected (%1$d) + Import Keys + Importing %1$s + %1$d of %2$d + %1$d bytes diff --git a/app/src/commonMain/kotlin/com/codebutler/farebot/persist/CardKeysPersister.kt b/app/src/commonMain/kotlin/com/codebutler/farebot/persist/CardKeysPersister.kt index fae3d7945..d3114c9b1 100644 --- a/app/src/commonMain/kotlin/com/codebutler/farebot/persist/CardKeysPersister.kt +++ b/app/src/commonMain/kotlin/com/codebutler/farebot/persist/CardKeysPersister.kt @@ -10,4 +10,10 @@ interface CardKeysPersister { fun insert(savedKey: SavedKey): Long fun delete(savedKey: SavedKey) + + fun getGlobalKeys(): List + + fun insertGlobalKeys(keys: List, source: String) + + fun deleteAllGlobalKeys() } diff --git a/app/src/commonMain/kotlin/com/codebutler/farebot/persist/db/DbCardKeysPersister.kt b/app/src/commonMain/kotlin/com/codebutler/farebot/persist/db/DbCardKeysPersister.kt index 36995f138..de81d5c9d 100644 --- a/app/src/commonMain/kotlin/com/codebutler/farebot/persist/db/DbCardKeysPersister.kt +++ b/app/src/commonMain/kotlin/com/codebutler/farebot/persist/db/DbCardKeysPersister.kt @@ -3,6 +3,7 @@ package com.codebutler.farebot.persist.db import com.codebutler.farebot.card.CardType import com.codebutler.farebot.persist.CardKeysPersister import com.codebutler.farebot.persist.db.model.SavedKey +import kotlin.time.Clock import kotlin.time.Instant class DbCardKeysPersister( @@ -37,6 +38,27 @@ class DbCardKeysPersister( override fun delete(savedKey: SavedKey) { db.savedKeyQueries.deleteById(savedKey.id) } + + override fun getGlobalKeys(): List = + db.savedKeyQueries + .selectAllGlobalKeys() + .executeAsList() + .map { hexToBytes(it.key_data) } + + override fun insertGlobalKeys(keys: List, source: String) { + val now = Clock.System.now().toEpochMilliseconds() + keys.forEach { key -> + db.savedKeyQueries.insertGlobalKey( + key_data = bytesToHex(key), + source = source, + created_at = now, + ) + } + } + + override fun deleteAllGlobalKeys() { + db.savedKeyQueries.deleteAllGlobalKeys() + } } private fun Keys.toSavedKey() = @@ -47,3 +69,9 @@ private fun Keys.toSavedKey() = keyData = key_data, createdAt = Instant.fromEpochMilliseconds(created_at), ) + +@OptIn(ExperimentalStdlibApi::class) +private fun bytesToHex(bytes: ByteArray): String = bytes.toHexString() + +@OptIn(ExperimentalStdlibApi::class) +private fun hexToBytes(hex: String): ByteArray = hex.hexToByteArray() diff --git a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/App.kt b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/App.kt index 5825be317..c85399b99 100644 --- a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/App.kt +++ b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/App.kt @@ -31,6 +31,7 @@ import com.codebutler.farebot.shared.ui.screen.AdvancedTab import com.codebutler.farebot.shared.ui.screen.CardAdvancedScreen import com.codebutler.farebot.shared.ui.screen.CardAdvancedUiState import com.codebutler.farebot.shared.ui.screen.CardScreen +import com.codebutler.farebot.shared.ui.screen.FlipperScreen import com.codebutler.farebot.shared.ui.screen.CardsMapMarker import com.codebutler.farebot.shared.ui.screen.HomeScreen import com.codebutler.farebot.shared.ui.screen.KeysScreen @@ -218,6 +219,7 @@ fun FareBotApp( } else { null }, + onNavigateToFlipper = { navController.navigate(Screen.Flipper.route) }, onOpenAbout = { platformActions.openUrl("https://codebutler.github.io/farebot") }, onOpenNfcSettings = platformActions.openNfcSettings, onToggleShowAllScans = { historyViewModel.toggleShowAllScans() }, @@ -280,6 +282,24 @@ fun FareBotApp( ) } + composable(Screen.Flipper.route) { + val viewModel = graphViewModel { flipperViewModel } + val flipperUiState by viewModel.uiState.collectAsState() + + FlipperScreen( + uiState = flipperUiState, + onConnectUsb = { viewModel.connectUsb() }, + onConnectBle = { viewModel.connectBle() }, + onDisconnect = { viewModel.disconnect() }, + onNavigateToDirectory = { path -> viewModel.navigateToDirectory(path) }, + onNavigateUp = { viewModel.navigateUp() }, + onToggleSelection = { path -> viewModel.toggleFileSelection(path) }, + onImportSelected = { viewModel.importSelectedFiles() }, + onImportKeys = { viewModel.importKeyDictionary() }, + onBack = { navController.popBackStack() }, + ) + } + composable(Screen.Keys.route) { val viewModel = graphViewModel { keysViewModel } val uiState by viewModel.uiState.collectAsState() diff --git a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/di/AppGraph.kt b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/di/AppGraph.kt index 7f01bc0b7..0bf2bd5c2 100644 --- a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/di/AppGraph.kt +++ b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/di/AppGraph.kt @@ -11,6 +11,8 @@ import com.codebutler.farebot.shared.serialize.CardImporter import com.codebutler.farebot.shared.transit.TransitFactoryRegistry import com.codebutler.farebot.shared.viewmodel.AddKeyViewModel import com.codebutler.farebot.shared.viewmodel.CardViewModel +import com.codebutler.farebot.flipper.FlipperTransportFactory +import com.codebutler.farebot.shared.viewmodel.FlipperViewModel import com.codebutler.farebot.shared.viewmodel.HistoryViewModel import com.codebutler.farebot.shared.viewmodel.HomeViewModel import com.codebutler.farebot.shared.viewmodel.KeysViewModel @@ -27,10 +29,12 @@ interface AppGraph { val cardKeysPersister: CardKeysPersister val transitFactoryRegistry: TransitFactoryRegistry val cardScanner: CardScanner + val flipperTransportFactory: FlipperTransportFactory val homeViewModel: HomeViewModel val cardViewModel: CardViewModel val historyViewModel: HistoryViewModel val keysViewModel: KeysViewModel val addKeyViewModel: AddKeyViewModel + val flipperViewModel: FlipperViewModel } diff --git a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/serialize/CardImporter.kt b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/serialize/CardImporter.kt index 4c470e950..4892ec7f5 100644 --- a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/serialize/CardImporter.kt +++ b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/serialize/CardImporter.kt @@ -23,6 +23,7 @@ package com.codebutler.farebot.shared.serialize import com.codebutler.farebot.card.RawCard +import com.codebutler.farebot.card.classic.key.ClassicCardKeys import com.codebutler.farebot.card.classic.raw.RawClassicBlock import com.codebutler.farebot.card.classic.raw.RawClassicCard import com.codebutler.farebot.card.classic.raw.RawClassicSector @@ -48,6 +49,7 @@ sealed class ImportResult { val cards: List>, val format: ImportFormat, val metadata: ImportMetadata? = null, + val classicKeys: ClassicCardKeys? = null, ) : ImportResult() /** @@ -301,12 +303,16 @@ class CardImporter( } private fun importFromFlipper(data: String): ImportResult { - val rawCard = + val result = FlipperNfcParser.parse(data) ?: return ImportResult.Error( "Failed to parse Flipper NFC dump. Unsupported card type or malformed file.", ) - return ImportResult.Success(listOf(rawCard), ImportFormat.FLIPPER_NFC) + return ImportResult.Success( + listOf(result.rawCard), + ImportFormat.FLIPPER_NFC, + classicKeys = result.classicKeys, + ) } companion object { diff --git a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/serialize/FlipperNfcParser.kt b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/serialize/FlipperNfcParser.kt index 55b5bdafc..3fd24278d 100644 --- a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/serialize/FlipperNfcParser.kt +++ b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/serialize/FlipperNfcParser.kt @@ -22,7 +22,10 @@ package com.codebutler.farebot.shared.serialize +import com.codebutler.farebot.card.CardType import com.codebutler.farebot.card.RawCard +import com.codebutler.farebot.card.classic.key.ClassicCardKeys +import com.codebutler.farebot.card.classic.key.ClassicSectorKey import com.codebutler.farebot.card.classic.raw.RawClassicBlock import com.codebutler.farebot.card.classic.raw.RawClassicCard import com.codebutler.farebot.card.classic.raw.RawClassicSector @@ -41,10 +44,15 @@ import com.codebutler.farebot.card.ultralight.UltralightPage import com.codebutler.farebot.card.ultralight.raw.RawUltralightCard import kotlin.time.Clock +data class FlipperParseResult( + val rawCard: RawCard<*>, + val classicKeys: ClassicCardKeys? = null, +) + object FlipperNfcParser { fun isFlipperFormat(data: String): Boolean = data.trimStart().startsWith("Filetype: Flipper NFC device") - fun parse(data: String): RawCard<*>? { + fun parse(data: String): FlipperParseResult? { val lines = data.lines() val headers = parseHeaders(lines) @@ -52,9 +60,9 @@ object FlipperNfcParser { return when (deviceType) { "Mifare Classic" -> parseClassic(headers, lines) - "NTAG/Ultralight" -> parseUltralight(headers, lines) - "Mifare DESFire" -> parseDesfire(headers, lines) - "FeliCa" -> parseFelica(headers, lines) + "NTAG/Ultralight" -> parseUltralight(headers, lines)?.let { FlipperParseResult(it) } + "Mifare DESFire" -> parseDesfire(headers, lines)?.let { FlipperParseResult(it) } + "FeliCa" -> parseFelica(headers, lines)?.let { FlipperParseResult(it) } else -> null } } @@ -396,7 +404,7 @@ object FlipperNfcParser { private fun parseClassic( headers: Map, lines: List, - ): RawClassicCard? { + ): FlipperParseResult? { val tagId = parseTagId(headers) ?: return null val classicType = headers["Mifare Classic type"] val totalSectors = @@ -416,12 +424,14 @@ object FlipperNfcParser { blockDataMap[blockIndex] = blockHex } - // Group blocks into sectors + // Group blocks into sectors and extract keys from sector trailers val sectors = mutableListOf() + val sectorKeys = mutableListOf() var currentBlock = 0 for (sectorIndex in 0 until totalSectors) { val blocksPerSector = if (sectorIndex < 32) 4 else 16 val sectorBlockIndices = (currentBlock until currentBlock + blocksPerSector) + val trailerBlockIndex = currentBlock + blocksPerSector - 1 // Check if ALL blocks in this sector are unread val allUnread = @@ -432,6 +442,7 @@ object FlipperNfcParser { if (allUnread) { sectors.add(RawClassicSector.createUnauthorized(sectorIndex)) + sectorKeys.add(null) } else { val blocks = sectorBlockIndices.map { blockIdx -> @@ -440,12 +451,39 @@ object FlipperNfcParser { RawClassicBlock.create(blockIdx, data) } sectors.add(RawClassicSector.createData(sectorIndex, blocks)) + + // Extract keys from sector trailer (last block of sector) + // Trailer format: [Key A: 6 bytes] [Access Bits: 4 bytes] [Key B: 6 bytes] + val trailerHex = blockDataMap[trailerBlockIndex] + if (trailerHex != null && !isAllUnread(trailerHex)) { + val trailerData = parseHexBytes(trailerHex) + if (trailerData.size >= 16) { + val keyA = trailerData.copyOfRange(0, 6) + val keyB = trailerData.copyOfRange(10, 16) + sectorKeys.add(ClassicSectorKey.create(keyA, keyB)) + } else { + sectorKeys.add(null) + } + } else { + sectorKeys.add(null) + } } currentBlock += blocksPerSector } - return RawClassicCard.create(tagId, Clock.System.now(), sectors) + val rawCard = RawClassicCard.create(tagId, Clock.System.now(), sectors) + + // Build ClassicCardKeys if any keys were extracted + val classicKeys = + if (sectorKeys.any { it != null }) { + val filledKeys = sectorKeys.map { it ?: ClassicSectorKey.create(ByteArray(6), ByteArray(6)) } + ClassicCardKeys(CardType.MifareClassic, filledKeys) + } else { + null + } + + return FlipperParseResult(rawCard, classicKeys) } // --- Ultralight parsing --- diff --git a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/ui/navigation/Screen.kt b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/ui/navigation/Screen.kt index b9dc37302..7c4fc6a82 100644 --- a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/ui/navigation/Screen.kt +++ b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/ui/navigation/Screen.kt @@ -52,4 +52,6 @@ sealed class Screen( data object TripMap : Screen("trip_map/{tripKey}") { fun createRoute(tripKey: String): String = "trip_map/$tripKey" } + + data object Flipper : Screen("flipper") } diff --git a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/ui/screen/FlipperScreen.kt b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/ui/screen/FlipperScreen.kt new file mode 100644 index 000000000..6ff9492f8 --- /dev/null +++ b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/ui/screen/FlipperScreen.kt @@ -0,0 +1,374 @@ +package com.codebutler.farebot.shared.ui.screen + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Bluetooth +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.automirrored.filled.InsertDriveFile +import androidx.compose.material.icons.filled.Usb +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import farebot.app.generated.resources.Res +import farebot.app.generated.resources.back +import farebot.app.generated.resources.flipper_bytes +import farebot.app.generated.resources.flipper_connect_ble +import farebot.app.generated.resources.flipper_connect_prompt +import farebot.app.generated.resources.flipper_connect_usb +import farebot.app.generated.resources.flipper_connecting_message +import farebot.app.generated.resources.flipper_disconnect +import farebot.app.generated.resources.flipper_import_keys +import farebot.app.generated.resources.flipper_import_progress +import farebot.app.generated.resources.flipper_import_selected +import farebot.app.generated.resources.flipper_importing +import farebot.app.generated.resources.flipper_no_files +import farebot.app.generated.resources.flipper_up +import farebot.app.generated.resources.flipper_zero +import org.jetbrains.compose.resources.stringResource + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FlipperScreen( + uiState: FlipperUiState, + onConnectUsb: () -> Unit, + onConnectBle: () -> Unit, + onDisconnect: () -> Unit, + onNavigateToDirectory: (String) -> Unit, + onNavigateUp: () -> Unit, + onToggleSelection: (String) -> Unit, + onImportSelected: () -> Unit, + onImportKeys: () -> Unit, + onBack: () -> Unit, +) { + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + when (uiState.connectionState) { + FlipperConnectionState.Connected -> + uiState.deviceInfo["hardware.name"] ?: stringResource(Res.string.flipper_zero) + FlipperConnectionState.Connecting -> stringResource(Res.string.flipper_connecting_message) + FlipperConnectionState.Disconnected -> stringResource(Res.string.flipper_zero) + }, + ) + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(Res.string.back)) + } + }, + actions = { + if (uiState.connectionState == FlipperConnectionState.Connected) { + TextButton(onClick = onDisconnect) { + Text(stringResource(Res.string.flipper_disconnect)) + } + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + ), + ) + }, + ) { padding -> + Box(modifier = Modifier.fillMaxSize().padding(padding)) { + when (uiState.connectionState) { + FlipperConnectionState.Disconnected -> { + DisconnectedContent( + error = uiState.error, + onConnectUsb = onConnectUsb, + onConnectBle = onConnectBle, + ) + } + + FlipperConnectionState.Connecting -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator() + Spacer(modifier = Modifier.height(16.dp)) + Text(stringResource(Res.string.flipper_connecting_message)) + } + } + } + + FlipperConnectionState.Connected -> { + ConnectedContent( + uiState = uiState, + onNavigateToDirectory = onNavigateToDirectory, + onNavigateUp = onNavigateUp, + onToggleSelection = onToggleSelection, + onImportSelected = onImportSelected, + onImportKeys = onImportKeys, + ) + } + } + + if (uiState.importProgress != null) { + ImportProgressOverlay(uiState.importProgress) + } + } + } +} + +@Composable +private fun DisconnectedContent( + error: String?, + onConnectUsb: () -> Unit, + onConnectBle: () -> Unit, +) { + Column( + modifier = Modifier.fillMaxSize().padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(48.dp)) + + Text( + text = stringResource(Res.string.flipper_connect_prompt), + style = MaterialTheme.typography.bodyLarge, + ) + + Spacer(modifier = Modifier.height(32.dp)) + + Button( + onClick = onConnectUsb, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Default.Usb, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(Res.string.flipper_connect_usb)) + } + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedButton( + onClick = onConnectBle, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Default.Bluetooth, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(Res.string.flipper_connect_ble)) + } + + if (error != null) { + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = error, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + ) + } + } +} + +@Composable +private fun ConnectedContent( + uiState: FlipperUiState, + onNavigateToDirectory: (String) -> Unit, + onNavigateUp: () -> Unit, + onToggleSelection: (String) -> Unit, + onImportSelected: () -> Unit, + onImportKeys: () -> Unit, +) { + Column(modifier = Modifier.fillMaxSize()) { + // Breadcrumb path bar + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (uiState.currentPath != "/ext/nfc") { + TextButton(onClick = onNavigateUp) { + Text(stringResource(Res.string.flipper_up)) + } + Spacer(modifier = Modifier.width(8.dp)) + } + Text( + text = uiState.currentPath, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + HorizontalDivider() + + if (uiState.error != null) { + Text( + text = uiState.error, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(16.dp), + ) + } + + if (uiState.isLoading) { + Box( + modifier = Modifier.weight(1f).fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else if (uiState.files.isEmpty()) { + Box( + modifier = Modifier.weight(1f).fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(Res.string.flipper_no_files), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + LazyColumn(modifier = Modifier.weight(1f)) { + items(uiState.files) { file -> + FileListItem( + file = file, + isSelected = uiState.selectedFiles.contains(file.path), + onTap = { + if (file.isDirectory) { + onNavigateToDirectory(file.path) + } else { + onToggleSelection(file.path) + } + }, + onToggleSelection = { onToggleSelection(file.path) }, + ) + HorizontalDivider() + } + } + } + + // Bottom action bar + HorizontalDivider() + Row( + modifier = Modifier.fillMaxWidth().padding(12.dp), + ) { + Button( + onClick = onImportSelected, + enabled = uiState.selectedFiles.isNotEmpty(), + modifier = Modifier.weight(1f), + ) { + Text(stringResource(Res.string.flipper_import_selected, uiState.selectedFiles.size)) + } + Spacer(modifier = Modifier.width(8.dp)) + OutlinedButton( + onClick = onImportKeys, + ) { + Text(stringResource(Res.string.flipper_import_keys)) + } + } + } +} + +@Composable +private fun FileListItem( + file: FlipperFileItem, + isSelected: Boolean, + onTap: () -> Unit, + onToggleSelection: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onTap) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = if (file.isDirectory) Icons.Default.Folder else Icons.AutoMirrored.Filled.InsertDriveFile, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = if (file.isDirectory) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = file.name, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (!file.isDirectory && file.size > 0) { + Text( + text = stringResource(Res.string.flipper_bytes, file.size), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + if (!file.isDirectory) { + Checkbox( + checked = isSelected, + onCheckedChange = { onToggleSelection() }, + ) + } + } +} + +@Composable +private fun ImportProgressOverlay(progress: ImportProgress) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(32.dp), + ) { + Text( + text = stringResource(Res.string.flipper_importing, progress.currentFile), + style = MaterialTheme.typography.bodyLarge, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(Res.string.flipper_import_progress, progress.currentIndex, progress.totalFiles), + style = MaterialTheme.typography.bodySmall, + ) + Spacer(modifier = Modifier.height(16.dp)) + LinearProgressIndicator( + progress = { progress.currentIndex.toFloat() / progress.totalFiles }, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} diff --git a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/ui/screen/FlipperUiState.kt b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/ui/screen/FlipperUiState.kt new file mode 100644 index 000000000..9f073b895 --- /dev/null +++ b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/ui/screen/FlipperUiState.kt @@ -0,0 +1,31 @@ +package com.codebutler.farebot.shared.ui.screen + +data class FlipperUiState( + val connectionState: FlipperConnectionState = FlipperConnectionState.Disconnected, + val deviceInfo: Map = emptyMap(), + val currentPath: String = "/ext/nfc", + val files: List = emptyList(), + val isLoading: Boolean = false, + val selectedFiles: Set = emptySet(), + val error: String? = null, + val importProgress: ImportProgress? = null, +) + +enum class FlipperConnectionState { + Disconnected, + Connecting, + Connected, +} + +data class FlipperFileItem( + val name: String, + val isDirectory: Boolean, + val size: Long, + val path: String, +) + +data class ImportProgress( + val currentFile: String, + val currentIndex: Int, + val totalFiles: Int, +) diff --git a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/ui/screen/HomeScreen.kt b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/ui/screen/HomeScreen.kt index b82dfd20a..25b6f1af4 100644 --- a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/ui/screen/HomeScreen.kt +++ b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/ui/screen/HomeScreen.kt @@ -92,6 +92,7 @@ import farebot.app.generated.resources.app_name import farebot.app.generated.resources.cancel import farebot.app.generated.resources.delete import farebot.app.generated.resources.delete_selected_cards +import farebot.app.generated.resources.flipper_zero import farebot.app.generated.resources.ic_cards_stack import farebot.app.generated.resources.ic_launcher import farebot.app.generated.resources.import_file @@ -142,6 +143,7 @@ fun HomeScreen( onKeysRequiredTap: () -> Unit, onStatusChipTap: (String) -> Unit = {}, onNavigateToKeys: (() -> Unit)?, + onNavigateToFlipper: (() -> Unit)? = null, onOpenAbout: () -> Unit, onOpenNfcSettings: (() -> Unit)? = null, onAddAllSamples: (() -> Unit)? = null, @@ -368,6 +370,15 @@ fun HomeScreen( }, ) } + if (onNavigateToFlipper != null) { + DropdownMenuItem( + text = { Text(stringResource(Res.string.flipper_zero)) }, + onClick = { + menuExpanded = false + onNavigateToFlipper() + }, + ) + } DropdownMenuItem( text = { Text(stringResource(Res.string.about)) }, onClick = { @@ -549,6 +560,15 @@ fun HomeScreen( }, ) } + if (onNavigateToFlipper != null) { + DropdownMenuItem( + text = { Text(stringResource(Res.string.flipper_zero)) }, + onClick = { + menuExpanded = false + onNavigateToFlipper() + }, + ) + } DropdownMenuItem( text = { Text(stringResource(Res.string.about)) }, onClick = { diff --git a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/viewmodel/FlipperViewModel.kt b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/viewmodel/FlipperViewModel.kt new file mode 100644 index 000000000..80f46f9aa --- /dev/null +++ b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/viewmodel/FlipperViewModel.kt @@ -0,0 +1,250 @@ +package com.codebutler.farebot.shared.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.codebutler.farebot.base.util.hex +import com.codebutler.farebot.card.serialize.CardSerializer +import com.codebutler.farebot.flipper.FlipperRpcClient +import com.codebutler.farebot.flipper.FlipperKeyDictParser +import com.codebutler.farebot.flipper.FlipperTransport +import com.codebutler.farebot.flipper.FlipperTransportFactory +import com.codebutler.farebot.persist.CardKeysPersister +import com.codebutler.farebot.persist.CardPersister +import com.codebutler.farebot.persist.db.model.SavedCard +import com.codebutler.farebot.shared.serialize.CardImporter +import com.codebutler.farebot.shared.serialize.ImportResult +import com.codebutler.farebot.shared.ui.screen.FlipperConnectionState +import com.codebutler.farebot.shared.ui.screen.FlipperFileItem +import com.codebutler.farebot.shared.ui.screen.FlipperUiState +import com.codebutler.farebot.shared.ui.screen.ImportProgress +import dev.zacsweers.metro.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +@Inject +class FlipperViewModel( + private val cardImporter: CardImporter, + private val cardPersister: CardPersister, + private val cardKeysPersister: CardKeysPersister, + private val cardSerializer: CardSerializer, + private val transportFactory: FlipperTransportFactory, +) : ViewModel() { + private val _uiState = MutableStateFlow(FlipperUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var rpcClient: FlipperRpcClient? = null + private var transport: FlipperTransport? = null + + fun connectUsb() { + viewModelScope.launch { + val transport = transportFactory.createUsbTransport() + if (transport != null) { + connect(transport) + } else { + _uiState.value = _uiState.value.copy( + error = "USB transport not available on this platform", + ) + } + } + } + + fun connectBle() { + viewModelScope.launch { + val transport = transportFactory.createBleTransport() + if (transport != null) { + connect(transport) + } else { + _uiState.value = _uiState.value.copy( + error = "Bluetooth transport not available on this platform", + ) + } + } + } + + fun connect(transport: FlipperTransport) { + this.transport = transport + val client = FlipperRpcClient(transport) + this.rpcClient = client + + _uiState.value = _uiState.value.copy( + connectionState = FlipperConnectionState.Connecting, + error = null, + ) + + viewModelScope.launch { + try { + client.connect() + + val deviceInfo = mutableMapOf() + try { + val info = client.getDeviceInfo() + deviceInfo.putAll(info) + } catch (e: Exception) { + println("[FlipperViewModel] Failed to get device info: ${e.message}") + } + + _uiState.value = _uiState.value.copy( + connectionState = FlipperConnectionState.Connected, + deviceInfo = deviceInfo, + ) + + navigateToDirectory("/ext/nfc") + } catch (e: Exception) { + _uiState.value = _uiState.value.copy( + connectionState = FlipperConnectionState.Disconnected, + error = "Connection failed: ${e.message}", + ) + } + } + } + + fun disconnect() { + viewModelScope.launch { + try { + transport?.close() + } catch (e: Exception) { + println("[FlipperViewModel] Error closing transport: ${e.message}") + } + rpcClient = null + transport = null + _uiState.value = FlipperUiState() + } + } + + fun navigateToDirectory(path: String) { + val client = rpcClient ?: return + _uiState.value = _uiState.value.copy(isLoading = true, error = null) + + viewModelScope.launch { + try { + val entries = client.listDirectory(path) + val files = entries.map { entry -> + FlipperFileItem( + name = entry.name, + isDirectory = entry.isDirectory, + size = entry.size, + path = "$path/${entry.name}", + ) + }.sortedWith(compareByDescending { it.isDirectory }.thenBy { it.name }) + + _uiState.value = _uiState.value.copy( + currentPath = path, + files = files, + isLoading = false, + selectedFiles = emptySet(), + ) + } catch (e: Exception) { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = "Failed to list directory: ${e.message}", + ) + } + } + } + + fun navigateUp() { + val current = _uiState.value.currentPath + val parent = current.substringBeforeLast('/', "/ext") + if (parent.isNotEmpty() && parent != current) { + navigateToDirectory(parent) + } + } + + fun toggleFileSelection(path: String) { + val current = _uiState.value.selectedFiles + val newSelected = if (current.contains(path)) { + current - path + } else { + current + path + } + _uiState.value = _uiState.value.copy(selectedFiles = newSelected) + } + + fun importSelectedFiles() { + val client = rpcClient ?: return + val selectedPaths = _uiState.value.selectedFiles.toList() + if (selectedPaths.isEmpty()) return + + viewModelScope.launch { + for ((index, path) in selectedPaths.withIndex()) { + val fileName = path.substringAfterLast('/') + _uiState.value = _uiState.value.copy( + importProgress = ImportProgress( + currentFile = fileName, + currentIndex = index + 1, + totalFiles = selectedPaths.size, + ), + ) + + try { + val fileData = client.readFile(path) + val content = fileData.decodeToString() + val result = cardImporter.importCards(content) + + if (result is ImportResult.Success) { + for (rawCard in result.cards) { + cardPersister.insertCard( + SavedCard( + type = rawCard.cardType(), + serial = rawCard.tagId().hex(), + data = cardSerializer.serialize(rawCard), + ), + ) + } + if (result.classicKeys != null) { + val keys = result.classicKeys.keys.flatMap { sectorKey -> + listOfNotNull( + sectorKey.keyA.takeIf { it.any { b -> b != 0.toByte() } }, + sectorKey.keyB.takeIf { it.any { b -> b != 0.toByte() } }, + ) + } + if (keys.isNotEmpty()) { + cardKeysPersister.insertGlobalKeys(keys, "flipper_nfc_dump") + } + } + } + } catch (e: Exception) { + println("[FlipperViewModel] Failed to import $path: ${e.message}") + } + } + + _uiState.value = _uiState.value.copy( + importProgress = null, + selectedFiles = emptySet(), + ) + } + } + + fun importKeyDictionary() { + val client = rpcClient ?: return + + viewModelScope.launch { + _uiState.value = _uiState.value.copy( + importProgress = ImportProgress( + currentFile = "mf_classic_dict_user.nfc", + currentIndex = 1, + totalFiles = 1, + ), + ) + + try { + val dictPath = "/ext/nfc/assets/mf_classic_dict_user.nfc" + val data = client.readFile(dictPath) + val content = data.decodeToString() + val keys = FlipperKeyDictParser.parse(content) + + if (keys.isNotEmpty()) { + cardKeysPersister.insertGlobalKeys(keys, "flipper_user_dict") + } + } catch (e: Exception) { + _uiState.value = _uiState.value.copy( + error = "Failed to import key dictionary: ${e.message}", + ) + } + + _uiState.value = _uiState.value.copy(importProgress = null) + } + } +} diff --git a/app/src/commonMain/sqldelight/com/codebutler/farebot/persist/db/SavedKey.sq b/app/src/commonMain/sqldelight/com/codebutler/farebot/persist/db/SavedKey.sq index 101c22a55..2a8d98236 100644 --- a/app/src/commonMain/sqldelight/com/codebutler/farebot/persist/db/SavedKey.sq +++ b/app/src/commonMain/sqldelight/com/codebutler/farebot/persist/db/SavedKey.sq @@ -17,3 +17,22 @@ INSERT INTO keys (card_id, card_type, key_data, created_at) VALUES (?, ?, ?, ?); deleteById: DELETE FROM keys WHERE id = ?; + +CREATE TABLE IF NOT EXISTS global_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + key_data TEXT NOT NULL, + source TEXT NOT NULL, + created_at INTEGER NOT NULL +); + +insertGlobalKey: +INSERT INTO global_keys (key_data, source, created_at) VALUES (?, ?, ?); + +selectAllGlobalKeys: +SELECT * FROM global_keys; + +deleteGlobalKey: +DELETE FROM global_keys WHERE id = ?; + +deleteAllGlobalKeys: +DELETE FROM global_keys; diff --git a/app/src/commonTest/kotlin/com/codebutler/farebot/test/FlipperIntegrationTest.kt b/app/src/commonTest/kotlin/com/codebutler/farebot/test/FlipperIntegrationTest.kt index 7798c4f8f..fc2ff64d5 100644 --- a/app/src/commonTest/kotlin/com/codebutler/farebot/test/FlipperIntegrationTest.kt +++ b/app/src/commonTest/kotlin/com/codebutler/farebot/test/FlipperIntegrationTest.kt @@ -57,10 +57,10 @@ class FlipperIntegrationTest { fun testOrcaFromFlipper() = runTest { val data = loadFlipperDump("ORCA.nfc") - val rawCard = FlipperNfcParser.parse(data) - assertNotNull(rawCard, "Failed to parse ORCA Flipper dump") + val parseResult = FlipperNfcParser.parse(data) + assertNotNull(parseResult, "Failed to parse ORCA Flipper dump") - val card = rawCard.parse() + val card = parseResult.rawCard.parse() assertTrue(card is DesfireCard, "Expected DesfireCard, got ${card::class.simpleName}") val factory = OrcaTransitFactory() @@ -92,10 +92,10 @@ class FlipperIntegrationTest { fun testClipperFromFlipper() = runTest { val data = loadFlipperDump("Clipper.nfc") - val rawCard = FlipperNfcParser.parse(data) - assertNotNull(rawCard, "Failed to parse Clipper Flipper dump") + val parseResult = FlipperNfcParser.parse(data) + assertNotNull(parseResult, "Failed to parse Clipper Flipper dump") - val card = rawCard.parse() + val card = parseResult.rawCard.parse() assertTrue(card is DesfireCard, "Expected DesfireCard, got ${card::class.simpleName}") val factory = ClipperTransitFactory() @@ -267,10 +267,10 @@ class FlipperIntegrationTest { fun testSuicaFromFlipper() = runTest { val data = loadFlipperDump("Suica.nfc") - val rawCard = FlipperNfcParser.parse(data) - assertNotNull(rawCard, "Failed to parse Suica Flipper dump") + val parseResult = FlipperNfcParser.parse(data) + assertNotNull(parseResult, "Failed to parse Suica Flipper dump") - val card = rawCard.parse() + val card = parseResult.rawCard.parse() assertTrue(card is FelicaCard, "Expected FelicaCard, got ${card::class.simpleName}") val factory = SuicaTransitFactory() @@ -504,10 +504,10 @@ class FlipperIntegrationTest { fun testPasmoFromFlipper() = runTest { val data = loadFlipperDump("PASMO.nfc") - val rawCard = FlipperNfcParser.parse(data) - assertNotNull(rawCard, "Failed to parse PASMO Flipper dump") + val parseResult = FlipperNfcParser.parse(data) + assertNotNull(parseResult, "Failed to parse PASMO Flipper dump") - val card = rawCard.parse() + val card = parseResult.rawCard.parse() assertTrue(card is FelicaCard, "Expected FelicaCard, got ${card::class.simpleName}") val factory = SuicaTransitFactory() @@ -648,10 +648,10 @@ class FlipperIntegrationTest { fun testIcocaFromFlipper() = runTest { val data = loadFlipperDump("ICOCA.nfc") - val rawCard = FlipperNfcParser.parse(data) - assertNotNull(rawCard, "Failed to parse ICOCA Flipper dump") + val parseResult = FlipperNfcParser.parse(data) + assertNotNull(parseResult, "Failed to parse ICOCA Flipper dump") - val card = rawCard.parse() + val card = parseResult.rawCard.parse() assertTrue(card is FelicaCard, "Expected FelicaCard, got ${card::class.simpleName}") val factory = SuicaTransitFactory() diff --git a/app/src/commonTest/kotlin/com/codebutler/farebot/test/FlipperNfcParserTest.kt b/app/src/commonTest/kotlin/com/codebutler/farebot/test/FlipperNfcParserTest.kt index aa1b9244d..24cb9cd39 100644 --- a/app/src/commonTest/kotlin/com/codebutler/farebot/test/FlipperNfcParserTest.kt +++ b/app/src/commonTest/kotlin/com/codebutler/farebot/test/FlipperNfcParserTest.kt @@ -29,6 +29,7 @@ import com.codebutler.farebot.card.felica.raw.RawFelicaCard import com.codebutler.farebot.card.ultralight.raw.RawUltralightCard import com.codebutler.farebot.shared.serialize.FlipperNfcParser import kotlin.test.Test +import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertIs @@ -79,9 +80,9 @@ class FlipperNfcParserTest { } } - val result = FlipperNfcParser.parse(dump) - assertNotNull(result) - assertIs(result) + val parseResult = FlipperNfcParser.parse(dump) + assertNotNull(parseResult) + val result = assertIs(parseResult.rawCard) // Verify UID assertEquals(0xBA.toByte(), result.tagId()[0]) @@ -123,9 +124,9 @@ class FlipperNfcParserTest { } } - val result = FlipperNfcParser.parse(dump) - assertNotNull(result) - assertIs(result) + val parseResult = FlipperNfcParser.parse(dump) + assertNotNull(parseResult) + val result = assertIs(parseResult.rawCard) val sectors = result.sectors() assertEquals(40, sectors.size) @@ -162,9 +163,9 @@ class FlipperNfcParserTest { } } - val result = FlipperNfcParser.parse(dump) - assertNotNull(result) - assertIs(result) + val parseResult = FlipperNfcParser.parse(dump) + assertNotNull(parseResult) + val result = assertIs(parseResult.rawCard) val sectors = result.sectors() assertEquals(16, sectors.size) @@ -201,9 +202,9 @@ class FlipperNfcParserTest { } } - val result = FlipperNfcParser.parse(dump) - assertNotNull(result) - assertIs(result) + val parseResult = FlipperNfcParser.parse(dump) + assertNotNull(parseResult) + val result = assertIs(parseResult.rawCard) val sectors = result.sectors() // Sector 0 has readable blocks, so it should be data @@ -218,6 +219,76 @@ class FlipperNfcParserTest { assertEquals(0x00.toByte(), block0.data[4]) // was ?? } + @Test + fun testParseClassicExtractsKeys() { + val dump = + buildString { + appendLine("Filetype: Flipper NFC device") + appendLine("Version: 4") + appendLine("Device type: Mifare Classic") + appendLine("UID: 01 02 03 04") + appendLine("ATQA: 00 02") + appendLine("SAK: 08") + appendLine("Mifare Classic type: 1K") + appendLine("Data format version: 2") + // Sector 0: known keys + appendLine("Block 0: 01 02 03 04 B9 18 02 00 46 44 53 37 30 56 30 31") + appendLine("Block 1: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00") + appendLine("Block 2: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00") + // Sector trailer: Key A = A0A1A2A3A4A5, Access = FF078069, Key B = FFFFFFFFFFFF + appendLine("Block 3: A0 A1 A2 A3 A4 A5 FF 07 80 69 FF FF FF FF FF FF") + // Sector 1: different keys + appendLine("Block 4: 11 22 33 44 55 66 77 88 99 AA BB CC DD EE FF 00") + appendLine("Block 5: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00") + appendLine("Block 6: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00") + // Sector trailer: Key A = D3F7D3F7D3F7, Access = FF078069, Key B = 000000000000 + appendLine("Block 7: D3 F7 D3 F7 D3 F7 FF 07 80 69 00 00 00 00 00 00") + // Sectors 2-15: unread + for (block in 8 until 64) { + appendLine("Block $block: ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ??") + } + } + + val parseResult = FlipperNfcParser.parse(dump) + assertNotNull(parseResult) + assertIs(parseResult.rawCard) + + // Verify keys were extracted + val keys = parseResult.classicKeys + assertNotNull(keys) + assertEquals(16, keys.keys.size) + + // Sector 0: Key A = A0A1A2A3A4A5, Key B = FFFFFFFFFFFF + val sector0Key = keys.keyForSector(0) + assertNotNull(sector0Key) + assertContentEquals( + byteArrayOf(0xA0.toByte(), 0xA1.toByte(), 0xA2.toByte(), 0xA3.toByte(), 0xA4.toByte(), 0xA5.toByte()), + sector0Key.keyA, + ) + assertContentEquals( + byteArrayOf(0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte()), + sector0Key.keyB, + ) + + // Sector 1: Key A = D3F7D3F7D3F7, Key B = 000000000000 + val sector1Key = keys.keyForSector(1) + assertNotNull(sector1Key) + assertContentEquals( + byteArrayOf(0xD3.toByte(), 0xF7.toByte(), 0xD3.toByte(), 0xF7.toByte(), 0xD3.toByte(), 0xF7.toByte()), + sector1Key.keyA, + ) + assertContentEquals( + byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x00, 0x00), + sector1Key.keyB, + ) + + // Sector 2 (unauthorized): should have placeholder zero keys + val sector2Key = keys.keyForSector(2) + assertNotNull(sector2Key) + assertContentEquals(ByteArray(6), sector2Key.keyA) + assertContentEquals(ByteArray(6), sector2Key.keyB) + } + @Test fun testParseUltralight() { val dump = @@ -250,9 +321,9 @@ class FlipperNfcParserTest { } } - val result = FlipperNfcParser.parse(dump) - assertNotNull(result) - assertIs(result) + val parseResult = FlipperNfcParser.parse(dump) + assertNotNull(parseResult) + val result = assertIs(parseResult.rawCard) // Verify UID assertEquals(0x04.toByte(), result.tagId()[0]) @@ -265,6 +336,9 @@ class FlipperNfcParserTest { // Verify type (NTAG213 = 2) assertEquals(2, result.ultralightType) + + // Ultralight should have no classic keys + assertNull(parseResult.classicKeys) } @Test @@ -310,9 +384,9 @@ class FlipperNfcParserTest { appendLine("Application abcdef File 2 Cur: 10") } - val result = FlipperNfcParser.parse(dump) - assertNotNull(result) - assertIs(result) + val parseResult = FlipperNfcParser.parse(dump) + assertNotNull(parseResult) + val result = assertIs(parseResult.rawCard) // Verify UID assertEquals(0x04.toByte(), result.tagId()[0]) @@ -342,6 +416,9 @@ class FlipperNfcParserTest { val file2 = app.files[1] assertEquals(2, file2.fileId) assertNotNull(file2.error) + + // DESFire should have no classic keys + assertNull(parseResult.classicKeys) } @Test @@ -377,9 +454,9 @@ class FlipperNfcParserTest { ) } - val result = FlipperNfcParser.parse(dump) - assertNotNull(result) - assertIs(result) + val parseResult = FlipperNfcParser.parse(dump) + assertNotNull(parseResult) + val result = assertIs(parseResult.rawCard) // Verify UID assertEquals(0x01.toByte(), result.tagId()[0]) diff --git a/app/src/iosMain/kotlin/com/codebutler/farebot/shared/di/IosAppGraph.kt b/app/src/iosMain/kotlin/com/codebutler/farebot/shared/di/IosAppGraph.kt index 9e3065f53..09d3b7cc4 100644 --- a/app/src/iosMain/kotlin/com/codebutler/farebot/shared/di/IosAppGraph.kt +++ b/app/src/iosMain/kotlin/com/codebutler/farebot/shared/di/IosAppGraph.kt @@ -16,6 +16,8 @@ import com.codebutler.farebot.shared.platform.IosAppPreferences import com.codebutler.farebot.shared.platform.IosPlatformActions import com.codebutler.farebot.shared.platform.NoOpAnalytics import com.codebutler.farebot.shared.platform.PlatformActions +import com.codebutler.farebot.flipper.FlipperTransportFactory +import com.codebutler.farebot.flipper.IosFlipperTransportFactory import com.codebutler.farebot.shared.serialize.CardImporter import com.codebutler.farebot.shared.serialize.FareBotSerializersModule import com.codebutler.farebot.shared.serialize.KotlinxCardSerializer @@ -89,6 +91,10 @@ abstract class IosAppGraph : AppGraph { json: Json, ): CardImporter = CardImporter(cardSerializer, json) + @Provides + @SingleIn(AppScope::class) + fun provideFlipperTransportFactory(): FlipperTransportFactory = IosFlipperTransportFactory() + @Provides fun provideNullableCardScanner(scanner: CardScanner): CardScanner? = scanner } diff --git a/app/web/src/wasmJsMain/kotlin/com/codebutler/farebot/web/LocalStorageCardKeysPersister.kt b/app/web/src/wasmJsMain/kotlin/com/codebutler/farebot/web/LocalStorageCardKeysPersister.kt index 5bd7a0d15..0a13f9d95 100644 --- a/app/web/src/wasmJsMain/kotlin/com/codebutler/farebot/web/LocalStorageCardKeysPersister.kt +++ b/app/web/src/wasmJsMain/kotlin/com/codebutler/farebot/web/LocalStorageCardKeysPersister.kt @@ -52,6 +52,7 @@ class LocalStorageCardKeysPersister( ) : CardKeysPersister { private companion object { const val STORAGE_KEY = "farebot_keys" + const val GLOBAL_KEYS_STORAGE_KEY = "farebot_global_keys" } override fun getSavedKeys(): List = loadKeys() @@ -82,6 +83,29 @@ class LocalStorageCardKeysPersister( saveKeys(keys) } + @OptIn(ExperimentalStdlibApi::class) + override fun getGlobalKeys(): List { + val raw = lsGetItem(GLOBAL_KEYS_STORAGE_KEY.toJsString())?.toString() ?: return emptyList() + return try { + json.decodeFromString>(raw).map { it.hexToByteArray() } + } catch (e: Exception) { + println("[LocalStorage] Failed to load global keys: $e") + emptyList() + } + } + + @OptIn(ExperimentalStdlibApi::class) + override fun insertGlobalKeys(keys: List, source: String) { + val existing = getGlobalKeys().map { it.toHexString() }.toMutableSet() + keys.forEach { existing.add(it.toHexString()) } + val serialized = json.encodeToString>(existing.toList()) + lsSetItem(GLOBAL_KEYS_STORAGE_KEY.toJsString(), serialized.toJsString()) + } + + override fun deleteAllGlobalKeys() { + lsSetItem(GLOBAL_KEYS_STORAGE_KEY.toJsString(), "[]".toJsString()) + } + private fun loadKeys(): List { val raw = lsGetItem(STORAGE_KEY.toJsString())?.toString() ?: return emptyList() return try { diff --git a/app/web/src/wasmJsMain/kotlin/com/codebutler/farebot/web/WebAppGraph.kt b/app/web/src/wasmJsMain/kotlin/com/codebutler/farebot/web/WebAppGraph.kt index 66222a65b..1da4c5059 100644 --- a/app/web/src/wasmJsMain/kotlin/com/codebutler/farebot/web/WebAppGraph.kt +++ b/app/web/src/wasmJsMain/kotlin/com/codebutler/farebot/web/WebAppGraph.kt @@ -10,6 +10,8 @@ import com.codebutler.farebot.shared.nfc.CardScanner import com.codebutler.farebot.shared.platform.Analytics import com.codebutler.farebot.shared.platform.AppPreferences import com.codebutler.farebot.shared.platform.NoOpAnalytics +import com.codebutler.farebot.flipper.FlipperTransportFactory +import com.codebutler.farebot.flipper.WebFlipperTransportFactory import com.codebutler.farebot.shared.serialize.CardImporter import com.codebutler.farebot.shared.serialize.FareBotSerializersModule import com.codebutler.farebot.shared.serialize.KotlinxCardSerializer @@ -70,6 +72,10 @@ abstract class WebAppGraph : AppGraph { json: Json, ): CardImporter = CardImporter(cardSerializer, json) + @Provides + @SingleIn(AppScope::class) + fun provideFlipperTransportFactory(): FlipperTransportFactory = WebFlipperTransportFactory() + @Provides fun provideNullableCardScanner(scanner: CardScanner): CardScanner? = scanner } diff --git a/card/classic/src/commonMain/kotlin/com/codebutler/farebot/card/classic/ClassicCardReader.kt b/card/classic/src/commonMain/kotlin/com/codebutler/farebot/card/classic/ClassicCardReader.kt index 6adfc1209..9c88513b4 100644 --- a/card/classic/src/commonMain/kotlin/com/codebutler/farebot/card/classic/ClassicCardReader.kt +++ b/card/classic/src/commonMain/kotlin/com/codebutler/farebot/card/classic/ClassicCardReader.kt @@ -48,6 +48,7 @@ object ClassicCardReader { tagId: ByteArray, tech: ClassicTechnology, cardKeys: ClassicCardKeys?, + globalKeys: List? = null, ): RawClassicCard { val sectors = ArrayList() @@ -136,6 +137,24 @@ object ClassicCardReader { } } + // Try global dictionary keys + if (!authSuccess && !globalKeys.isNullOrEmpty()) { + for (globalKey in globalKeys) { + authSuccess = tech.authenticateSectorWithKeyA(sectorIndex, globalKey) + if (authSuccess) { + successfulKey = globalKey + isKeyA = true + break + } + authSuccess = tech.authenticateSectorWithKeyB(sectorIndex, globalKey) + if (authSuccess) { + successfulKey = globalKey + isKeyA = false + break + } + } + } + if (authSuccess && successfulKey != null) { val blocks = ArrayList() // FIXME: First read trailer block to get type of other blocks. diff --git a/card/classic/src/commonTest/kotlin/com/codebutler/farebot/card/classic/ClassicCardReaderTest.kt b/card/classic/src/commonTest/kotlin/com/codebutler/farebot/card/classic/ClassicCardReaderTest.kt index 7b7d60113..4e3a7c1aa 100644 --- a/card/classic/src/commonTest/kotlin/com/codebutler/farebot/card/classic/ClassicCardReaderTest.kt +++ b/card/classic/src/commonTest/kotlin/com/codebutler/farebot/card/classic/ClassicCardReaderTest.kt @@ -268,6 +268,33 @@ class ClassicCardReaderTest { assertEquals(RawClassicSector.TYPE_UNAUTHORIZED, sectors[2].type) } + @Test + fun testGlobalKeysUsedWhenCardKeysFail() = + runTest { + val globalKey = byteArrayOf(0xAA.toByte(), 0xBB.toByte(), 0xCC.toByte(), 0xDD.toByte(), 0xEE.toByte(), 0xFF.toByte()) + val blockData = ByteArray(16) { 0x42 } + + val tech = + MockClassicTechnology( + sectorCount = 1, + blocksPerSector = 1, + authKeyAResult = { _, key -> + // Only the global key works + key.contentEquals(globalKey) + }, + readBlockResult = { blockData }, + ) + + val result = ClassicCardReader.readCard(testTagId, tech, null, globalKeys = listOf(globalKey)) + val sectors = result.sectors() + + assertEquals(1, sectors.size) + assertEquals(RawClassicSector.TYPE_DATA, sectors[0].type) + assertTrue(sectors[0].blocks!![0].data.contentEquals(blockData)) + // Default keys should have been tried and failed, then global key succeeded + assertTrue(tech.authKeyACalls.any { it.second.contentEquals(globalKey) }) + } + @Test fun testGenericExceptionCreatesInvalidSector() = runTest { diff --git a/flipper/build.gradle.kts b/flipper/build.gradle.kts new file mode 100644 index 000000000..2f094aed0 --- /dev/null +++ b/flipper/build.gradle.kts @@ -0,0 +1,37 @@ +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.android.kotlin.multiplatform.library) + alias(libs.plugins.kotlin.serialization) +} + +kotlin { + androidLibrary { + namespace = "com.codebutler.farebot.flipper" + compileSdk = + libs.versions.compileSdk + .get() + .toInt() + minSdk = + libs.versions.minSdk + .get() + .toInt() + } + + iosX64() + iosArm64() + iosSimulatorArm64() + + sourceSets { + commonMain.dependencies { + implementation(libs.kotlinx.serialization.protobuf) + implementation(libs.kotlinx.coroutines.core) + } + commonTest.dependencies { + implementation(kotlin("test")) + implementation(libs.kotlinx.coroutines.test) + } + jvmMain.dependencies { + implementation("com.fazecast:jSerialComm:2.10.4") + } + } +} diff --git a/flipper/src/androidMain/kotlin/com/codebutler/farebot/flipper/AndroidBleSerialTransport.kt b/flipper/src/androidMain/kotlin/com/codebutler/farebot/flipper/AndroidBleSerialTransport.kt new file mode 100644 index 000000000..52b14131f --- /dev/null +++ b/flipper/src/androidMain/kotlin/com/codebutler/farebot/flipper/AndroidBleSerialTransport.kt @@ -0,0 +1,186 @@ +@file:Suppress("MissingPermission") + +package com.codebutler.farebot.flipper + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCallback +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattDescriptor +import android.bluetooth.BluetoothManager +import android.bluetooth.BluetoothProfile +import android.bluetooth.le.ScanCallback +import android.bluetooth.le.ScanFilter +import android.bluetooth.le.ScanResult +import android.bluetooth.le.ScanSettings +import android.content.Context +import android.os.ParcelUuid +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeout +import java.util.UUID +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +/** + * FlipperTransport implementation using Android BLE. + * Connects to Flipper Zero's BLE Serial service. + */ +@SuppressLint("MissingPermission") +class AndroidBleSerialTransport( + private val context: Context, + private val device: BluetoothDevice? = null, +) : FlipperTransport { + companion object { + val SERIAL_SERVICE_UUID: UUID = UUID.fromString("8fe5b3d5-2e7f-4a98-2a48-7acc60fe0000") + val SERIAL_RX_UUID: UUID = UUID.fromString("19ed82ae-ed21-4c9d-4145-228e62fe0000") + val SERIAL_TX_UUID: UUID = UUID.fromString("19ed82ae-ed21-4c9d-4145-228e63fe0000") + private val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") + private const val SCAN_TIMEOUT_MS = 15_000L + } + + private var gatt: BluetoothGatt? = null + private var rxCharacteristic: BluetoothGattCharacteristic? = null + private var txCharacteristic: BluetoothGattCharacteristic? = null + private val receiveChannel = Channel(Channel.UNLIMITED) + + override val isConnected: Boolean + get() = gatt != null + + override suspend fun connect() { + val targetDevice = device ?: scanForFlipper() + + val connectionDeferred = CompletableDeferred() + val servicesDeferred = CompletableDeferred() + + val callback = object : BluetoothGattCallback() { + override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { + if (newState == BluetoothProfile.STATE_CONNECTED) { + connectionDeferred.complete(Unit) + gatt.discoverServices() + } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { + if (!connectionDeferred.isCompleted) { + connectionDeferred.completeExceptionally(FlipperException("BLE connection failed (status $status)")) + } + } + } + + override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { + if (status == BluetoothGatt.GATT_SUCCESS) { + val service = gatt.getService(SERIAL_SERVICE_UUID) + if (service != null) { + rxCharacteristic = service.getCharacteristic(SERIAL_RX_UUID) + txCharacteristic = service.getCharacteristic(SERIAL_TX_UUID) + servicesDeferred.complete(Unit) + } else { + servicesDeferred.completeExceptionally( + FlipperException("Serial service not found on device"), + ) + } + } else { + servicesDeferred.completeExceptionally( + FlipperException("Service discovery failed (status $status)"), + ) + } + } + + @Deprecated("Deprecated in API 33") + override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { + if (characteristic.uuid == SERIAL_TX_UUID) { + val data = characteristic.value + if (data != null && data.isNotEmpty()) { + receiveChannel.trySend(data) + } + } + } + } + + val bluetoothGatt = targetDevice.connectGatt(context, false, callback) + this.gatt = bluetoothGatt + + connectionDeferred.await() + servicesDeferred.await() + + // Request higher MTU for better throughput + bluetoothGatt.requestMtu(512) + + // Enable notifications on the TX characteristic + val tx = txCharacteristic + ?: throw FlipperException("TX characteristic not found") + bluetoothGatt.setCharacteristicNotification(tx, true) + val descriptor = tx.getDescriptor(CCCD_UUID) + if (descriptor != null) { + descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + bluetoothGatt.writeDescriptor(descriptor) + } + } + + override suspend fun read(buffer: ByteArray, offset: Int, length: Int): Int { + val data = receiveChannel.receive() + val bytesToCopy = minOf(data.size, length) + data.copyInto(buffer, offset, 0, bytesToCopy) + return bytesToCopy + } + + override suspend fun write(data: ByteArray) { + val g = gatt ?: throw FlipperException("Not connected") + val rx = rxCharacteristic ?: throw FlipperException("RX characteristic not found") + rx.value = data + if (!g.writeCharacteristic(rx)) { + throw FlipperException("BLE write failed") + } + } + + override suspend fun close() { + gatt?.disconnect() + gatt?.close() + gatt = null + rxCharacteristic = null + txCharacteristic = null + receiveChannel.close() + } + + private suspend fun scanForFlipper(): BluetoothDevice { + val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager + val adapter = bluetoothManager.adapter + ?: throw FlipperException("Bluetooth not available") + + if (!adapter.isEnabled) { + throw FlipperException("Bluetooth is disabled") + } + + return withTimeout(SCAN_TIMEOUT_MS) { + suspendCancellableCoroutine { cont -> + val scanner = adapter.bluetoothLeScanner + ?: throw FlipperException("BLE scanner not available") + + val callback = object : ScanCallback() { + override fun onScanResult(callbackType: Int, result: ScanResult) { + scanner.stopScan(this) + cont.resume(result.device) + } + + override fun onScanFailed(errorCode: Int) { + cont.resumeWithException(FlipperException("BLE scan failed (error $errorCode)")) + } + } + + val filter = ScanFilter.Builder() + .setServiceUuid(ParcelUuid(SERIAL_SERVICE_UUID)) + .build() + val settings = ScanSettings.Builder() + .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) + .build() + + scanner.startScan(listOf(filter), settings, callback) + + cont.invokeOnCancellation { + scanner.stopScan(callback) + } + } + } + } +} diff --git a/flipper/src/androidMain/kotlin/com/codebutler/farebot/flipper/AndroidFlipperTransportFactory.kt b/flipper/src/androidMain/kotlin/com/codebutler/farebot/flipper/AndroidFlipperTransportFactory.kt new file mode 100644 index 000000000..1a5d46994 --- /dev/null +++ b/flipper/src/androidMain/kotlin/com/codebutler/farebot/flipper/AndroidFlipperTransportFactory.kt @@ -0,0 +1,16 @@ +package com.codebutler.farebot.flipper + +import android.content.Context + +class AndroidFlipperTransportFactory( + private val context: Context, +) : FlipperTransportFactory { + override val isUsbSupported: Boolean = true + override val isBleSupported: Boolean = true + + override suspend fun createUsbTransport(): FlipperTransport = + AndroidUsbSerialTransport(context) + + override suspend fun createBleTransport(): FlipperTransport = + AndroidBleSerialTransport(context) +} diff --git a/flipper/src/androidMain/kotlin/com/codebutler/farebot/flipper/AndroidUsbSerialTransport.kt b/flipper/src/androidMain/kotlin/com/codebutler/farebot/flipper/AndroidUsbSerialTransport.kt new file mode 100644 index 000000000..ef0e6ae25 --- /dev/null +++ b/flipper/src/androidMain/kotlin/com/codebutler/farebot/flipper/AndroidUsbSerialTransport.kt @@ -0,0 +1,179 @@ +package com.codebutler.farebot.flipper + +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.hardware.usb.UsbConstants +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbDeviceConnection +import android.hardware.usb.UsbEndpoint +import android.hardware.usb.UsbInterface +import android.hardware.usb.UsbManager +import android.os.Build +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +/** + * FlipperTransport implementation using Android USB Host API. + * Communicates with the Flipper Zero via CDC ACM (virtual serial port). + * + * Flipper Zero USB identifiers: VID 0x0483 (STMicroelectronics), PID 0x5740. + */ +class AndroidUsbSerialTransport( + private val context: Context, +) : FlipperTransport { + companion object { + const val FLIPPER_VID = 0x0483 + const val FLIPPER_PID = 0x5740 + private const val ACTION_USB_PERMISSION = "com.codebutler.farebot.USB_PERMISSION" + private const val TIMEOUT_MS = 5000 + } + + private var connection: UsbDeviceConnection? = null + private var dataInterface: UsbInterface? = null + private var inEndpoint: UsbEndpoint? = null + private var outEndpoint: UsbEndpoint? = null + + override val isConnected: Boolean + get() = connection != null + + override suspend fun connect() { + val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager + val device = findFlipperDevice(usbManager) + ?: throw FlipperException("Flipper Zero not found. Is it connected via USB?") + + if (!usbManager.hasPermission(device)) { + requestPermission(usbManager, device) + } + + val conn = usbManager.openDevice(device) + ?: throw FlipperException("Failed to open USB device") + + // Find the CDC Data interface (class 0x0A) + var dataIface: UsbInterface? = null + for (i in 0 until device.interfaceCount) { + val iface = device.getInterface(i) + if (iface.interfaceClass == UsbConstants.USB_CLASS_CDC_DATA) { + dataIface = iface + break + } + } + + if (dataIface == null) { + conn.close() + throw FlipperException("CDC Data interface not found on device") + } + + if (!conn.claimInterface(dataIface, true)) { + conn.close() + throw FlipperException("Failed to claim CDC Data interface") + } + + // Find bulk IN and OUT endpoints + var bulkIn: UsbEndpoint? = null + var bulkOut: UsbEndpoint? = null + for (i in 0 until dataIface.endpointCount) { + val ep = dataIface.getEndpoint(i) + if (ep.type == UsbConstants.USB_ENDPOINT_XFER_BULK) { + if (ep.direction == UsbConstants.USB_DIR_IN) { + bulkIn = ep + } else { + bulkOut = ep + } + } + } + + if (bulkIn == null || bulkOut == null) { + conn.releaseInterface(dataIface) + conn.close() + throw FlipperException("Bulk endpoints not found") + } + + connection = conn + dataInterface = dataIface + inEndpoint = bulkIn + outEndpoint = bulkOut + } + + override suspend fun read(buffer: ByteArray, offset: Int, length: Int): Int { + val conn = connection ?: throw FlipperException("Not connected") + val ep = inEndpoint ?: throw FlipperException("No IN endpoint") + + val tempBuffer = ByteArray(length) + val bytesRead = conn.bulkTransfer(ep, tempBuffer, length, TIMEOUT_MS) + if (bytesRead < 0) { + throw FlipperException("USB read failed (error $bytesRead)") + } + tempBuffer.copyInto(buffer, offset, 0, bytesRead) + return bytesRead + } + + override suspend fun write(data: ByteArray) { + val conn = connection ?: throw FlipperException("Not connected") + val ep = outEndpoint ?: throw FlipperException("No OUT endpoint") + + val result = conn.bulkTransfer(ep, data, data.size, TIMEOUT_MS) + if (result < 0) { + throw FlipperException("USB write failed (error $result)") + } + } + + override suspend fun close() { + val conn = connection ?: return + val iface = dataInterface + if (iface != null) { + conn.releaseInterface(iface) + } + conn.close() + connection = null + dataInterface = null + inEndpoint = null + outEndpoint = null + } + + private fun findFlipperDevice(usbManager: UsbManager): UsbDevice? = + usbManager.deviceList.values.firstOrNull { device -> + device.vendorId == FLIPPER_VID && device.productId == FLIPPER_PID + } + + @Suppress("UnspecifiedRegisterReceiverFlag") + private suspend fun requestPermission(usbManager: UsbManager, device: UsbDevice) = + suspendCancellableCoroutine { cont -> + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + context.unregisterReceiver(this) + val granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false) + if (granted) { + cont.resume(Unit) + } else { + cont.resumeWithException(FlipperException("USB permission denied")) + } + } + } + + val filter = IntentFilter(ACTION_USB_PERMISSION) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED) + } else { + context.registerReceiver(receiver, filter) + } + + val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + PendingIntent.FLAG_MUTABLE + } else { + 0 + } + val permissionIntent = PendingIntent.getBroadcast(context, 0, Intent(ACTION_USB_PERMISSION), flags) + usbManager.requestPermission(device, permissionIntent) + + cont.invokeOnCancellation { + try { + context.unregisterReceiver(receiver) + } catch (_: Exception) { + } + } + } +} diff --git a/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/FlipperException.kt b/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/FlipperException.kt new file mode 100644 index 000000000..94091452d --- /dev/null +++ b/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/FlipperException.kt @@ -0,0 +1,32 @@ +/* + * FlipperException.kt + * + * This file is part of FareBot. + * Learn more at: https://codebutler.github.io/farebot/ + * + * Copyright (C) 2025 Eric Butler + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.codebutler.farebot.flipper + +import com.codebutler.farebot.flipper.proto.CommandStatus + +class FlipperException( + val status: CommandStatus? = null, + message: String = if (status != null) "Flipper RPC error: $status" else "Flipper error", +) : Exception(message) { + constructor(message: String) : this(status = null, message = message) +} diff --git a/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/FlipperKeyDictParser.kt b/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/FlipperKeyDictParser.kt new file mode 100644 index 000000000..ceaf4f48c --- /dev/null +++ b/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/FlipperKeyDictParser.kt @@ -0,0 +1,48 @@ +/* + * FlipperKeyDictParser.kt + * + * This file is part of FareBot. + * Learn more at: https://codebutler.github.io/farebot/ + * + * Copyright (C) 2025 Eric Butler + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.codebutler.farebot.flipper + +/** + * Parses Flipper Zero MIFARE Classic user key dictionary files. + * + * Format: plain text, one 12-character hex key per line. + * Lines starting with '#' are comments. Blank lines are ignored. + * Each key is 6 bytes (12 hex characters). + */ +object FlipperKeyDictParser { + + private val HEX_KEY_REGEX = Regex("^[0-9A-Fa-f]{12}$") + + fun parse(data: String): List = + data.lineSequence() + .map { it.trim() } + .filter { it.isNotEmpty() && !it.startsWith('#') } + .filter { HEX_KEY_REGEX.matches(it) } + .map { hexToBytes(it) } + .toList() + + private fun hexToBytes(hex: String): ByteArray = + ByteArray(hex.length / 2) { i -> + hex.substring(i * 2, i * 2 + 2).toInt(16).toByte() + } +} diff --git a/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/FlipperRpcClient.kt b/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/FlipperRpcClient.kt new file mode 100644 index 000000000..8aecce15e --- /dev/null +++ b/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/FlipperRpcClient.kt @@ -0,0 +1,433 @@ +/* + * FlipperRpcClient.kt + * + * This file is part of FareBot. + * Learn more at: https://codebutler.github.io/farebot/ + * + * Copyright (C) 2025 Eric Butler + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.codebutler.farebot.flipper + +import com.codebutler.farebot.flipper.proto.CommandStatus +import com.codebutler.farebot.flipper.proto.StorageFile +import com.codebutler.farebot.flipper.proto.StorageInfoResponse +import com.codebutler.farebot.flipper.proto.StorageListResponse +import com.codebutler.farebot.flipper.proto.StorageReadResponse +import com.codebutler.farebot.flipper.proto.StorageStatResponse +import com.codebutler.farebot.flipper.proto.SystemDeviceInfoResponse +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.decodeFromByteArray +import kotlinx.serialization.encodeToByteArray +import kotlinx.serialization.protobuf.ProtoBuf + +/** + * Flipper Zero RPC client implementing the protobuf-based protocol over a serial transport. + * + * The Flipper protocol uses a `Main` wrapper message with `oneof` content. Since + * kotlinx.serialization.protobuf doesn't support `oneof`, we construct and parse + * `Main` envelopes manually using raw protobuf field encoding. + * + * Protocol flow: + * 1. Send "start_rpc_session\r" as raw text + * 2. Send/receive varint-length-prefixed protobuf `Main` messages + * 3. Correlate responses by command_id + * 4. Handle multi-part responses (has_next = true) + */ +class FlipperRpcClient( + private val transport: FlipperTransport, + private val timeoutMs: Long = 30_000L, +) { + private var nextCommandId = 1 + + /** Connect to the Flipper, start RPC session, and verify with a ping. */ + suspend fun connect() { + transport.connect() + // Send raw session start command + transport.write("start_rpc_session\r".encodeToByteArray()) + // Verify connectivity with a ping + ping() + } + + /** Send a ping and wait for the pong response. */ + suspend fun ping() { + val commandId = nextCommandId++ + sendRequest(commandId, FIELD_SYSTEM_PING_REQUEST, byteArrayOf()) + val response = readMainResponse(commandId) + checkStatus(response) + } + + /** Disconnect from the Flipper. */ + suspend fun disconnect() { + transport.close() + } + + /** List files in a directory on the Flipper's filesystem. */ + @OptIn(ExperimentalSerializationApi::class) + suspend fun listDirectory(path: String): List { + val commandId = nextCommandId++ + val requestBytes = ProtoBuf.encodeToByteArray( + com.codebutler.farebot.flipper.proto.StorageListRequest(path = path), + ) + sendRequest(commandId, FIELD_STORAGE_LIST_REQUEST, requestBytes) + + val allFiles = mutableListOf() + var hasNext = true + while (hasNext) { + val response = readMainResponse(commandId) + checkStatus(response) + hasNext = response.hasNext + + if (response.contentFieldNumber == FIELD_STORAGE_LIST_RESPONSE && response.contentBytes.isNotEmpty()) { + val listResponse = ProtoBuf.decodeFromByteArray(response.contentBytes) + for (file in listResponse.files) { + allFiles.add(file.toEntry(path)) + } + } + } + return allFiles + } + + /** Read a file from the Flipper's filesystem. Returns the raw file bytes. */ + @OptIn(ExperimentalSerializationApi::class) + suspend fun readFile(path: String): ByteArray { + val commandId = nextCommandId++ + val requestBytes = ProtoBuf.encodeToByteArray( + com.codebutler.farebot.flipper.proto.StorageReadRequest(path = path), + ) + sendRequest(commandId, FIELD_STORAGE_READ_REQUEST, requestBytes) + + val chunks = mutableListOf() + var hasNext = true + while (hasNext) { + val response = readMainResponse(commandId) + checkStatus(response) + hasNext = response.hasNext + + if (response.contentFieldNumber == FIELD_STORAGE_READ_RESPONSE && response.contentBytes.isNotEmpty()) { + val readResponse = ProtoBuf.decodeFromByteArray(response.contentBytes) + if (readResponse.file.data.isNotEmpty()) { + chunks.add(readResponse.file.data) + } + } + } + + // Concatenate all chunks + val totalSize = chunks.sumOf { it.size } + val result = ByteArray(totalSize) + var offset = 0 + for (chunk in chunks) { + chunk.copyInto(result, offset) + offset += chunk.size + } + return result + } + + /** Stat a file on the Flipper's filesystem. */ + @OptIn(ExperimentalSerializationApi::class) + suspend fun statFile(path: String): StorageFile { + val commandId = nextCommandId++ + val requestBytes = ProtoBuf.encodeToByteArray( + com.codebutler.farebot.flipper.proto.StorageStatRequest(path = path), + ) + sendRequest(commandId, FIELD_STORAGE_STAT_REQUEST, requestBytes) + + val response = readMainResponse(commandId) + checkStatus(response) + + if (response.contentFieldNumber == FIELD_STORAGE_STAT_RESPONSE && response.contentBytes.isNotEmpty()) { + val statResponse = ProtoBuf.decodeFromByteArray(response.contentBytes) + return statResponse.file + } + throw FlipperException(CommandStatus.ERROR, "No stat response received") + } + + /** Get storage info (total/free space) for a filesystem path. */ + @OptIn(ExperimentalSerializationApi::class) + suspend fun getStorageInfo(path: String): StorageInfoResponse { + val commandId = nextCommandId++ + val requestBytes = ProtoBuf.encodeToByteArray( + com.codebutler.farebot.flipper.proto.StorageInfoRequest(path = path), + ) + sendRequest(commandId, FIELD_STORAGE_INFO_REQUEST, requestBytes) + + val response = readMainResponse(commandId) + checkStatus(response) + + if (response.contentFieldNumber == FIELD_STORAGE_INFO_RESPONSE && response.contentBytes.isNotEmpty()) { + return ProtoBuf.decodeFromByteArray(response.contentBytes) + } + throw FlipperException(CommandStatus.ERROR, "No storage info response received") + } + + /** Get device info as key-value pairs. Multi-part response. */ + @OptIn(ExperimentalSerializationApi::class) + suspend fun getDeviceInfo(): Map { + val commandId = nextCommandId++ + val requestBytes = ProtoBuf.encodeToByteArray( + com.codebutler.farebot.flipper.proto.SystemDeviceInfoRequest(), + ) + sendRequest(commandId, FIELD_SYSTEM_DEVICE_INFO_REQUEST, requestBytes) + + val info = mutableMapOf() + var hasNext = true + while (hasNext) { + val response = readMainResponse(commandId) + checkStatus(response) + hasNext = response.hasNext + + if (response.contentFieldNumber == FIELD_SYSTEM_DEVICE_INFO_RESPONSE && + response.contentBytes.isNotEmpty() + ) { + val devInfo = ProtoBuf.decodeFromByteArray(response.contentBytes) + if (devInfo.key.isNotEmpty()) { + info[devInfo.key] = devInfo.value + } + } + } + return info + } + + // --- Internal protocol implementation --- + + private suspend fun sendRequest(commandId: Int, contentFieldNumber: Int, contentBytes: ByteArray) { + val envelope = buildMainEnvelope(commandId, contentFieldNumber, contentBytes) + val framed = frameMessage(envelope) + transport.write(framed) + } + + /** Read a complete Main response from the transport, with timeout. */ + private suspend fun readMainResponse(expectedCommandId: Int): ParsedMainResponse { + return withTimeout(timeoutMs) { + // Read varint length prefix byte-by-byte + val length = readVarintFromTransport() + + // Read the full message + val messageBytes = readExactly(length) + + // Parse the Main envelope + parseMainEnvelope(messageBytes) + } + } + + /** Read a varint from the transport one byte at a time. */ + private suspend fun readVarintFromTransport(): Int { + var result = 0 + var shift = 0 + val buf = ByteArray(1) + while (true) { + val read = transport.read(buf, 0, 1) + if (read == 0) continue // spin until data available + val b = buf[0].toInt() and 0xFF + result = result or ((b and 0x7F) shl shift) + if (b and 0x80 == 0) break + shift += 7 + if (shift > 35) throw FlipperException(CommandStatus.ERROR, "Varint too long") + } + return result + } + + /** Read exactly `length` bytes from the transport. */ + private suspend fun readExactly(length: Int): ByteArray { + val result = ByteArray(length) + var offset = 0 + while (offset < length) { + val read = transport.read(result, offset, length - offset) + if (read > 0) { + offset += read + } + } + return result + } + + private fun checkStatus(response: ParsedMainResponse) { + if (response.commandStatus != CommandStatus.OK) { + throw FlipperException(response.commandStatus) + } + } + + /** Parsed representation of a Main protobuf envelope. */ + internal data class ParsedMainResponse( + val commandId: Int = 0, + val commandStatus: CommandStatus = CommandStatus.OK, + val hasNext: Boolean = false, + val contentFieldNumber: Int = 0, + val contentBytes: ByteArray = byteArrayOf(), + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ParsedMainResponse) return false + return commandId == other.commandId && + commandStatus == other.commandStatus && + hasNext == other.hasNext && + contentFieldNumber == other.contentFieldNumber && + contentBytes.contentEquals(other.contentBytes) + } + + override fun hashCode(): Int { + var result = commandId + result = 31 * result + commandStatus.hashCode() + result = 31 * result + hasNext.hashCode() + result = 31 * result + contentFieldNumber + result = 31 * result + contentBytes.contentHashCode() + return result + } + } + + companion object { + // Main message content field numbers from flipper.proto + internal const val FIELD_SYSTEM_PING_REQUEST = 4 + internal const val FIELD_SYSTEM_PING_RESPONSE = 5 + internal const val FIELD_SYSTEM_DEVICE_INFO_REQUEST = 7 + internal const val FIELD_SYSTEM_DEVICE_INFO_RESPONSE = 8 + internal const val FIELD_STORAGE_LIST_REQUEST = 19 + internal const val FIELD_STORAGE_LIST_RESPONSE = 20 + internal const val FIELD_STORAGE_READ_REQUEST = 21 + internal const val FIELD_STORAGE_READ_RESPONSE = 22 + internal const val FIELD_STORAGE_STAT_REQUEST = 25 + internal const val FIELD_STORAGE_STAT_RESPONSE = 26 + internal const val FIELD_STORAGE_INFO_REQUEST = 28 + internal const val FIELD_STORAGE_INFO_RESPONSE = 29 + + /** Prepend a varint length prefix to a message. */ + fun frameMessage(data: ByteArray): ByteArray { + val lengthPrefix = Varint.encode(data.size) + return lengthPrefix + data + } + + /** + * Build a raw protobuf Main envelope. + * + * Main message layout (from flipper.proto): + * - field 1: command_id (uint32, varint) + * - field 2: command_status (enum, varint) + * - field 3: has_next (bool, varint) + * - fields 4+: oneof content (length-delimited) + */ + fun buildMainEnvelope( + commandId: Int, + contentFieldNumber: Int, + contentBytes: ByteArray, + hasNext: Boolean = false, + commandStatus: Int = 0, + ): ByteArray { + val buf = mutableListOf() + + // Field 1: command_id (wire type 0 = varint), tag = (1 << 3) | 0 = 0x08 + buf.add(0x08.toByte()) + buf.addAll(Varint.encode(commandId).toList()) + + // Field 2: command_status (wire type 0 = varint), tag = (2 << 3) | 0 = 0x10 + if (commandStatus != 0) { + buf.add(0x10.toByte()) + buf.addAll(Varint.encode(commandStatus).toList()) + } + + // Field 3: has_next (wire type 0 = varint), tag = (3 << 3) | 0 = 0x18 + if (hasNext) { + buf.add(0x18.toByte()) + buf.add(0x01.toByte()) + } + + // Content field (wire type 2 = length-delimited) + val tag = (contentFieldNumber shl 3) or 2 + buf.addAll(Varint.encode(tag).toList()) + buf.addAll(Varint.encode(contentBytes.size).toList()) + buf.addAll(contentBytes.toList()) + + return buf.toByteArray() + } + + /** + * Parse a raw protobuf Main envelope into its component fields. + * Iterates raw protobuf tag+value pairs. + */ + internal fun parseMainEnvelope(data: ByteArray): ParsedMainResponse { + var commandId = 0 + var commandStatus = CommandStatus.OK + var hasNext = false + var contentFieldNumber = 0 + var contentBytes = byteArrayOf() + + var pos = 0 + while (pos < data.size) { + // Read field tag (varint) + val (tagValue, tagLen) = Varint.decode(data, pos) + pos += tagLen + + val fieldNumber = tagValue ushr 3 + val wireType = tagValue and 0x07 + + when (wireType) { + 0 -> { + // Varint + val (value, valueLen) = Varint.decode(data, pos) + pos += valueLen + + when (fieldNumber) { + 1 -> commandId = value + 2 -> commandStatus = CommandStatus.fromValue(value) + 3 -> hasNext = value != 0 + } + } + 2 -> { + // Length-delimited + val (length, lengthLen) = Varint.decode(data, pos) + pos += lengthLen + + if (fieldNumber >= 4) { + // This is a content field (oneof) + contentFieldNumber = fieldNumber + contentBytes = data.copyOfRange(pos, pos + length) + } + pos += length + } + else -> { + // Skip unknown wire types (shouldn't happen in practice) + break + } + } + } + + return ParsedMainResponse( + commandId = commandId, + commandStatus = commandStatus, + hasNext = hasNext, + contentFieldNumber = contentFieldNumber, + contentBytes = contentBytes, + ) + } + } +} + +/** A file entry returned by [FlipperRpcClient.listDirectory]. */ +data class FlipperFileEntry( + val name: String, + val isDirectory: Boolean, + val size: Long, + val path: String, +) + +private fun StorageFile.toEntry(parentPath: String): FlipperFileEntry { + val fullPath = if (parentPath.endsWith("/")) "$parentPath$name" else "$parentPath/$name" + return FlipperFileEntry( + name = name, + isDirectory = type == com.codebutler.farebot.flipper.proto.StorageFileType.DIR, + size = size.toLong(), + path = fullPath, + ) +} diff --git a/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/FlipperTransport.kt b/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/FlipperTransport.kt new file mode 100644 index 000000000..9c24ac10a --- /dev/null +++ b/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/FlipperTransport.kt @@ -0,0 +1,31 @@ +/* + * FlipperTransport.kt + * + * This file is part of FareBot. + * Learn more at: https://codebutler.github.io/farebot/ + * + * Copyright (C) 2025 Eric Butler + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.codebutler.farebot.flipper + +interface FlipperTransport { + suspend fun connect() + suspend fun read(buffer: ByteArray, offset: Int, length: Int): Int + suspend fun write(data: ByteArray) + suspend fun close() + val isConnected: Boolean +} diff --git a/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/FlipperTransportFactory.kt b/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/FlipperTransportFactory.kt new file mode 100644 index 000000000..4321c3ef1 --- /dev/null +++ b/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/FlipperTransportFactory.kt @@ -0,0 +1,27 @@ +package com.codebutler.farebot.flipper + +/** + * Factory for creating platform-specific FlipperTransport instances. + * Each platform implements this to provide USB and/or BLE transport. + */ +interface FlipperTransportFactory { + /** Returns true if USB transport is supported on this platform. */ + val isUsbSupported: Boolean + + /** Returns true if BLE transport is supported on this platform. */ + val isBleSupported: Boolean + + /** + * Creates a USB serial transport. + * May show a device picker dialog. + * Returns null if USB is not supported or user cancelled. + */ + suspend fun createUsbTransport(): FlipperTransport? + + /** + * Creates a BLE serial transport. + * May show a device picker/scan dialog. + * Returns null if BLE is not supported or user cancelled. + */ + suspend fun createBleTransport(): FlipperTransport? +} diff --git a/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/Varint.kt b/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/Varint.kt new file mode 100644 index 000000000..c77120920 --- /dev/null +++ b/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/Varint.kt @@ -0,0 +1,52 @@ +/* + * Varint.kt + * + * This file is part of FareBot. + * Learn more at: https://codebutler.github.io/farebot/ + * + * Copyright (C) 2025 Eric Butler + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.codebutler.farebot.flipper + +object Varint { + + fun encode(value: Int): ByteArray { + val result = mutableListOf() + var v = value + while (v > 0x7F) { + result.add(((v and 0x7F) or 0x80).toByte()) + v = v ushr 7 + } + result.add((v and 0x7F).toByte()) + return result.toByteArray() + } + + /** Returns (decoded value, number of bytes consumed). */ + fun decode(data: ByteArray, offset: Int): Pair { + var result = 0 + var shift = 0 + var pos = offset + while (pos < data.size) { + val b = data[pos].toInt() and 0xFF + result = result or ((b and 0x7F) shl shift) + pos++ + if (b and 0x80 == 0) break + shift += 7 + } + return result to (pos - offset) + } +} diff --git a/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/proto/FlipperMain.kt b/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/proto/FlipperMain.kt new file mode 100644 index 000000000..8538ab32d --- /dev/null +++ b/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/proto/FlipperMain.kt @@ -0,0 +1,56 @@ +/* + * FlipperMain.kt + * + * This file is part of FareBot. + * Learn more at: https://codebutler.github.io/farebot/ + * + * Copyright (C) 2025 Eric Butler + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.codebutler.farebot.flipper.proto + +/** + * Flipper RPC command status codes. + * Matches CommandStatus enum in flipper.proto. + */ +enum class CommandStatus(val value: Int) { + OK(0), + ERROR(1), + ERROR_STORAGE_NOT_READY(2), + ERROR_STORAGE_EXIST(3), + ERROR_STORAGE_NOT_EXIST(4), + ERROR_STORAGE_INVALID_PARAMETER(5), + ERROR_STORAGE_DENIED(6), + ERROR_STORAGE_INVALID_NAME(7), + ERROR_STORAGE_INTERNAL(8), + ERROR_STORAGE_NOT_IMPLEMENTED(9), + ERROR_STORAGE_ALREADY_OPEN(10), + ERROR_STORAGE_DIR_NOT_EMPTY(11), + ERROR_APP_CANT_START(12), + ERROR_APP_SYSTEM_LOCKED(13), + ERROR_APP_NOT_RUNNING(14), + ERROR_APP_CMD_ERROR(15), + ERROR_VIRTUAL_DISPLAY_ALREADY_STARTED(16), + ERROR_VIRTUAL_DISPLAY_NOT_STARTED(17), + ERROR_GPIO_MODE_INCORRECT(18), + ERROR_GPIO_UNKNOWN_PIN_MODE(19), + ; + + companion object { + fun fromValue(value: Int): CommandStatus = + entries.firstOrNull { it.value == value } ?: ERROR + } +} diff --git a/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/proto/FlipperStorage.kt b/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/proto/FlipperStorage.kt new file mode 100644 index 000000000..7208787b2 --- /dev/null +++ b/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/proto/FlipperStorage.kt @@ -0,0 +1,128 @@ +/* + * FlipperStorage.kt + * + * This file is part of FareBot. + * Learn more at: https://codebutler.github.io/farebot/ + * + * Copyright (C) 2025 Eric Butler + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.codebutler.farebot.flipper.proto + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.protobuf.ProtoNumber + +@Serializable(with = StorageFileTypeSerializer::class) +enum class StorageFileType(val value: Int) { + FILE(0), + DIR(1), +} + +internal object StorageFileTypeSerializer : KSerializer { + override val descriptor = PrimitiveSerialDescriptor("StorageFileType", PrimitiveKind.INT) + + override fun serialize(encoder: Encoder, value: StorageFileType) { + encoder.encodeInt(value.value) + } + + override fun deserialize(decoder: Decoder): StorageFileType { + val v = decoder.decodeInt() + return StorageFileType.entries.firstOrNull { it.value == v } ?: StorageFileType.FILE + } +} + +@OptIn(ExperimentalSerializationApi::class) +@Serializable +data class StorageFile( + @ProtoNumber(1) val type: StorageFileType = StorageFileType.FILE, + @ProtoNumber(2) val name: String = "", + @ProtoNumber(3) val size: UInt = 0u, + @ProtoNumber(4) val data: ByteArray = byteArrayOf(), + @ProtoNumber(5) val md5sum: String = "", +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is StorageFile) return false + return type == other.type && name == other.name && size == other.size && + data.contentEquals(other.data) && md5sum == other.md5sum + } + + override fun hashCode(): Int { + var result = type.hashCode() + result = 31 * result + name.hashCode() + result = 31 * result + size.hashCode() + result = 31 * result + data.contentHashCode() + result = 31 * result + md5sum.hashCode() + return result + } +} + +@OptIn(ExperimentalSerializationApi::class) +@Serializable +data class StorageInfoRequest( + @ProtoNumber(1) val path: String = "", +) + +@OptIn(ExperimentalSerializationApi::class) +@Serializable +data class StorageInfoResponse( + @ProtoNumber(1) val totalSpace: ULong = 0u, + @ProtoNumber(2) val freeSpace: ULong = 0u, +) + +@OptIn(ExperimentalSerializationApi::class) +@Serializable +data class StorageListRequest( + @ProtoNumber(1) val path: String = "", + @ProtoNumber(2) val includeMd5: Boolean = false, + @ProtoNumber(3) val filterMaxSize: UInt = 0u, +) + +@OptIn(ExperimentalSerializationApi::class) +@Serializable +data class StorageListResponse( + @ProtoNumber(1) val files: List = emptyList(), +) + +@OptIn(ExperimentalSerializationApi::class) +@Serializable +data class StorageReadRequest( + @ProtoNumber(1) val path: String = "", +) + +@OptIn(ExperimentalSerializationApi::class) +@Serializable +data class StorageReadResponse( + @ProtoNumber(1) val file: StorageFile = StorageFile(), +) + +@OptIn(ExperimentalSerializationApi::class) +@Serializable +data class StorageStatRequest( + @ProtoNumber(1) val path: String = "", +) + +@OptIn(ExperimentalSerializationApi::class) +@Serializable +data class StorageStatResponse( + @ProtoNumber(1) val file: StorageFile = StorageFile(), +) diff --git a/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/proto/FlipperSystem.kt b/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/proto/FlipperSystem.kt new file mode 100644 index 000000000..7054226e9 --- /dev/null +++ b/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/proto/FlipperSystem.kt @@ -0,0 +1,40 @@ +/* + * FlipperSystem.kt + * + * This file is part of FareBot. + * Learn more at: https://codebutler.github.io/farebot/ + * + * Copyright (C) 2025 Eric Butler + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.codebutler.farebot.flipper.proto + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.protobuf.ProtoNumber + +@OptIn(ExperimentalSerializationApi::class) +@Serializable +data class SystemDeviceInfoRequest( + @ProtoNumber(1) val dummy: Int = 0, +) + +@OptIn(ExperimentalSerializationApi::class) +@Serializable +data class SystemDeviceInfoResponse( + @ProtoNumber(1) val key: String = "", + @ProtoNumber(2) val value: String = "", +) diff --git a/flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/FlipperIntegrationTest.kt b/flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/FlipperIntegrationTest.kt new file mode 100644 index 000000000..a6ed7154a --- /dev/null +++ b/flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/FlipperIntegrationTest.kt @@ -0,0 +1,181 @@ +/* + * FlipperIntegrationTest.kt + * + * This file is part of FareBot. + * Learn more at: https://codebutler.github.io/farebot/ + * + * Copyright (C) 2025 Eric Butler + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.codebutler.farebot.flipper + +import com.codebutler.farebot.flipper.FlipperRpcClientTest.Companion.buildMainEnvelope +import com.codebutler.farebot.flipper.FlipperRpcClientTest.Companion.buildStorageListResponseBytes +import com.codebutler.farebot.flipper.FlipperRpcClientTest.Companion.buildStorageReadResponseBytes +import com.codebutler.farebot.flipper.FlipperRpcClientTest.TestFileEntry +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * End-to-end integration test: connect → list directory → read file → parse content. + * Tests the full RPC client flow with mock transport, then verifies FlipperKeyDictParser + * can process the retrieved data. + */ +class FlipperIntegrationTest { + + @Test + fun testFullFlowConnectListReadFile() = runTest { + val transport = MockTransport() + val client = FlipperRpcClient(transport) + + // 1. Connect — enqueue ping response + val pingResponse = buildMainEnvelope(commandId = 1, contentFieldNumber = 5, contentBytes = byteArrayOf()) + transport.enqueueResponse(FlipperRpcClient.frameMessage(pingResponse)) + client.connect() + assertTrue(transport.isConnected) + + // 2. List directory — enqueue response with 2 NFC files and 1 directory + val listContent = buildStorageListResponseBytes( + listOf( + TestFileEntry("card.nfc", isDir = false, size = 512u), + TestFileEntry("assets", isDir = true, size = 0u), + TestFileEntry("backup.nfc", isDir = false, size = 256u), + ), + ) + val listResponse = buildMainEnvelope(commandId = 2, contentFieldNumber = 20, contentBytes = listContent) + transport.enqueueResponse(FlipperRpcClient.frameMessage(listResponse)) + + val entries = client.listDirectory("/ext/nfc") + assertEquals(3, entries.size) + assertEquals("card.nfc", entries[0].name) + assertEquals(false, entries[0].isDirectory) + assertEquals(512L, entries[0].size) + assertEquals("assets", entries[1].name) + assertEquals(true, entries[1].isDirectory) + assertEquals("backup.nfc", entries[2].name) + + // 3. Read an NFC dump file + val nfcContent = """ + Filetype: Flipper NFC device + Version: 4 + Device type: Mifare Classic + UID: 01 02 03 04 + """.trimIndent() + val fileData = nfcContent.encodeToByteArray() + val readContent = buildStorageReadResponseBytes(fileData) + val readResponse = buildMainEnvelope(commandId = 3, contentFieldNumber = 22, contentBytes = readContent) + transport.enqueueResponse(FlipperRpcClient.frameMessage(readResponse)) + + val data = client.readFile("/ext/nfc/card.nfc") + val content = data.decodeToString() + assertTrue(content.contains("Filetype: Flipper NFC device")) + assertTrue(content.contains("Device type: Mifare Classic")) + assertTrue(content.contains("UID: 01 02 03 04")) + } + + @Test + fun testFullFlowConnectReadKeyDictionary() = runTest { + val transport = MockTransport() + val client = FlipperRpcClient(transport) + + // Connect + val pingResponse = buildMainEnvelope(commandId = 1, contentFieldNumber = 5, contentBytes = byteArrayOf()) + transport.enqueueResponse(FlipperRpcClient.frameMessage(pingResponse)) + client.connect() + + // Read key dictionary file from Flipper + val dictContent = """ + # Flipper user dictionary + A0A1A2A3A4A5 + B0B1B2B3B4B5 + # comment + FFFFFFFFFFFF + """.trimIndent() + val dictData = dictContent.encodeToByteArray() + val readContent = buildStorageReadResponseBytes(dictData) + val readResponse = buildMainEnvelope(commandId = 2, contentFieldNumber = 22, contentBytes = readContent) + transport.enqueueResponse(FlipperRpcClient.frameMessage(readResponse)) + + val data = client.readFile("/ext/nfc/assets/mf_classic_dict_user.nfc") + + // Parse with FlipperKeyDictParser + val keys = FlipperKeyDictParser.parse(data.decodeToString()) + + assertEquals(3, keys.size) + // Verify first key: A0 A1 A2 A3 A4 A5 + assertEquals(0xA0.toByte(), keys[0][0]) + assertEquals(0xA5.toByte(), keys[0][5]) + assertEquals(6, keys[0].size) + // Verify second key: B0 B1 B2 B3 B4 B5 + assertEquals(0xB0.toByte(), keys[1][0]) + assertEquals(0xB5.toByte(), keys[1][5]) + // Verify last key: FF FF FF FF FF FF + assertTrue(keys[2].all { it == 0xFF.toByte() }) + } + + @Test + fun testMultiChunkFileRead() = runTest { + val transport = MockTransport() + val client = FlipperRpcClient(transport) + + // Connect + val pingResponse = buildMainEnvelope(commandId = 1, contentFieldNumber = 5, contentBytes = byteArrayOf()) + transport.enqueueResponse(FlipperRpcClient.frameMessage(pingResponse)) + client.connect() + + // Simulate reading a large file in two chunks (has_next = true for first chunk) + val chunk1 = "Filetype: Flipper NFC device\n".encodeToByteArray() + val chunk2 = "Version: 4\nDevice type: Mifare Classic\n".encodeToByteArray() + + val readResponse1 = buildMainEnvelope( + commandId = 2, + contentFieldNumber = 22, + contentBytes = buildStorageReadResponseBytes(chunk1), + hasNext = true, + ) + transport.enqueueResponse(FlipperRpcClient.frameMessage(readResponse1)) + + val readResponse2 = buildMainEnvelope( + commandId = 2, + contentFieldNumber = 22, + contentBytes = buildStorageReadResponseBytes(chunk2), + hasNext = false, + ) + transport.enqueueResponse(FlipperRpcClient.frameMessage(readResponse2)) + + val data = client.readFile("/ext/nfc/card.nfc") + val content = data.decodeToString() + assertEquals("Filetype: Flipper NFC device\nVersion: 4\nDevice type: Mifare Classic\n", content) + } + + @Test + fun testDisconnectCleansUp() = runTest { + val transport = MockTransport() + val client = FlipperRpcClient(transport) + + // Connect + val pingResponse = buildMainEnvelope(commandId = 1, contentFieldNumber = 5, contentBytes = byteArrayOf()) + transport.enqueueResponse(FlipperRpcClient.frameMessage(pingResponse)) + client.connect() + assertTrue(transport.isConnected) + + // Disconnect via transport + transport.close() + assertTrue(!transport.isConnected) + } +} diff --git a/flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/FlipperKeyDictParserTest.kt b/flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/FlipperKeyDictParserTest.kt new file mode 100644 index 000000000..51a5acbef --- /dev/null +++ b/flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/FlipperKeyDictParserTest.kt @@ -0,0 +1,92 @@ +/* + * FlipperKeyDictParserTest.kt + * + * This file is part of FareBot. + * Learn more at: https://codebutler.github.io/farebot/ + * + * Copyright (C) 2025 Eric Butler + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.codebutler.farebot.flipper + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals + +class FlipperKeyDictParserTest { + + @Test + fun testParseValidDictionary() { + val input = """ + # Flipper NFC user dictionary + FFFFFFFFFFFF + A0A1A2A3A4A5 + D3F7D3F7D3F7 + + 000000000000 + """.trimIndent() + + val keys = FlipperKeyDictParser.parse(input) + assertEquals(4, keys.size) + assertContentEquals( + byteArrayOf( + 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), + 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), + ), + keys[0], + ) + assertContentEquals( + byteArrayOf( + 0xA0.toByte(), 0xA1.toByte(), 0xA2.toByte(), + 0xA3.toByte(), 0xA4.toByte(), 0xA5.toByte(), + ), + keys[1], + ) + } + + @Test + fun testSkipsCommentsAndBlanks() { + val input = """ + # Comment + + # Another comment + FFFFFFFFFFFF + + """.trimIndent() + + val keys = FlipperKeyDictParser.parse(input) + assertEquals(1, keys.size) + } + + @Test + fun testSkipsInvalidKeys() { + val input = """ + FFFFFFFFFFFF + TOOSHORT + FFFFFFFFFFFF00 + A0A1A2A3A4A5 + """.trimIndent() + + val keys = FlipperKeyDictParser.parse(input) + assertEquals(2, keys.size) // Only valid 12-char hex strings + } + + @Test + fun testEmptyInput() { + val keys = FlipperKeyDictParser.parse("") + assertEquals(0, keys.size) + } +} diff --git a/flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/FlipperRpcClientTest.kt b/flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/FlipperRpcClientTest.kt new file mode 100644 index 000000000..24aece842 --- /dev/null +++ b/flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/FlipperRpcClientTest.kt @@ -0,0 +1,278 @@ +/* + * FlipperRpcClientTest.kt + * + * This file is part of FareBot. + * Learn more at: https://codebutler.github.io/farebot/ + * + * Copyright (C) 2025 Eric Butler + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.codebutler.farebot.flipper + +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class FlipperRpcClientTest { + + @Test + fun testFrameMessage() { + // Verify that a message of N bytes is prefixed with varint(N) + val data = ByteArray(300) { it.toByte() } + val framed = FlipperRpcClient.frameMessage(data) + val (length, bytesRead) = Varint.decode(framed, 0) + assertEquals(300, length) + assertEquals(framed.size, bytesRead + 300) + } + + @Test + fun testFrameSmallMessage() { + val data = ByteArray(10) { 0x42 } + val framed = FlipperRpcClient.frameMessage(data) + // varint(10) = 0x0A (1 byte), so total = 11 + assertEquals(11, framed.size) + assertEquals(0x0A.toByte(), framed[0]) + } + + @Test + fun testConnectSendsStartRpcSession() = runTest { + val transport = MockTransport() + val client = FlipperRpcClient(transport) + + // Enqueue a valid ping response (Main envelope with command_id=1, status=OK, ping_response) + // Main fields: command_id=1 (field 1, varint), command_status=0 (field 2, varint), + // has_next=false (field 3, varint=0), system_ping_response (field 5, LEN) + val pingResponse = buildMainEnvelope(commandId = 1, contentFieldNumber = 5, contentBytes = byteArrayOf()) + transport.enqueueResponse(FlipperRpcClient.frameMessage(pingResponse)) + + client.connect() + + assertTrue(transport.isConnected) + assertTrue(transport.writtenData.isNotEmpty()) + val firstWrite = transport.writtenData[0].decodeToString() + assertTrue(firstWrite.contains("start_rpc_session"), "First write should be start_rpc_session") + } + + @Test + fun testBuildMainEnvelope() { + // Build envelope with command_id=1, empty ping request (field 4) + val envelope = FlipperRpcClient.buildMainEnvelope( + commandId = 1, + contentFieldNumber = 4, + contentBytes = byteArrayOf(), + ) + // Should start with field 1 (command_id) tag = 0x08, then varint 1 + assertEquals(0x08.toByte(), envelope[0]) + assertEquals(0x01.toByte(), envelope[1]) + } + + @Test + fun testListDirectory() = runTest { + val transport = MockTransport() + val client = FlipperRpcClient(transport) + + // Enqueue ping response for connect + val pingResponse = buildMainEnvelope(commandId = 1, contentFieldNumber = 5, contentBytes = byteArrayOf()) + transport.enqueueResponse(FlipperRpcClient.frameMessage(pingResponse)) + + client.connect() + + // Build a StorageListResponse with two files + val listResponseContent = buildStorageListResponseBytes( + listOf( + TestFileEntry("card.nfc", isDir = false, size = 1024u), + TestFileEntry("keys", isDir = true, size = 0u), + ), + ) + val listResponse = buildMainEnvelope( + commandId = 2, + contentFieldNumber = 20, // storage_list_response + contentBytes = listResponseContent, + ) + transport.enqueueResponse(FlipperRpcClient.frameMessage(listResponse)) + + val files = client.listDirectory("/ext/nfc") + assertEquals(2, files.size) + assertEquals("card.nfc", files[0].name) + assertEquals(false, files[0].isDirectory) + assertEquals("keys", files[1].name) + assertEquals(true, files[1].isDirectory) + } + + @Test + fun testReadFile() = runTest { + val transport = MockTransport() + val client = FlipperRpcClient(transport) + + // Enqueue ping response for connect + val pingResponse = buildMainEnvelope(commandId = 1, contentFieldNumber = 5, contentBytes = byteArrayOf()) + transport.enqueueResponse(FlipperRpcClient.frameMessage(pingResponse)) + + client.connect() + + // Build a StorageReadResponse with file data + val fileData = "Filetype: Flipper NFC device\n".encodeToByteArray() + val readResponseContent = buildStorageReadResponseBytes(fileData) + val readResponse = buildMainEnvelope( + commandId = 2, + contentFieldNumber = 22, // storage_read_response + contentBytes = readResponseContent, + ) + transport.enqueueResponse(FlipperRpcClient.frameMessage(readResponse)) + + val data = client.readFile("/ext/nfc/card.nfc") + assertEquals("Filetype: Flipper NFC device\n", data.decodeToString()) + } + + @Test + fun testMultiPartReadFile() = runTest { + val transport = MockTransport() + val client = FlipperRpcClient(transport) + + // Enqueue ping response for connect + val pingResponse = buildMainEnvelope(commandId = 1, contentFieldNumber = 5, contentBytes = byteArrayOf()) + transport.enqueueResponse(FlipperRpcClient.frameMessage(pingResponse)) + + client.connect() + + // Part 1: has_next = true + val chunk1 = "Hello, ".encodeToByteArray() + val readResponse1 = buildMainEnvelope( + commandId = 2, + contentFieldNumber = 22, + contentBytes = buildStorageReadResponseBytes(chunk1), + hasNext = true, + ) + transport.enqueueResponse(FlipperRpcClient.frameMessage(readResponse1)) + + // Part 2: has_next = false (final) + val chunk2 = "World!".encodeToByteArray() + val readResponse2 = buildMainEnvelope( + commandId = 2, + contentFieldNumber = 22, + contentBytes = buildStorageReadResponseBytes(chunk2), + hasNext = false, + ) + transport.enqueueResponse(FlipperRpcClient.frameMessage(readResponse2)) + + val data = client.readFile("/ext/nfc/card.nfc") + assertEquals("Hello, World!", data.decodeToString()) + } + + // --- Test helpers to build raw protobuf bytes --- + + data class TestFileEntry(val name: String, val isDir: Boolean, val size: UInt) + + companion object { + /** Build a raw protobuf Main envelope. */ + fun buildMainEnvelope( + commandId: Int, + contentFieldNumber: Int, + contentBytes: ByteArray, + hasNext: Boolean = false, + commandStatus: Int = 0, + ): ByteArray { + val buf = mutableListOf() + + // Field 1: command_id (varint) + buf.add(0x08.toByte()) // tag = (1 << 3) | 0 + buf.addAll(Varint.encode(commandId).toList()) + + // Field 2: command_status (varint) - only if non-zero + if (commandStatus != 0) { + buf.add(0x10.toByte()) // tag = (2 << 3) | 0 + buf.addAll(Varint.encode(commandStatus).toList()) + } + + // Field 3: has_next (varint) + if (hasNext) { + buf.add(0x18.toByte()) // tag = (3 << 3) | 0 + buf.add(0x01.toByte()) + } + + // Content field (wire type 2 = length-delimited) + if (contentBytes.isNotEmpty() || contentFieldNumber > 0) { + val tag = (contentFieldNumber shl 3) or 2 + buf.addAll(Varint.encode(tag).toList()) + buf.addAll(Varint.encode(contentBytes.size).toList()) + buf.addAll(contentBytes.toList()) + } + + return buf.toByteArray() + } + + /** Build raw protobuf bytes for StorageListResponse (field 1 = repeated StorageFile). */ + fun buildStorageListResponseBytes(files: List): ByteArray { + val buf = mutableListOf() + for (file in files) { + val fileBytes = buildStorageFileBytes(file) + // field 1, wire type 2 (length-delimited) + buf.add(0x0A.toByte()) // (1 << 3) | 2 + buf.addAll(Varint.encode(fileBytes.size).toList()) + buf.addAll(fileBytes.toList()) + } + return buf.toByteArray() + } + + /** Build raw protobuf bytes for a StorageFile message. */ + private fun buildStorageFileBytes(file: TestFileEntry): ByteArray { + val buf = mutableListOf() + + // Field 1: type (varint) - 0=FILE, 1=DIR + buf.add(0x08.toByte()) // (1 << 3) | 0 + buf.add(if (file.isDir) 0x01.toByte() else 0x00.toByte()) + + // Field 2: name (length-delimited string) + val nameBytes = file.name.encodeToByteArray() + buf.add(0x12.toByte()) // (2 << 3) | 2 + buf.addAll(Varint.encode(nameBytes.size).toList()) + buf.addAll(nameBytes.toList()) + + // Field 3: size (varint) + if (file.size > 0u) { + buf.add(0x18.toByte()) // (3 << 3) | 0 + buf.addAll(Varint.encode(file.size.toInt()).toList()) + } + + return buf.toByteArray() + } + + /** Build raw protobuf bytes for StorageReadResponse (field 1 = StorageFile with data). */ + fun buildStorageReadResponseBytes(data: ByteArray): ByteArray { + val buf = mutableListOf() + + // The StorageReadResponse has field 1 = StorageFile + // We need a StorageFile with field 4 = data + val fileBytes = buildStorageFileWithData(data) + buf.add(0x0A.toByte()) // (1 << 3) | 2 + buf.addAll(Varint.encode(fileBytes.size).toList()) + buf.addAll(fileBytes.toList()) + + return buf.toByteArray() + } + + /** Build a StorageFile with just the data field populated. */ + private fun buildStorageFileWithData(data: ByteArray): ByteArray { + val buf = mutableListOf() + // Field 4: data (length-delimited bytes) + buf.add(0x22.toByte()) // (4 << 3) | 2 + buf.addAll(Varint.encode(data.size).toList()) + buf.addAll(data.toList()) + return buf.toByteArray() + } + } +} diff --git a/flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/MockTransport.kt b/flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/MockTransport.kt new file mode 100644 index 000000000..e15881621 --- /dev/null +++ b/flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/MockTransport.kt @@ -0,0 +1,56 @@ +/* + * MockTransport.kt + * + * This file is part of FareBot. + * Learn more at: https://codebutler.github.io/farebot/ + * + * Copyright (C) 2025 Eric Butler + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.codebutler.farebot.flipper + +class MockTransport : FlipperTransport { + val writtenData = mutableListOf() + private val responseBuffer = mutableListOf() + private var _connected = false + + override val isConnected: Boolean get() = _connected + + override suspend fun connect() { + _connected = true + } + + override suspend fun close() { + _connected = false + } + + override suspend fun write(data: ByteArray) { + writtenData.add(data.copyOf()) + } + + override suspend fun read(buffer: ByteArray, offset: Int, length: Int): Int { + if (responseBuffer.isEmpty()) return 0 + val toCopy = minOf(length, responseBuffer.size) + for (i in 0 until toCopy) { + buffer[offset + i] = responseBuffer.removeFirst() + } + return toCopy + } + + fun enqueueResponse(data: ByteArray) { + responseBuffer.addAll(data.toList()) + } +} diff --git a/flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/VarintTest.kt b/flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/VarintTest.kt new file mode 100644 index 000000000..dea4d7491 --- /dev/null +++ b/flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/VarintTest.kt @@ -0,0 +1,72 @@ +/* + * VarintTest.kt + * + * This file is part of FareBot. + * Learn more at: https://codebutler.github.io/farebot/ + * + * Copyright (C) 2025 Eric Butler + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.codebutler.farebot.flipper + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals + +class VarintTest { + + @Test + fun testEncodeSmallValue() { + assertContentEquals(byteArrayOf(0x01), Varint.encode(1)) + assertContentEquals(byteArrayOf(0x7F), Varint.encode(127)) + } + + @Test + fun testEncodeTwoByteValue() { + // 128 = 0x80 -> varint [0x80, 0x01] + assertContentEquals(byteArrayOf(0x80.toByte(), 0x01), Varint.encode(128)) + // 300 = 0x12C -> varint [0xAC, 0x02] + assertContentEquals(byteArrayOf(0xAC.toByte(), 0x02), Varint.encode(300)) + } + + @Test + fun testEncodeZero() { + assertContentEquals(byteArrayOf(0x00), Varint.encode(0)) + } + + @Test + fun testDecodeSmallValue() { + val (value, bytesRead) = Varint.decode(byteArrayOf(0x01), 0) + assertEquals(1, value) + assertEquals(1, bytesRead) + } + + @Test + fun testDecodeTwoByteValue() { + val (value, bytesRead) = Varint.decode(byteArrayOf(0xAC.toByte(), 0x02), 0) + assertEquals(300, value) + assertEquals(2, bytesRead) + } + + @Test + fun testRoundTrip() { + for (v in listOf(0, 1, 127, 128, 255, 256, 16383, 16384, 65535, 1_000_000)) { + val encoded = Varint.encode(v) + val (decoded, _) = Varint.decode(encoded, 0) + assertEquals(v, decoded, "Round-trip failed for $v") + } + } +} diff --git a/flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/proto/FlipperProtoTest.kt b/flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/proto/FlipperProtoTest.kt new file mode 100644 index 000000000..da8899ec2 --- /dev/null +++ b/flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/proto/FlipperProtoTest.kt @@ -0,0 +1,95 @@ +/* + * FlipperProtoTest.kt + * + * This file is part of FareBot. + * Learn more at: https://codebutler.github.io/farebot/ + * + * Copyright (C) 2025 Eric Butler + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.codebutler.farebot.flipper.proto + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.decodeFromByteArray +import kotlinx.serialization.encodeToByteArray +import kotlinx.serialization.protobuf.ProtoBuf +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalSerializationApi::class) +class FlipperProtoTest { + + @Test + fun testStorageListRequestRoundTrip() { + val request = StorageListRequest(path = "/ext/nfc") + val bytes = ProtoBuf.encodeToByteArray(request) + val decoded = ProtoBuf.decodeFromByteArray(bytes) + assertEquals("/ext/nfc", decoded.path) + } + + @Test + fun testStorageFileRoundTrip() { + val file = StorageFile( + type = StorageFileType.FILE, + name = "card.nfc", + size = 1234u, + ) + val bytes = ProtoBuf.encodeToByteArray(file) + val decoded = ProtoBuf.decodeFromByteArray(bytes) + assertEquals("card.nfc", decoded.name) + assertEquals(1234u, decoded.size) + assertEquals(StorageFileType.FILE, decoded.type) + } + + @Test + fun testStorageListResponseRoundTrip() { + val response = StorageListResponse( + files = listOf( + StorageFile(type = StorageFileType.FILE, name = "card.nfc", size = 100u), + StorageFile(type = StorageFileType.DIR, name = "assets", size = 0u), + ), + ) + val bytes = ProtoBuf.encodeToByteArray(response) + val decoded = ProtoBuf.decodeFromByteArray(bytes) + assertEquals(2, decoded.files.size) + assertEquals("card.nfc", decoded.files[0].name) + assertEquals(StorageFileType.DIR, decoded.files[1].type) + } + + @Test + fun testCommandStatusValues() { + assertEquals(0, CommandStatus.OK.value) + assertEquals(2, CommandStatus.ERROR_STORAGE_NOT_READY.value) + } + + @Test + fun testStorageInfoRoundTrip() { + val response = StorageInfoResponse(totalSpace = 1000000u, freeSpace = 500000u) + val bytes = ProtoBuf.encodeToByteArray(response) + val decoded = ProtoBuf.decodeFromByteArray(bytes) + assertEquals(1000000u, decoded.totalSpace) + assertEquals(500000u, decoded.freeSpace) + } + + @Test + fun testSystemDeviceInfoResponseRoundTrip() { + val response = SystemDeviceInfoResponse(key = "hardware.model", value = "Flipper Zero") + val bytes = ProtoBuf.encodeToByteArray(response) + val decoded = ProtoBuf.decodeFromByteArray(bytes) + assertEquals("hardware.model", decoded.key) + assertEquals("Flipper Zero", decoded.value) + } +} diff --git a/flipper/src/iosMain/kotlin/com/codebutler/farebot/flipper/IosBleSerialTransport.kt b/flipper/src/iosMain/kotlin/com/codebutler/farebot/flipper/IosBleSerialTransport.kt new file mode 100644 index 000000000..796aae8a1 --- /dev/null +++ b/flipper/src/iosMain/kotlin/com/codebutler/farebot/flipper/IosBleSerialTransport.kt @@ -0,0 +1,249 @@ +package com.codebutler.farebot.flipper + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.withTimeout +import platform.CoreBluetooth.CBCentralManager +import platform.CoreBluetooth.CBCentralManagerDelegateProtocol +import platform.CoreBluetooth.CBCentralManagerStatePoweredOn +import platform.CoreBluetooth.CBCharacteristic +import platform.CoreBluetooth.CBCharacteristicWriteWithResponse +import platform.CoreBluetooth.CBPeripheral +import platform.CoreBluetooth.CBPeripheralDelegateProtocol +import platform.CoreBluetooth.CBService +import platform.CoreBluetooth.CBUUID +import platform.Foundation.NSData +import platform.Foundation.NSError +import platform.Foundation.NSNumber +import platform.darwin.NSObject +import platform.posix.memcpy +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.usePinned +import kotlinx.cinterop.allocArrayOf +import kotlinx.cinterop.memScoped +import platform.Foundation.create + +/** + * FlipperTransport implementation using iOS Core Bluetooth. + * Connects to Flipper Zero's BLE Serial service. + */ +@OptIn(ExperimentalForeignApi::class) +class IosBleSerialTransport( + private val peripheral: CBPeripheral? = null, +) : FlipperTransport { + companion object { + val SERIAL_SERVICE_UUID: CBUUID = CBUUID.UUIDWithString("8fe5b3d5-2e7f-4a98-2a48-7acc60fe0000") + val SERIAL_RX_UUID: CBUUID = CBUUID.UUIDWithString("19ed82ae-ed21-4c9d-4145-228e62fe0000") + val SERIAL_TX_UUID: CBUUID = CBUUID.UUIDWithString("19ed82ae-ed21-4c9d-4145-228e63fe0000") + private const val SCAN_TIMEOUT_MS = 15_000L + private const val CONNECT_TIMEOUT_MS = 10_000L + } + + private var centralManager: CBCentralManager? = null + private var connectedPeripheral: CBPeripheral? = null + private var rxCharacteristic: CBCharacteristic? = null + private var txCharacteristic: CBCharacteristic? = null + private val receiveChannel = Channel(Channel.UNLIMITED) + + private var connectionDeferred: CompletableDeferred? = null + private var servicesDeferred: CompletableDeferred? = null + private var scanDeferred: CompletableDeferred? = null + + override val isConnected: Boolean + get() = connectedPeripheral != null + + override suspend fun connect() { + val target = peripheral ?: scanForFlipper() + + connectionDeferred = CompletableDeferred() + servicesDeferred = CompletableDeferred() + + val manager = centralManager ?: CBCentralManager(delegate = centralDelegate, queue = null) + centralManager = manager + + target.delegate = peripheralDelegate + connectedPeripheral = target + + manager.connectPeripheral(target, options = null) + + withTimeout(CONNECT_TIMEOUT_MS) { + connectionDeferred!!.await() + } + + target.discoverServices(listOf(SERIAL_SERVICE_UUID)) + + withTimeout(CONNECT_TIMEOUT_MS) { + servicesDeferred!!.await() + } + + // Enable notifications on TX characteristic + val tx = txCharacteristic + ?: throw FlipperException("TX characteristic not found") + target.setNotifyValue(true, forCharacteristic = tx) + } + + override suspend fun read(buffer: ByteArray, offset: Int, length: Int): Int { + val data = receiveChannel.receive() + val bytesToCopy = minOf(data.size, length) + data.copyInto(buffer, offset, 0, bytesToCopy) + return bytesToCopy + } + + override suspend fun write(data: ByteArray) { + val peripheral = connectedPeripheral ?: throw FlipperException("Not connected") + val rx = rxCharacteristic ?: throw FlipperException("RX characteristic not found") + + val nsData = data.toNSData() + peripheral.writeValue(nsData, forCharacteristic = rx, type = CBCharacteristicWriteWithResponse) + } + + override suspend fun close() { + val peripheral = connectedPeripheral ?: return + centralManager?.cancelPeripheralConnection(peripheral) + connectedPeripheral = null + rxCharacteristic = null + txCharacteristic = null + receiveChannel.close() + } + + private suspend fun scanForFlipper(): CBPeripheral { + scanDeferred = CompletableDeferred() + + val manager = CBCentralManager(delegate = centralDelegate, queue = null) + centralManager = manager + + return withTimeout(SCAN_TIMEOUT_MS) { + // Wait for powered on state + if (manager.state != CBCentralManagerStatePoweredOn) { + // Central delegate will start scan when powered on + } + manager.scanForPeripheralsWithServices( + serviceUUIDs = listOf(SERIAL_SERVICE_UUID), + options = null, + ) + try { + scanDeferred!!.await() + } finally { + manager.stopScan() + } + } + } + + private val centralDelegate = object : NSObject(), CBCentralManagerDelegateProtocol { + override fun centralManagerDidUpdateState(central: CBCentralManager) { + if (central.state == CBCentralManagerStatePoweredOn) { + if (scanDeferred != null && scanDeferred?.isCompleted == false) { + central.scanForPeripheralsWithServices( + serviceUUIDs = listOf(SERIAL_SERVICE_UUID), + options = null, + ) + } + } + } + + override fun centralManager( + central: CBCentralManager, + didDiscoverPeripheral: CBPeripheral, + advertisementData: Map, + RSSI: NSNumber, + ) { + scanDeferred?.complete(didDiscoverPeripheral) + } + + override fun centralManager(central: CBCentralManager, didConnectPeripheral: CBPeripheral) { + connectionDeferred?.complete(Unit) + } + + override fun centralManager( + central: CBCentralManager, + didFailToConnectPeripheral: CBPeripheral, + error: NSError?, + ) { + connectionDeferred?.completeExceptionally( + FlipperException("BLE connection failed: ${error?.localizedDescription}"), + ) + } + + override fun centralManager( + central: CBCentralManager, + didDisconnectPeripheral: CBPeripheral, + error: NSError?, + ) { + connectedPeripheral = null + } + } + + private val peripheralDelegate = object : NSObject(), CBPeripheralDelegateProtocol { + override fun peripheral(peripheral: CBPeripheral, didDiscoverServices: NSError?) { + if (didDiscoverServices != null) { + servicesDeferred?.completeExceptionally( + FlipperException("Service discovery failed: ${didDiscoverServices.localizedDescription}"), + ) + return + } + + val service = peripheral.services?.firstOrNull { (it as? CBService)?.UUID == SERIAL_SERVICE_UUID } as? CBService + if (service != null) { + peripheral.discoverCharacteristics( + listOf(SERIAL_RX_UUID, SERIAL_TX_UUID), + forService = service, + ) + } else { + servicesDeferred?.completeExceptionally( + FlipperException("Serial service not found"), + ) + } + } + + override fun peripheral(peripheral: CBPeripheral, didDiscoverCharacteristicsForService: CBService, error: NSError?) { + if (error != null) { + servicesDeferred?.completeExceptionally( + FlipperException("Characteristic discovery failed: ${error.localizedDescription}"), + ) + return + } + + val characteristics = didDiscoverCharacteristicsForService.characteristics ?: emptyList() + for (char in characteristics) { + val characteristic = char as? CBCharacteristic ?: continue + when (characteristic.UUID) { + SERIAL_RX_UUID -> rxCharacteristic = characteristic + SERIAL_TX_UUID -> txCharacteristic = characteristic + } + } + + servicesDeferred?.complete(Unit) + } + + override fun peripheral(peripheral: CBPeripheral, didUpdateValueForCharacteristic: CBCharacteristic, error: NSError?) { + if (error != null) return + if (didUpdateValueForCharacteristic.UUID == SERIAL_TX_UUID) { + val nsData = didUpdateValueForCharacteristic.value ?: return + val bytes = nsData.toByteArray() + if (bytes.isNotEmpty()) { + receiveChannel.trySend(bytes) + } + } + } + } +} + +@OptIn(ExperimentalForeignApi::class) +private fun ByteArray.toNSData(): NSData = memScoped { + if (isEmpty()) return NSData() + usePinned { pinned -> + NSData.create(bytes = pinned.addressOf(0), length = size.toULong()) + } +} + +@OptIn(ExperimentalForeignApi::class) +private fun NSData.toByteArray(): ByteArray { + val size = length.toInt() + if (size == 0) return byteArrayOf() + val bytes = ByteArray(size) + bytes.usePinned { pinned -> + memcpy(pinned.addressOf(0), this@toByteArray.bytes, length) + } + return bytes +} diff --git a/flipper/src/iosMain/kotlin/com/codebutler/farebot/flipper/IosFlipperTransportFactory.kt b/flipper/src/iosMain/kotlin/com/codebutler/farebot/flipper/IosFlipperTransportFactory.kt new file mode 100644 index 000000000..e0842d104 --- /dev/null +++ b/flipper/src/iosMain/kotlin/com/codebutler/farebot/flipper/IosFlipperTransportFactory.kt @@ -0,0 +1,11 @@ +package com.codebutler.farebot.flipper + +class IosFlipperTransportFactory : FlipperTransportFactory { + override val isUsbSupported: Boolean = false + override val isBleSupported: Boolean = true + + override suspend fun createUsbTransport(): FlipperTransport? = null + + override suspend fun createBleTransport(): FlipperTransport = + IosBleSerialTransport() +} diff --git a/flipper/src/jvmMain/kotlin/com/codebutler/farebot/flipper/JvmFlipperTransportFactory.kt b/flipper/src/jvmMain/kotlin/com/codebutler/farebot/flipper/JvmFlipperTransportFactory.kt new file mode 100644 index 000000000..3e036dac2 --- /dev/null +++ b/flipper/src/jvmMain/kotlin/com/codebutler/farebot/flipper/JvmFlipperTransportFactory.kt @@ -0,0 +1,11 @@ +package com.codebutler.farebot.flipper + +class JvmFlipperTransportFactory : FlipperTransportFactory { + override val isUsbSupported: Boolean = true + override val isBleSupported: Boolean = false + + override suspend fun createUsbTransport(): FlipperTransport = + JvmUsbSerialTransport() + + override suspend fun createBleTransport(): FlipperTransport? = null +} diff --git a/flipper/src/jvmMain/kotlin/com/codebutler/farebot/flipper/JvmUsbSerialTransport.kt b/flipper/src/jvmMain/kotlin/com/codebutler/farebot/flipper/JvmUsbSerialTransport.kt new file mode 100644 index 000000000..286aec945 --- /dev/null +++ b/flipper/src/jvmMain/kotlin/com/codebutler/farebot/flipper/JvmUsbSerialTransport.kt @@ -0,0 +1,70 @@ +package com.codebutler.farebot.flipper + +import com.fazecast.jSerialComm.SerialPort + +/** + * FlipperTransport implementation using jSerialComm for Desktop JVM. + * Finds and connects to the Flipper Zero's CDC virtual serial port. + */ +class JvmUsbSerialTransport( + private val portDescriptor: String? = null, +) : FlipperTransport { + companion object { + private const val FLIPPER_VID = 0x0483 + private const val FLIPPER_PID = 0x5740 + private const val BAUD_RATE = 230400 + private const val READ_TIMEOUT_MS = 5000 + } + + private var serialPort: SerialPort? = null + + override val isConnected: Boolean + get() = serialPort?.isOpen == true + + override suspend fun connect() { + val port = if (portDescriptor != null) { + SerialPort.getCommPort(portDescriptor) + } else { + findFlipperPort() + ?: throw FlipperException("Flipper Zero not found. Is it connected via USB?") + } + + port.baudRate = BAUD_RATE + port.setComPortTimeouts(SerialPort.TIMEOUT_READ_SEMI_BLOCKING, READ_TIMEOUT_MS, 0) + + if (!port.openPort()) { + throw FlipperException("Failed to open serial port: ${port.systemPortName}") + } + + serialPort = port + } + + override suspend fun read(buffer: ByteArray, offset: Int, length: Int): Int { + val port = serialPort ?: throw FlipperException("Not connected") + val tempBuffer = ByteArray(length) + val bytesRead = port.readBytes(tempBuffer, length) + if (bytesRead <= 0) { + throw FlipperException("Serial read failed or timed out") + } + tempBuffer.copyInto(buffer, offset, 0, bytesRead) + return bytesRead + } + + override suspend fun write(data: ByteArray) { + val port = serialPort ?: throw FlipperException("Not connected") + val written = port.writeBytes(data, data.size) + if (written < 0) { + throw FlipperException("Serial write failed") + } + } + + override suspend fun close() { + serialPort?.closePort() + serialPort = null + } + + private fun findFlipperPort(): SerialPort? = + SerialPort.getCommPorts().firstOrNull { port -> + port.vendorID == FLIPPER_VID && port.productID == FLIPPER_PID + } +} diff --git a/flipper/src/wasmJsMain/kotlin/com/codebutler/farebot/flipper/WebBleTransport.kt b/flipper/src/wasmJsMain/kotlin/com/codebutler/farebot/flipper/WebBleTransport.kt new file mode 100644 index 000000000..526246b91 --- /dev/null +++ b/flipper/src/wasmJsMain/kotlin/com/codebutler/farebot/flipper/WebBleTransport.kt @@ -0,0 +1,232 @@ +@file:OptIn(ExperimentalWasmJsInterop::class) + +package com.codebutler.farebot.flipper + +import kotlinx.coroutines.delay +import kotlin.js.ExperimentalWasmJsInterop + +/** + * FlipperTransport implementation using the Web Bluetooth API. + * Connects to Flipper Zero's BLE Serial service. + * + * Requires Chrome/Edge with Web Bluetooth API support. + * Must be initiated from a user gesture (button click). + */ +class WebBleTransport : FlipperTransport { + companion object { + private const val SERIAL_SERVICE_UUID = "8fe5b3d5-2e7f-4a98-2a48-7acc60fe0000" + private const val SERIAL_RX_UUID = "19ed82ae-ed21-4c9d-4145-228e62fe0000" + private const val SERIAL_TX_UUID = "19ed82ae-ed21-4c9d-4145-228e63fe0000" + private const val POLL_INTERVAL_MS = 10L + private const val READ_TIMEOUT_MS = 5000 + } + + private var connected = false + + override val isConnected: Boolean + get() = connected + + override suspend fun connect() { + if (!jsHasWebBluetooth()) { + throw FlipperException("Web Bluetooth API not available. Use Chrome or Edge.") + } + + jsWebBleRequestDevice() + + while (!jsWebBleIsReady()) { + delay(POLL_INTERVAL_MS) + } + + if (!jsWebBleHasDevice()) { + throw FlipperException("No Flipper Zero device selected") + } + + jsWebBleConnect() + + while (!jsWebBleIsConnected()) { + delay(POLL_INTERVAL_MS) + } + + val error = jsWebBleGetConnectError()?.toString() + if (error != null) { + throw FlipperException("BLE connection failed: $error") + } + + connected = true + } + + override suspend fun read(buffer: ByteArray, offset: Int, length: Int): Int { + var elapsed = 0L + while (jsWebBleAvailable() == 0) { + delay(POLL_INTERVAL_MS) + elapsed += POLL_INTERVAL_MS + if (elapsed > READ_TIMEOUT_MS) { + throw FlipperException("BLE read timed out") + } + } + + jsWebBleStartRead(length) + val csv = jsWebBleGetReadResult()?.toString() + ?: throw FlipperException("BLE read returned no data") + if (csv.isEmpty()) throw FlipperException("BLE read returned empty data") + + val bytes = csv.split(",").map { it.toInt().toByte() }.toByteArray() + bytes.copyInto(buffer, offset, 0, bytes.size) + return bytes.size + } + + override suspend fun write(data: ByteArray) { + val csv = data.joinToString(",") { (it.toInt() and 0xFF).toString() } + jsWebBleStartWrite(csv.toJsString()) + + while (!jsWebBleIsWriteReady()) { + delay(POLL_INTERVAL_MS) + } + + val error = jsWebBleGetWriteError()?.toString() + if (error != null) { + throw FlipperException("BLE write failed: $error") + } + } + + override suspend fun close() { + if (connected) { + jsWebBleDisconnect() + connected = false + } + } +} + +// --- Web Bluetooth JS interop --- + +private fun jsHasWebBluetooth(): Boolean = + js("typeof navigator !== 'undefined' && typeof navigator.bluetooth !== 'undefined'") + +private fun jsWebBleRequestDevice() { + js( + """ + (function() { + window._fbBle = { device: null, server: null, rxChar: null, txChar: null, ready: false, connected: false, connectError: null, buffer: [], writeReady: false, writeError: null }; + navigator.bluetooth.requestDevice({ + filters: [{ services: ['8fe5b3d5-2e7f-4a98-2a48-7acc60fe0000'] }] + }).then(function(device) { + window._fbBle.device = device; + window._fbBle.ready = true; + }).catch(function(err) { + console.error('Web Bluetooth requestDevice failed:', err); + window._fbBle.ready = true; + }); + })() + """, + ) +} + +private fun jsWebBleIsReady(): Boolean = + js("window._fbBle && window._fbBle.ready === true") + +private fun jsWebBleHasDevice(): Boolean = + js("window._fbBle && window._fbBle.device !== null") + +private fun jsWebBleConnect() { + js( + """ + (function() { + var ble = window._fbBle; + ble.device.gatt.connect().then(function(server) { + ble.server = server; + return server.getPrimaryService('8fe5b3d5-2e7f-4a98-2a48-7acc60fe0000'); + }).then(function(service) { + return Promise.all([ + service.getCharacteristic('19ed82ae-ed21-4c9d-4145-228e62fe0000'), + service.getCharacteristic('19ed82ae-ed21-4c9d-4145-228e63fe0000') + ]); + }).then(function(chars) { + ble.rxChar = chars[0]; + ble.txChar = chars[1]; + return ble.txChar.startNotifications(); + }).then(function() { + ble.txChar.addEventListener('characteristicvaluechanged', function(event) { + var value = event.target.value; + var arr = new Uint8Array(value.buffer); + for (var i = 0; i < arr.length; i++) { + ble.buffer.push(arr[i]); + } + }); + ble.connected = true; + }).catch(function(err) { + ble.connectError = err.message || 'Unknown error'; + ble.connected = true; + }); + })() + """, + ) +} + +private fun jsWebBleIsConnected(): Boolean = + js("window._fbBle && window._fbBle.connected === true") + +private fun jsWebBleGetConnectError(): JsString? = + js("(window._fbBle && window._fbBle.connectError) || null") + +private fun jsWebBleAvailable(): Int = + js("(window._fbBle && window._fbBle.buffer) ? window._fbBle.buffer.length : 0") + +private fun jsWebBleStartRead(length: Int) { + js( + """ + (function() { + var buf = window._fbBle.buffer; + var toRead = Math.min(buf.length, length); + var parts = []; + for (var i = 0; i < toRead; i++) parts.push(buf.shift()); + window._fbBleReadResult = parts.join(','); + })() + """, + ) +} + +private fun jsWebBleGetReadResult(): JsString? = + js("window._fbBleReadResult || null") + +private fun jsWebBleStartWrite(dataStr: JsString) { + js( + """ + (function() { + window._fbBle.writeReady = false; + window._fbBle.writeError = null; + var parts = dataStr.split(','); + var bytes = new Uint8Array(parts.length); + for (var i = 0; i < parts.length; i++) bytes[i] = parseInt(parts[i]); + window._fbBle.rxChar.writeValue(bytes).then(function() { + window._fbBle.writeReady = true; + }).catch(function(err) { + window._fbBle.writeError = err.message; + window._fbBle.writeReady = true; + }); + })() + """, + ) +} + +private fun jsWebBleIsWriteReady(): Boolean = + js("window._fbBle && window._fbBle.writeReady === true") + +private fun jsWebBleGetWriteError(): JsString? = + js("(window._fbBle && window._fbBle.writeError) || null") + +private fun jsWebBleDisconnect() { + js( + """ + (function() { + try { + if (window._fbBle && window._fbBle.server) { + window._fbBle.server.disconnect(); + } + } catch(e) { + console.error('Web Bluetooth disconnect error:', e); + } + window._fbBle = null; + })() + """, + ) +} diff --git a/flipper/src/wasmJsMain/kotlin/com/codebutler/farebot/flipper/WebFlipperTransportFactory.kt b/flipper/src/wasmJsMain/kotlin/com/codebutler/farebot/flipper/WebFlipperTransportFactory.kt new file mode 100644 index 000000000..6205890a9 --- /dev/null +++ b/flipper/src/wasmJsMain/kotlin/com/codebutler/farebot/flipper/WebFlipperTransportFactory.kt @@ -0,0 +1,12 @@ +package com.codebutler.farebot.flipper + +class WebFlipperTransportFactory : FlipperTransportFactory { + override val isUsbSupported: Boolean = true + override val isBleSupported: Boolean = true + + override suspend fun createUsbTransport(): FlipperTransport = + WebSerialTransport() + + override suspend fun createBleTransport(): FlipperTransport = + WebBleTransport() +} diff --git a/flipper/src/wasmJsMain/kotlin/com/codebutler/farebot/flipper/WebSerialTransport.kt b/flipper/src/wasmJsMain/kotlin/com/codebutler/farebot/flipper/WebSerialTransport.kt new file mode 100644 index 000000000..1f16868ba --- /dev/null +++ b/flipper/src/wasmJsMain/kotlin/com/codebutler/farebot/flipper/WebSerialTransport.kt @@ -0,0 +1,222 @@ +@file:OptIn(ExperimentalWasmJsInterop::class) + +package com.codebutler.farebot.flipper + +import kotlinx.coroutines.delay +import kotlin.js.ExperimentalWasmJsInterop + +/** + * FlipperTransport implementation using the Web Serial API. + * Connects to Flipper Zero's CDC serial port via navigator.serial. + * + * Requires Chrome/Edge with Web Serial API support. + * Must be initiated from a user gesture (button click). + */ +class WebSerialTransport : FlipperTransport { + companion object { + private const val POLL_INTERVAL_MS = 10L + private const val READ_TIMEOUT_MS = 5000 + } + + private var opened = false + + override val isConnected: Boolean + get() = opened + + /** + * Request a serial port from the user and open it. + * Must be called from a user gesture context (button click). + */ + override suspend fun connect() { + if (!jsHasWebSerial()) { + throw FlipperException("Web Serial API not available. Use Chrome or Edge.") + } + + jsWebSerialRequestPort() + + while (!jsWebSerialIsReady()) { + delay(POLL_INTERVAL_MS) + } + + if (!jsWebSerialHasPort()) { + throw FlipperException("No serial port selected") + } + + jsWebSerialOpen() + + while (!jsWebSerialIsOpen()) { + delay(POLL_INTERVAL_MS) + } + + opened = true + } + + override suspend fun read(buffer: ByteArray, offset: Int, length: Int): Int { + jsWebSerialStartRead(length) + + var elapsed = 0L + while (!jsWebSerialIsReadReady()) { + delay(POLL_INTERVAL_MS) + elapsed += POLL_INTERVAL_MS + if (elapsed > READ_TIMEOUT_MS) { + throw FlipperException("Serial read timed out") + } + } + + val csv = jsWebSerialGetReadData()?.toString() ?: throw FlipperException("Serial read returned no data") + if (csv.isEmpty()) throw FlipperException("Serial read returned empty data") + + val bytes = csv.split(",").map { it.toInt().toByte() }.toByteArray() + bytes.copyInto(buffer, offset, 0, bytes.size) + return bytes.size + } + + override suspend fun write(data: ByteArray) { + val csv = data.joinToString(",") { (it.toInt() and 0xFF).toString() } + jsWebSerialStartWrite(csv.toJsString()) + + while (!jsWebSerialIsWriteReady()) { + delay(POLL_INTERVAL_MS) + } + + val error = jsWebSerialGetWriteError()?.toString() + if (error != null) { + throw FlipperException("Serial write failed: $error") + } + } + + override suspend fun close() { + if (opened) { + jsWebSerialClose() + opened = false + } + } +} + +// --- Web Serial JS interop --- + +private fun jsHasWebSerial(): Boolean = + js("typeof navigator !== 'undefined' && typeof navigator.serial !== 'undefined'") + +private fun jsWebSerialRequestPort() { + js( + """ + (function() { + window._fbSerial = { port: null, ready: false, open: false }; + navigator.serial.requestPort({ + filters: [{ usbVendorId: 0x0483, usbProductId: 0x5740 }] + }).then(function(port) { + window._fbSerial.port = port; + window._fbSerial.ready = true; + }).catch(function(err) { + console.error('Web Serial requestPort failed:', err); + window._fbSerial.ready = true; + }); + })() + """, + ) +} + +private fun jsWebSerialIsReady(): Boolean = + js("window._fbSerial && window._fbSerial.ready === true") + +private fun jsWebSerialHasPort(): Boolean = + js("window._fbSerial && window._fbSerial.port !== null") + +private fun jsWebSerialOpen() { + js( + """ + (function() { + window._fbSerial.port.open({ baudRate: 230400 }).then(function() { + window._fbSerial.reader = window._fbSerial.port.readable.getReader(); + window._fbSerial.open = true; + }).catch(function(err) { + console.error('Web Serial open failed:', err); + }); + })() + """, + ) +} + +private fun jsWebSerialIsOpen(): Boolean = + js("window._fbSerial && window._fbSerial.open === true") + +private fun jsWebSerialStartRead(length: Int) { + js( + """ + (function() { + window._fbSerialIn = { data: null, ready: false }; + window._fbSerial.reader.read().then(function(result) { + if (result.value && result.value.length > 0) { + var arr = result.value; + var parts = []; + var len = Math.min(arr.length, length); + for (var i = 0; i < len; i++) parts.push(arr[i]); + window._fbSerialIn.data = parts.join(','); + } + window._fbSerialIn.ready = true; + }).catch(function(err) { + console.error('Web Serial read error:', err); + window._fbSerialIn.ready = true; + }); + })() + """, + ) +} + +private fun jsWebSerialIsReadReady(): Boolean = + js("window._fbSerialIn && window._fbSerialIn.ready === true") + +private fun jsWebSerialGetReadData(): JsString? = + js("(window._fbSerialIn && window._fbSerialIn.data) || null") + +private fun jsWebSerialStartWrite(dataStr: JsString) { + js( + """ + (function() { + window._fbSerialOut = { ready: false, error: null }; + var parts = dataStr.split(','); + var bytes = new Uint8Array(parts.length); + for (var i = 0; i < parts.length; i++) bytes[i] = parseInt(parts[i]); + var writer = window._fbSerial.port.writable.getWriter(); + writer.write(bytes).then(function() { + writer.releaseLock(); + window._fbSerialOut.ready = true; + }).catch(function(err) { + writer.releaseLock(); + window._fbSerialOut.error = err.message; + window._fbSerialOut.ready = true; + }); + })() + """, + ) +} + +private fun jsWebSerialIsWriteReady(): Boolean = + js("window._fbSerialOut && window._fbSerialOut.ready === true") + +private fun jsWebSerialGetWriteError(): JsString? = + js("(window._fbSerialOut && window._fbSerialOut.error) || null") + +private fun jsWebSerialClose() { + js( + """ + (function() { + try { + if (window._fbSerial && window._fbSerial.reader) { + window._fbSerial.reader.cancel(); + window._fbSerial.reader.releaseLock(); + } + if (window._fbSerial && window._fbSerial.port) { + window._fbSerial.port.close(); + } + } catch(e) { + console.error('Web Serial close error:', e); + } + window._fbSerial = null; + window._fbSerialIn = null; + window._fbSerialOut = null; + })() + """, + ) +} diff --git a/settings.gradle.kts b/settings.gradle.kts index c2a452943..e8009b8cb 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -130,6 +130,7 @@ include(":transit:warsaw") include(":transit:yargor") include(":transit:yvr-compass") include(":transit:zolotayakorona") +include(":flipper") include(":app") include(":app:android") include(":app:desktop") From f649f509b8381394d7d84e9d913eb28a8d6b7be9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Feb 2026 05:28:52 -0800 Subject: [PATCH 3/5] fix(flipper): iOS BLE compilation and README update - Add @ObjCSignatureOverride to conflicting CoreBluetooth delegate methods (centralManager and peripheral overloads) - Fix characteristics list fallback (use early return instead of emptyList()) - Add Flipper Zero integration section to README - Add flipper/ to project structure in README Co-Authored-By: Claude Opus 4.6 --- README.md | 393 +++++++++--------- .../farebot/desktop/DesktopAppGraph.kt | 4 +- .../farebot/app/core/di/AndroidAppGraph.kt | 4 +- .../farebot/persist/CardKeysPersister.kt | 5 +- .../farebot/persist/db/DbCardKeysPersister.kt | 5 +- .../com/codebutler/farebot/shared/App.kt | 2 +- .../codebutler/farebot/shared/di/AppGraph.kt | 2 +- .../farebot/shared/ui/screen/FlipperScreen.kt | 29 +- .../shared/viewmodel/FlipperViewModel.kt | 155 ++++--- .../farebot/shared/di/IosAppGraph.kt | 4 +- .../web/LocalStorageCardKeysPersister.kt | 5 +- .../com/codebutler/farebot/web/WebAppGraph.kt | 4 +- .../card/classic/ClassicCardReaderTest.kt | 3 +- .../flipper/AndroidBleSerialTransport.kt | 133 +++--- .../flipper/AndroidFlipperTransportFactory.kt | 6 +- .../flipper/AndroidUsbSerialTransport.kt | 59 ++- .../farebot/flipper/FlipperKeyDictParser.kt | 4 +- .../farebot/flipper/FlipperRpcClient.kt | 51 ++- .../farebot/flipper/FlipperTransport.kt | 10 +- .../com/codebutler/farebot/flipper/Varint.kt | 6 +- .../farebot/flipper/proto/FlipperMain.kt | 7 +- .../farebot/flipper/proto/FlipperStorage.kt | 16 +- .../farebot/flipper/FlipperIntegrationTest.kt | 276 ++++++------ .../flipper/FlipperKeyDictParserTest.kt | 32 +- .../farebot/flipper/FlipperRpcClientTest.kt | 235 ++++++----- .../farebot/flipper/MockTransport.kt | 6 +- .../codebutler/farebot/flipper/VarintTest.kt | 1 - .../farebot/flipper/proto/FlipperProtoTest.kt | 26 +- .../farebot/flipper/IosBleSerialTransport.kt | 215 +++++----- .../flipper/IosFlipperTransportFactory.kt | 3 +- .../flipper/JvmFlipperTransportFactory.kt | 3 +- .../farebot/flipper/JvmUsbSerialTransport.kt | 19 +- .../farebot/flipper/WebBleTransport.kt | 35 +- .../flipper/WebFlipperTransportFactory.kt | 6 +- .../farebot/flipper/WebSerialTransport.kt | 27 +- 35 files changed, 979 insertions(+), 812 deletions(-) diff --git a/README.md b/README.md index 5a2d38612..16f1d0930 100644 --- a/README.md +++ b/README.md @@ -1,176 +1,229 @@ -# FareBot +

+ FareBot +

-Read your remaining balance, recent trips, and other information from contactless public transit cards using your NFC-enabled Android or iOS device. +

FareBot

-FareBot is a [Kotlin Multiplatform](https://kotlinlang.org/docs/multiplatform.html) app built with [Compose Multiplatform](https://www.jetbrains.com/compose-multiplatform/), targeting Android (NFC), iOS (CoreNFC), macOS (experimental, via PC/SC smart card readers or PN533 raw USB NFC controllers), and Web (experimental, via WebAssembly). +

+ Read your remaining balance, recent trips, and other information from contactless public transit cards using your NFC-enabled device. +

-## Platform Compatibility +

+ Android +    + iOS +    + Web +

+ +FareBot runs on: + +- **Android** — built-in NFC (6.0+) +- **iOS** — built-in NFC (iPhone 7+) +- **macOS** (experimental) — PC/SC smart card readers or PN533 USB NFC readers +- **Web** (experimental) — PN533 USB NFC readers (Chrome/Edge/Opera) + +## Download + + +- **Android:** Coming soon on Google Play +- **iOS:** Coming soon on the App Store +- **Web:** Coming soon +- **Build from source:** See [Building](#building) + +## Written By + +* [Eric Butler](https://x.com/codebutler) + +## Thanks To -| Protocol | Android | iOS | -|----------|---------|-----| -| [CEPAS](https://en.wikipedia.org/wiki/CEPAS) | Yes | Yes | -| [FeliCa](https://en.wikipedia.org/wiki/FeliCa) | Yes | Yes | -| [ISO 7816](https://en.wikipedia.org/wiki/ISO/IEC_7816) | Yes | Yes | -| [MIFARE Classic](https://en.wikipedia.org/wiki/MIFARE#MIFARE_Classic) | NXP NFC chips only | No | -| [MIFARE DESFire](https://en.wikipedia.org/wiki/MIFARE#MIFARE_DESFire) | Yes | Yes | -| [MIFARE Ultralight](https://en.wikipedia.org/wiki/MIFARE#MIFARE_Ultralight_and_MIFARE_Ultralight_EV1) | Yes | Yes | -| [NFC-V / Vicinity](https://en.wikipedia.org/wiki/Near-field_communication#Standards) | Yes | Yes | +> [!NOTE] +> Huge thanks to [the Metrodroid project](https://github.com/metrodroid/metrodroid), a fork of FareBot that added support for many additional transit systems. All features as of [v3.1.0 (`04a603ba`)](https://github.com/metrodroid/metrodroid/commit/04a603ba639f) have been backported. -MIFARE Classic requires proprietary NXP hardware and is not supported on iOS or on Android devices with non-NXP NFC controllers (e.g. most Samsung and some other devices). All other protocols work on both platforms. Cards marked **Android only** in the tables below use MIFARE Classic. +* [Karl Koscher](https://x.com/supersat) (ORCA) +* [Sean Cross](https://x.com/xobs) (CEPAS/EZ-Link) +* Anonymous Contributor (Clipper) +* [nfc-felica](http://code.google.com/p/nfc-felica/) and [IC SFCard Fan](http://www014.upp.so-net.ne.jp/SFCardFan/) projects (Suica) +* [Wilbert Duijvenvoorde](https://github.com/wandcode) (MIFARE Classic/OV-chipkaart) +* [tbonang](https://github.com/tbonang) (NETS FlashPay) +* [Marcelo Liberato](https://github.com/mliberato) (Bilhete Unico) +* [Lauri Andler](https://github.com/landler/) (HSL) +* [Michael Farrell](https://github.com/micolous/) (Opal, Manly Fast Ferry, Go card, Myki, Octopus) +* [Rob O'Regan](http://www.robx1.net/nswtkt/private/manlyff/manlyff.htm) (Manly Fast Ferry card image) +* [b33f](http://www.fuzzysecurity.com/tutorials/rfid/4.html) (EasyCard) +* [Bondan Sumbodo](http://sybond.web.id) (Kartu Multi Trip, COMMET) ## Supported Cards ### Asia -| Card | Location | Protocol | Platform | -|------|----------|----------|----------| -| [Beijing Municipal Card](https://en.wikipedia.org/wiki/Yikatong) | Beijing, China | ISO 7816 | Android, iOS | -| [City Union](https://en.wikipedia.org/wiki/China_T-Union) | China | ISO 7816 | Android, iOS | -| [Edy](https://en.wikipedia.org/wiki/Edy) | Japan | FeliCa | Android, iOS | -| [EZ-Link](http://www.ezlink.com.sg/) | Singapore | CEPAS | Android, iOS | -| [Kartu Multi Trip](https://en.wikipedia.org/wiki/Kereta_Commuter_Indonesia) | Jakarta, Indonesia | FeliCa | Android, iOS | -| [KomuterLink](https://en.wikipedia.org/wiki/KTM_Komuter) | Malaysia | Classic | Android only | -| [NETS FlashPay](https://www.nets.com.sg/) | Singapore | CEPAS | Android, iOS | -| [Octopus](https://www.octopus.com.hk/) | Hong Kong | FeliCa | Android, iOS | -| [One Card All Pass](https://en.wikipedia.org/wiki/One_Card_All_Pass) | South Korea | ISO 7816 | Android, iOS | -| [Shanghai Public Transportation Card](https://en.wikipedia.org/wiki/Shanghai_Public_Transportation_Card) | Shanghai, China | ISO 7816 | Android, iOS | -| [Shenzhen Tong](https://en.wikipedia.org/wiki/Shenzhen_Tong) | Shenzhen, China | ISO 7816 | Android, iOS | -| [Suica](https://en.wikipedia.org/wiki/Suica) / ICOCA / PASMO | Japan | FeliCa | Android, iOS | -| [T-money](https://en.wikipedia.org/wiki/T-money) | South Korea | ISO 7816 | Android, iOS | -| [T-Union](https://en.wikipedia.org/wiki/China_T-Union) | China | ISO 7816 | Android, iOS | -| [Touch 'n Go](https://www.touchngo.com.my/) | Malaysia | Classic | Android only | -| [Wuhan Tong](https://en.wikipedia.org/wiki/Wuhan_Metro) | Wuhan, China | ISO 7816 | Android, iOS | +| Card | Location | Protocol | Android | iOS | macOS | Web | +|------|----------|----------|---------|-----|-------|-----| +| [Beijing Municipal Card](https://en.wikipedia.org/wiki/Yikatong) | Beijing, China | ISO 7816 | ✅ | ✅ | ✅ | ✅ | +| [City Union](https://en.wikipedia.org/wiki/China_T-Union) | China | ISO 7816 | ✅ | ✅ | ✅ | ✅ | +| [Edy](https://en.wikipedia.org/wiki/Edy) | Japan | FeliCa | ✅ | ✅ | ✅ | ✅ | +| [EZ-Link](http://www.ezlink.com.sg/) | Singapore | CEPAS | ✅ | ✅ | ✅ | ✅ | +| [Kartu Multi Trip](https://en.wikipedia.org/wiki/Kereta_Commuter_Indonesia) | Jakarta, Indonesia | FeliCa | ✅ | ✅ | ✅ | ✅ | +| [KomuterLink](https://en.wikipedia.org/wiki/KTM_Komuter) | Malaysia | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [NETS FlashPay](https://www.nets.com.sg/) | Singapore | CEPAS | ✅ | ✅ | ✅ | ✅ | +| [Octopus](https://www.octopus.com.hk/) | Hong Kong | FeliCa | ✅ | ✅ | ✅ | ✅ | +| [One Card All Pass](https://en.wikipedia.org/wiki/One_Card_All_Pass) | South Korea | ISO 7816 | ✅ | ✅ | ✅ | ✅ | +| [Shanghai Public Transportation Card](https://en.wikipedia.org/wiki/Shanghai_Public_Transportation_Card) | Shanghai, China | ISO 7816 | ✅ | ✅ | ✅ | ✅ | +| [Shenzhen Tong](https://en.wikipedia.org/wiki/Shenzhen_Tong) | Shenzhen, China | ISO 7816 | ✅ | ✅ | ✅ | ✅ | +| [Suica](https://en.wikipedia.org/wiki/Suica) / ICOCA / PASMO | Japan | FeliCa | ✅ | ✅ | ✅ | ✅ | +| [T-money](https://en.wikipedia.org/wiki/T-money) | South Korea | ISO 7816 | ✅ | ✅ | ✅ | ✅ | +| [T-Union](https://en.wikipedia.org/wiki/China_T-Union) | China | ISO 7816 | ✅ | ✅ | ✅ | ✅ | +| [Touch 'n Go](https://www.touchngo.com.my/) | Malaysia | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Wuhan Tong](https://en.wikipedia.org/wiki/Wuhan_Metro) | Wuhan, China | ISO 7816 | ✅ | ✅ | ✅ | ✅ | ### Australia & New Zealand -| Card | Location | Protocol | Platform | -|------|----------|----------|----------| -| [Adelaide Metrocard](https://www.adelaidemetro.com.au/) | Adelaide, SA | DESFire | Android, iOS | -| [BUSIT](https://www.busit.co.nz/) | Waikato, NZ | Classic | Android only | -| [Manly Fast Ferry](http://www.manlyfastferry.com.au/) | Sydney, NSW | Classic | Android only | -| [Metrocard](https://www.metroinfo.co.nz/) | Christchurch, NZ | Classic | Android only | -| [Myki](https://www.ptv.vic.gov.au/tickets/myki/) | Melbourne, VIC | DESFire | Android, iOS | -| [Opal](https://www.opal.com.au/) | Sydney, NSW | DESFire | Android, iOS | -| [Otago GoCard](https://www.orc.govt.nz/) | Otago, NZ | Classic | Android only | -| [SeqGo](https://translink.com.au/) | Queensland | Classic | Android only | -| [SmartRide](https://www.busit.co.nz/) | Rotorua, NZ | Classic | Android only | -| [SmartRider](https://www.transperth.wa.gov.au/) | Perth, WA | Classic | Android only | -| [Snapper](https://www.snapper.co.nz/) | Wellington, NZ | ISO 7816 | Android, iOS | +| Card | Location | Protocol | Android | iOS | macOS | Web | +|------|----------|----------|---------|-----|-------|-----| +| [Adelaide Metrocard](https://www.adelaidemetro.com.au/) | Adelaide, SA | DESFire | ✅ | ✅ | ✅ | ✅ | +| [BUSIT](https://www.busit.co.nz/) | Waikato, NZ | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Manly Fast Ferry](http://www.manlyfastferry.com.au/) | Sydney, NSW | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Metrocard](https://www.metroinfo.co.nz/) | Christchurch, NZ | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Myki](https://www.ptv.vic.gov.au/tickets/myki/) | Melbourne, VIC | DESFire | ✅ | ✅ | ✅ | ✅ | +| [Opal](https://www.opal.com.au/) | Sydney, NSW | DESFire | ✅ | ✅ | ✅ | ✅ | +| [Otago GoCard](https://www.orc.govt.nz/) | Otago, NZ | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [SeqGo](https://translink.com.au/) | Queensland | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [SmartRide](https://www.busit.co.nz/) | Rotorua, NZ | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [SmartRider](https://www.transperth.wa.gov.au/) | Perth, WA | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Snapper](https://www.snapper.co.nz/) | Wellington, NZ | ISO 7816 | ✅ | ✅ | ✅ | ✅ | ### Europe -| Card | Location | Protocol | Platform | -|------|----------|----------|----------| -| [Bonobus](https://www.bonobus.es/) | Cadiz, Spain | Classic | Android only | -| [Carta Mobile](https://www.at-bus.it/) | Pisa, Italy | ISO 7816 (Calypso) | Android, iOS | -| [Envibus](https://www.envibus.fr/) | Sophia Antipolis, France | ISO 7816 (Calypso) | Android, iOS | -| [HSL](https://www.hsl.fi/) | Helsinki, Finland | DESFire | Android, iOS | -| [KorriGo](https://www.star.fr/) | Brittany, France | ISO 7816 (Calypso) | Android, iOS | -| [Leap](https://www.leapcard.ie/) | Dublin, Ireland | DESFire | Android, iOS | -| [Lisboa Viva](https://www.portalviva.pt/) | Lisbon, Portugal | ISO 7816 (Calypso) | Android, iOS | -| [Mobib](https://mobib.be/) | Brussels, Belgium | ISO 7816 (Calypso) | Android, iOS | -| [Navigo](https://www.iledefrance-mobilites.fr/) | Paris, France | ISO 7816 (Calypso) | Android, iOS | -| [OuRA](https://www.oura.com/) | Grenoble, France | ISO 7816 (Calypso) | Android, iOS | -| [OV-chipkaart](https://www.ov-chipkaart.nl/) | Netherlands | Classic / Ultralight | Android only (Classic), Android + iOS (Ultralight) | -| [Oyster](https://oyster.tfl.gov.uk/) | London, UK | Classic | Android only | -| [Pass Pass](https://www.passpass.fr/) | Hauts-de-France, France | ISO 7816 (Calypso) | Android, iOS | -| [Pastel](https://www.tisseo.fr/) | Toulouse, France | ISO 7816 (Calypso) | Android, iOS | -| [Rejsekort](https://www.rejsekort.dk/) | Denmark | Classic | Android only | -| [RicaricaMi](https://www.atm.it/) | Milan, Italy | Classic | Android only | -| [SLaccess](https://sl.se/) | Stockholm, Sweden | Classic | Android only | -| [TaM](https://www.tam-voyages.com/) | Montpellier, France | ISO 7816 (Calypso) | Android, iOS | -| [Tampere](https://www.nysse.fi/) | Tampere, Finland | DESFire | Android, iOS | -| [Tartu Bus](https://www.tartu.ee/) | Tartu, Estonia | Classic | Android only | -| [TransGironde](https://transgironde.fr/) | Gironde, France | ISO 7816 (Calypso) | Android, iOS | -| [Västtrafik](https://www.vasttrafik.se/) | Gothenburg, Sweden | Classic | Android only | -| [Venezia Unica](https://actv.avmspa.it/) | Venice, Italy | ISO 7816 (Calypso) | Android, iOS | -| [Waltti](https://waltti.fi/) | Finland | DESFire | Android, iOS | -| [Warsaw](https://www.ztm.waw.pl/) | Warsaw, Poland | Classic | Android only | +| Card | Location | Protocol | Android | iOS | macOS | Web | +|------|----------|----------|---------|-----|-------|-----| +| [Bonobus](https://www.bonobus.es/) | Cadiz, Spain | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Carta Mobile](https://www.at-bus.it/) | Pisa, Italy | ISO 7816 (Calypso) | ✅ | ✅ | ✅ | ✅ | +| [Envibus](https://www.envibus.fr/) | Sophia Antipolis, France | ISO 7816 (Calypso) | ✅ | ✅ | ✅ | ✅ | +| [HSL](https://www.hsl.fi/) | Helsinki, Finland | DESFire | ✅ | ✅ | ✅ | ✅ | +| [KorriGo](https://www.star.fr/) | Brittany, France | ISO 7816 (Calypso) | ✅ | ✅ | ✅ | ✅ | +| [Leap](https://www.leapcard.ie/) | Dublin, Ireland | DESFire | ✅ | ✅ | ✅ | ✅ | +| [Lisboa Viva](https://www.portalviva.pt/) | Lisbon, Portugal | ISO 7816 (Calypso) | ✅ | ✅ | ✅ | ✅ | +| [Mobib](https://mobib.be/) | Brussels, Belgium | ISO 7816 (Calypso) | ✅ | ✅ | ✅ | ✅ | +| [Navigo](https://www.iledefrance-mobilites.fr/) | Paris, France | ISO 7816 (Calypso) | ✅ | ✅ | ✅ | ✅ | +| [OuRA](https://www.oura.com/) | Grenoble, France | ISO 7816 (Calypso) | ✅ | ✅ | ✅ | ✅ | +| [OV-chipkaart](https://www.ov-chipkaart.nl/) | Netherlands | Classic 🔒 / Ultralight | ✅ | ✅³ | ✅ | ✅ | +| [Oyster](https://oyster.tfl.gov.uk/) | London, UK | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Pass Pass](https://www.passpass.fr/) | Hauts-de-France, France | ISO 7816 (Calypso) | ✅ | ✅ | ✅ | ✅ | +| [Pastel](https://www.tisseo.fr/) | Toulouse, France | ISO 7816 (Calypso) | ✅ | ✅ | ✅ | ✅ | +| [Rejsekort](https://www.rejsekort.dk/) | Denmark | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [RicaricaMi](https://www.atm.it/) | Milan, Italy | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [SLaccess](https://sl.se/) | Stockholm, Sweden | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [TaM](https://www.tam-voyages.com/) | Montpellier, France | ISO 7816 (Calypso) | ✅ | ✅ | ✅ | ✅ | +| [Tampere](https://www.nysse.fi/) | Tampere, Finland | DESFire | ✅ | ✅ | ✅ | ✅ | +| [Tartu Bus](https://www.tartu.ee/) | Tartu, Estonia | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [TransGironde](https://transgironde.fr/) | Gironde, France | ISO 7816 (Calypso) | ✅ | ✅ | ✅ | ✅ | +| [Västtrafik](https://www.vasttrafik.se/) | Gothenburg, Sweden | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Venezia Unica](https://actv.avmspa.it/) | Venice, Italy | ISO 7816 (Calypso) | ✅ | ✅ | ✅ | ✅ | +| [Waltti](https://waltti.fi/) | Finland | DESFire | ✅ | ✅ | ✅ | ✅ | +| [Warsaw](https://www.ztm.waw.pl/) | Warsaw, Poland | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | ### Middle East & Africa -| Card | Location | Protocol | Platform | -|------|----------|----------|----------| -| [Gautrain](https://www.gautrain.co.za/) | Gauteng, South Africa | Classic | Android only | -| [Hafilat](https://www.dot.abudhabi/) | Abu Dhabi, UAE | DESFire | Android, iOS | -| [Metro Q](https://www.qr.com.qa/) | Qatar | Classic | Android only | -| [RavKav](https://ravkav.co.il/) | Israel | ISO 7816 (Calypso) | Android, iOS | +| Card | Location | Protocol | Android | iOS | macOS | Web | +|------|----------|----------|---------|-----|-------|-----| +| [Gautrain](https://www.gautrain.co.za/) | Gauteng, South Africa | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Hafilat](https://www.dot.abudhabi/) | Abu Dhabi, UAE | DESFire | ✅ | ✅ | ✅ | ✅ | +| [Metro Q](https://www.qr.com.qa/) | Qatar | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [RavKav](https://ravkav.co.il/) | Israel | ISO 7816 (Calypso) | ✅ | ✅ | ✅ | ✅ | ### North America -| Card | Location | Protocol | Platform | -|------|----------|----------|----------| -| [Charlie Card](https://www.mbta.com/fares/charliecard) | Boston, MA | Classic | Android only | -| [Clipper](https://www.clippercard.com/) | San Francisco, CA | DESFire / Ultralight | Android, iOS | -| [Compass](https://www.compasscard.ca/) | Vancouver, Canada | Ultralight | Android, iOS | -| [LAX TAP](https://www.taptogo.net/) | Los Angeles, CA | Classic | Android only | -| [MSP GoTo](https://www.metrotransit.org/) | Minneapolis, MN | Classic | Android only | -| [Opus](https://www.stm.info/) | Montreal, Canada | ISO 7816 (Calypso) | Android, iOS | -| [ORCA](https://www.orcacard.com/) | Seattle, WA | DESFire | Android, iOS | -| [Ventra](https://www.ventrachicago.com/) | Chicago, IL | Ultralight | Android, iOS | +| Card | Location | Protocol | Android | iOS | macOS | Web | +|------|----------|----------|---------|-----|-------|-----| +| [Charlie Card](https://www.mbta.com/fares/charliecard) | Boston, MA | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Clipper](https://www.clippercard.com/) | San Francisco, CA | DESFire / Ultralight | ✅ | ✅ | ✅ | ✅ | +| [Compass](https://www.compasscard.ca/) | Vancouver, Canada | Ultralight | ✅ | ✅ | ✅ | ✅ | +| [LAX TAP](https://www.taptogo.net/) | Los Angeles, CA | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [MSP GoTo](https://www.metrotransit.org/) | Minneapolis, MN | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Opus](https://www.stm.info/) | Montreal, Canada | ISO 7816 (Calypso) | ✅ | ✅ | ✅ | ✅ | +| [ORCA](https://www.orcacard.com/) | Seattle, WA | DESFire | ✅ | ✅ | ✅ | ✅ | +| [Ventra](https://www.ventrachicago.com/) | Chicago, IL | Ultralight | ✅ | ✅ | ✅ | ✅ | ### Russia & Former Soviet Union -| Card | Location | Protocol | Platform | -|------|----------|----------|----------| -| [Crimea Trolleybus Card](https://www.korona.net/) | Crimea | Classic | Android only | -| [Ekarta](https://www.korona.net/) | Yekaterinburg, Russia | Classic | Android only | -| [Electronic Barnaul](https://umarsh.com/) | Barnaul, Russia | Classic | Android only | -| [Kazan](https://en.wikipedia.org/wiki/Kazan_Metro) | Kazan, Russia | Classic | Android only | -| [Kirov transport card](https://umarsh.com/) | Kirov, Russia | Classic | Android only | -| [Krasnodar ETK](https://www.korona.net/) | Krasnodar, Russia | Classic | Android only | -| [Kyiv Digital](https://www.eway.in.ua/) | Kyiv, Ukraine | Classic | Android only | -| [Kyiv Metro](https://www.eway.in.ua/) | Kyiv, Ukraine | Classic | Android only | -| [MetroMoney](https://www.tbilisi.gov.ge/) | Tbilisi, Georgia | Classic | Android only | -| [OMKA](https://umarsh.com/) | Omsk, Russia | Classic | Android only | -| [Orenburg EKG](https://www.korona.net/) | Orenburg, Russia | Classic | Android only | -| [Parus school card](https://www.korona.net/) | Crimea | Classic | Android only | -| [Penza transport card](https://umarsh.com/) | Penza, Russia | Classic | Android only | -| [Podorozhnik](https://podorozhnik.spb.ru/) | St. Petersburg, Russia | Classic | Android only | -| [Samara ETK](https://www.korona.net/) | Samara, Russia | Classic | Android only | -| [SitiCard](https://umarsh.com/) | Nizhniy Novgorod, Russia | Classic | Android only | -| [SitiCard (Vladimir)](https://umarsh.com/) | Vladimir, Russia | Classic | Android only | -| [Strizh](https://umarsh.com/) | Izhevsk, Russia | Classic | Android only | -| [Troika](https://troika.mos.ru/) | Moscow, Russia | Classic / Ultralight | Android only (Classic), Android + iOS (Ultralight) | -| [YarGor](https://yargor.ru/) | Yaroslavl, Russia | Classic | Android only | -| [Yaroslavl ETK](https://www.korona.net/) | Yaroslavl, Russia | Classic | Android only | -| [Yoshkar-Ola transport card](https://umarsh.com/) | Yoshkar-Ola, Russia | Classic | Android only | -| [Zolotaya Korona](https://www.korona.net/) | Russia | Classic | Android only | +| Card | Location | Protocol | Android | iOS | macOS | Web | +|------|----------|----------|---------|-----|-------|-----| +| [Crimea Trolleybus Card](https://www.korona.net/) | Crimea | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Ekarta](https://www.korona.net/) | Yekaterinburg, Russia | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Electronic Barnaul](https://umarsh.com/) | Barnaul, Russia | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Kazan](https://en.wikipedia.org/wiki/Kazan_Metro) | Kazan, Russia | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Kirov transport card](https://umarsh.com/) | Kirov, Russia | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Krasnodar ETK](https://www.korona.net/) | Krasnodar, Russia | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Kyiv Digital](https://www.eway.in.ua/) | Kyiv, Ukraine | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Kyiv Metro](https://www.eway.in.ua/) | Kyiv, Ukraine | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [MetroMoney](https://www.tbilisi.gov.ge/) | Tbilisi, Georgia | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [OMKA](https://umarsh.com/) | Omsk, Russia | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Orenburg EKG](https://www.korona.net/) | Orenburg, Russia | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Parus school card](https://www.korona.net/) | Crimea | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Penza transport card](https://umarsh.com/) | Penza, Russia | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Podorozhnik](https://podorozhnik.spb.ru/) | St. Petersburg, Russia | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Samara ETK](https://www.korona.net/) | Samara, Russia | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [SitiCard](https://umarsh.com/) | Nizhniy Novgorod, Russia | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [SitiCard (Vladimir)](https://umarsh.com/) | Vladimir, Russia | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Strizh](https://umarsh.com/) | Izhevsk, Russia | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Troika](https://troika.mos.ru/) | Moscow, Russia | Classic 🔒 / Ultralight | ✅ | ✅³ | ✅ | ✅ | +| [YarGor](https://yargor.ru/) | Yaroslavl, Russia | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Yaroslavl ETK](https://www.korona.net/) | Yaroslavl, Russia | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Yoshkar-Ola transport card](https://umarsh.com/) | Yoshkar-Ola, Russia | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Zolotaya Korona](https://www.korona.net/) | Russia | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | ### South America -| Card | Location | Protocol | Platform | -|------|----------|----------|----------| -| [Bilhete Único](http://www.sptrans.com.br/bilhete_unico/) | São Paulo, Brazil | Classic | Android only | -| [Bip!](https://www.red.cl/tarjeta-bip) | Santiago, Chile | Classic | Android only | +| Card | Location | Protocol | Android | iOS | macOS | Web | +|------|----------|----------|---------|-----|-------|-----| +| [Bilhete Único](http://www.sptrans.com.br/bilhete_unico/) | São Paulo, Brazil | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Bip!](https://www.red.cl/tarjeta-bip) | Santiago, Chile | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | ### Taiwan -| Card | Location | Protocol | Platform | -|------|----------|----------|----------| -| [EasyCard](https://www.easycard.com.tw/) | Taipei | Classic / DESFire | Android only (Classic), Android + iOS (DESFire) | +| Card | Location | Protocol | Android | iOS | macOS | Web | +|------|----------|----------|---------|-----|-------|-----| +| [EasyCard](https://www.easycard.com.tw/) | Taipei | Classic 🔒 / DESFire | ✅ | ✅⁴ | ✅ | ✅ | ### Identification Only (Serial Number) These cards can be detected and identified, but their data is locked or not stored on-card: -| Card | Location | Protocol | Platform | Reason | -|------|----------|----------|----------|--------| -| [AT HOP](https://at.govt.nz/bus-train-ferry/at-hop-card/) | Auckland, NZ | DESFire | Android, iOS | Locked | -| [Holo](https://www.holocard.net/) | Oahu, HI | DESFire | Android, iOS | Not stored on card | -| [Istanbul Kart](https://www.istanbulkart.istanbul/) | Istanbul, Turkey | DESFire | Android, iOS | Locked | -| [Nextfare DESFire](https://en.wikipedia.org/wiki/Cubic_Transportation_Systems) | Various | DESFire | Android, iOS | Locked | -| [Nol](https://www.nol.ae/) | Dubai, UAE | DESFire | Android, iOS | Locked | -| [Nortic](https://rfrend.no/) | Scandinavia | DESFire | Android, iOS | Locked | -| [Presto](https://www.prestocard.ca/) | Ontario, Canada | DESFire | Android, iOS | Locked | -| [Strelka](https://strelkacard.ru/) | Moscow Region, Russia | Classic | Android only | Locked | -| [Sun Card](https://sunrail.com/) | Orlando, FL | Classic | Android only | Locked | -| [TPF](https://www.tpf.ch/) | Fribourg, Switzerland | DESFire | Android, iOS | Locked | -| [TriMet Hop](https://myhopcard.com/) | Portland, OR | DESFire | Android, iOS | Not stored on card | +| Card | Location | Protocol | Reason | Android | iOS | macOS | Web | +|------|----------|----------|--------|---------|-----|-------|-----| +| [AT HOP](https://at.govt.nz/bus-train-ferry/at-hop-card/) | Auckland, NZ | DESFire | Locked | ✅ | ✅ | ✅ | ✅ | +| [Holo](https://www.holocard.net/) | Oahu, HI | DESFire | Not stored on card | ✅ | ✅ | ✅ | ✅ | +| [Istanbul Kart](https://www.istanbulkart.istanbul/) | Istanbul, Turkey | DESFire | Locked | ✅ | ✅ | ✅ | ✅ | +| [Nextfare DESFire](https://en.wikipedia.org/wiki/Cubic_Transportation_Systems) | Various | DESFire | Locked | ✅ | ✅ | ✅ | ✅ | +| [Nol](https://www.nol.ae/) | Dubai, UAE | DESFire | Locked | ✅ | ✅ | ✅ | ✅ | +| [Nortic](https://rfrend.no/) | Scandinavia | DESFire | Locked | ✅ | ✅ | ✅ | ✅ | +| [Presto](https://www.prestocard.ca/) | Ontario, Canada | DESFire | Locked | ✅ | ✅ | ✅ | ✅ | +| [Strelka](https://strelkacard.ru/) | Moscow Region, Russia | Classic 🔒 | Locked | ✅¹ | ❌ | ✅ | ✅ | +| [Sun Card](https://sunrail.com/) | Orlando, FL | Classic 🔒 | Locked | ✅¹ | ❌ | ✅ | ✅ | +| [TPF](https://www.tpf.ch/) | Fribourg, Switzerland | DESFire | Locked | ✅ | ✅ | ✅ | ✅ | +| [TriMet Hop](https://myhopcard.com/) | Portland, OR | DESFire | Not stored on card | ✅ | ✅ | ✅ | ✅ | + +## Platform Compatibility + +| Protocol | Android | iOS | macOS | Web | +|----------|---------|-----|-------|-----| +| [CEPAS](https://en.wikipedia.org/wiki/CEPAS) | ✅ | ✅ | ✅ | ✅ | +| [FeliCa](https://en.wikipedia.org/wiki/FeliCa) | ✅ | ✅ | ✅ | ✅ | +| [ISO 7816](https://en.wikipedia.org/wiki/ISO/IEC_7816) | ✅ | ✅ | ✅ | ✅ | +| [MIFARE Classic](https://en.wikipedia.org/wiki/MIFARE#MIFARE_Classic) | ✅¹ | ❌ | ✅ | ✅ | +| [MIFARE DESFire](https://en.wikipedia.org/wiki/MIFARE#MIFARE_DESFire) | ✅ | ✅ | ✅ | ✅ | +| [MIFARE Ultralight](https://en.wikipedia.org/wiki/MIFARE#MIFARE_Ultralight_and_MIFARE_Ultralight_EV1) | ✅ | ✅ | ✅ | ✅ | +| [NFC-V / Vicinity](https://en.wikipedia.org/wiki/Near-field_communication#Standards) | ✅ | ✅ | ✅² | ❌ | + +¹ Requires NXP NFC chip — most Samsung and some other Android devices use non-NXP controllers and cannot read MIFARE Classic. +² PC/SC readers only. PN533-based USB readers do not support NFC-V. +³ Ultralight variant only. +⁴ DESFire variant only. +🔒 Requires encryption keys — see [Cards Requiring Keys](#cards-requiring-keys). ## Cards Requiring Keys -Some MIFARE Classic cards require encryption keys to read. You can obtain keys using a [Proxmark3](https://github.com/Proxmark/proxmark3/wiki/Mifare-HowTo) or [MFOC](https://github.com/nfc-tools/mfoc). These include: +Some MIFARE Classic cards require encryption keys to read. You can obtain keys using a [Flipper Zero](https://docs.flipper.net/nfc/mf-classic), [Proxmark3](https://github.com/Proxmark/proxmark3/wiki/Mifare-HowTo), or [MFOC](https://github.com/nfc-tools/mfoc). These include: * Bilhete Único * Charlie Card @@ -179,12 +232,18 @@ Some MIFARE Classic cards require encryption keys to read. You can obtain keys u * Oyster * And most other MIFARE Classic-based cards -## Requirements +## Flipper Zero Integration -* **Android:** NFC-enabled device running Android 6.0 (API 23) or later -* **iOS:** iPhone 7 or later with iOS support for CoreNFC -* **macOS** (experimental): Mac with a PC/SC-compatible NFC smart card reader (e.g., ACR122U), a PN533-based USB NFC controller (e.g., SCL3711), or a Sony RC-S956 (PaSoRi) USB NFC reader -* **Web** (experimental): Any modern browser with WebAssembly support. Card data can be imported from JSON files exported by other platforms. Live NFC card reading is supported in Chrome/Edge/Opera via WebUSB with a PN533-based USB NFC reader (e.g., SCL3711). +FareBot supports connecting to a [Flipper Zero](https://flipperzero.one/) to browse and import NFC card dumps and MIFARE Classic key dictionaries. + +| Platform | USB | Bluetooth | +|----------|-----|-----------| +| Android | Yes | Yes | +| iOS | — | Yes | +| macOS | Yes | — | +| Web | Yes | Yes | + +From the home screen menu, tap **Flipper Zero** to connect via USB serial or Bluetooth Low Energy, browse the `/ext/nfc` file system, select card dump files (`.nfc`), and import them into your card history. You can also import the Flipper user key dictionary (`mf_classic_dict_user.nfc`) into the app's global key store, which is used as a fallback when reading MIFARE Classic cards. ## Building @@ -207,38 +266,7 @@ $ make # show all targets | `make test` | Run all tests | | `make clean` | Clean all build artifacts | -## Development Container - -A devcontainer is provided for sandboxed development with [Claude Code](https://claude.com/claude-code). It runs Claude with `--dangerously-skip-permissions` inside a network-restricted Docker container so agents can work unattended without risk of arbitrary network access. - -### What's included - -* Bun runtime + Claude Code -* Java 21 + Gradle (via devcontainer feature) -* tmux, zsh, git-delta, fzf, gh CLI -* iptables firewall allowing only: Anthropic API, GitHub, Maven Central, Google Maven, Gradle Plugin Portal, JetBrains repos, npm/bun registries -* All other outbound traffic is blocked - -### Quick start - -```bash -bun install -g @devcontainers/cli # one-time -.devcontainer/dc up # build and start -.devcontainer/dc auth # one-time: authenticate with GitHub -.devcontainer/dc claude # run Claude (--dangerously-skip-permissions, in tmux) -``` - -The `dc claude` command runs Claude inside a tmux session. Re-running it reattaches to the existing session instead of starting a new one. Other commands: - -``` -.devcontainer/dc shell # zsh shell in the container -.devcontainer/dc run # run any command (e.g. ./gradlew allTests) -.devcontainer/dc down # stop the container -``` - -Git push uses HTTPS via `gh auth` — no SSH keys are mounted. Credentials persist in a Docker volume across container restarts. - -Compatible with [Zed](https://zed.dev/docs/dev-containers), VS Code (Remote - Containers extension), and the `devcontainer` CLI. +A [development container](.devcontainer/README.md) is available for sandboxed development with Claude Code. ## Tech Stack @@ -256,34 +284,13 @@ Compatible with [Zed](https://zed.dev/docs/dev-containers), VS Code (Remote - Co - `card/*/` — Card protocol implementations (classic, desfire, felica, etc.) - `transit/` — Shared transit abstractions (Trip, Station, TransitInfo, etc.) - `transit/*/` — Transit system implementations (one per system) +- `flipper/` — Flipper Zero integration (RPC client, transport abstractions, parsers) - `app/` — KMP app framework (UI, ViewModels, DI, platform code) - `app/android/` — Android app shell (Activities, manifest, resources) - `app/ios/` — iOS app shell (Swift entry point, assets, config) - `app/desktop/` — macOS desktop app (experimental, PC/SC + PN533 + RC-S956 USB NFC) - `app/web/` — Web app (experimental, WebAssembly via Kotlin/Wasm) -## Written By - -* [Eric Butler](https://x.com/codebutler) - -## Thanks To - -> [!NOTE] -> Huge thanks to [the Metrodroid project](https://github.com/metrodroid/metrodroid), a fork of FareBot that added support for many additional transit systems. All features as of [v3.1.0 (`04a603ba`)](https://github.com/metrodroid/metrodroid/commit/04a603ba639f) have been backported. - -* [Karl Koscher](https://x.com/supersat) (ORCA) -* [Sean Cross](https://x.com/xobs) (CEPAS/EZ-Link) -* Anonymous Contributor (Clipper) -* [nfc-felica](http://code.google.com/p/nfc-felica/) and [IC SFCard Fan](http://www014.upp.so-net.ne.jp/SFCardFan/) projects (Suica) -* [Wilbert Duijvenvoorde](https://github.com/wandcode) (MIFARE Classic/OV-chipkaart) -* [tbonang](https://github.com/tbonang) (NETS FlashPay) -* [Marcelo Liberato](https://github.com/mliberato) (Bilhete Unico) -* [Lauri Andler](https://github.com/landler/) (HSL) -* [Michael Farrell](https://github.com/micolous/) (Opal, Manly Fast Ferry, Go card, Myki, Octopus) -* [Rob O'Regan](http://www.robx1.net/nswtkt/private/manlyff/manlyff.htm) (Manly Fast Ferry card image) -* [b33f](http://www.fuzzysecurity.com/tutorials/rfid/4.html) (EasyCard) -* [Bondan Sumbodo](http://sybond.web.id) (Kartu Multi Trip, COMMET) - ## License This program is free software: you can redistribute it and/or modify diff --git a/app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/DesktopAppGraph.kt b/app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/DesktopAppGraph.kt index 32b975a54..350bb4d92 100644 --- a/app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/DesktopAppGraph.kt +++ b/app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/DesktopAppGraph.kt @@ -2,6 +2,8 @@ package com.codebutler.farebot.desktop import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver import com.codebutler.farebot.card.serialize.CardSerializer +import com.codebutler.farebot.flipper.FlipperTransportFactory +import com.codebutler.farebot.flipper.JvmFlipperTransportFactory import com.codebutler.farebot.persist.CardKeysPersister import com.codebutler.farebot.persist.CardPersister import com.codebutler.farebot.persist.db.DbCardKeysPersister @@ -15,8 +17,6 @@ import com.codebutler.farebot.shared.platform.Analytics import com.codebutler.farebot.shared.platform.AppPreferences import com.codebutler.farebot.shared.platform.JvmAppPreferences import com.codebutler.farebot.shared.platform.NoOpAnalytics -import com.codebutler.farebot.flipper.FlipperTransportFactory -import com.codebutler.farebot.flipper.JvmFlipperTransportFactory import com.codebutler.farebot.shared.serialize.CardImporter import com.codebutler.farebot.shared.serialize.FareBotSerializersModule import com.codebutler.farebot.shared.serialize.KotlinxCardSerializer diff --git a/app/src/androidMain/kotlin/com/codebutler/farebot/app/core/di/AndroidAppGraph.kt b/app/src/androidMain/kotlin/com/codebutler/farebot/app/core/di/AndroidAppGraph.kt index 22124edff..c66c4d3ed 100644 --- a/app/src/androidMain/kotlin/com/codebutler/farebot/app/core/di/AndroidAppGraph.kt +++ b/app/src/androidMain/kotlin/com/codebutler/farebot/app/core/di/AndroidAppGraph.kt @@ -7,6 +7,8 @@ import com.codebutler.farebot.app.core.nfc.TagReaderFactory import com.codebutler.farebot.app.core.platform.AndroidAppPreferences import com.codebutler.farebot.app.feature.home.AndroidCardScanner import com.codebutler.farebot.card.serialize.CardSerializer +import com.codebutler.farebot.flipper.AndroidFlipperTransportFactory +import com.codebutler.farebot.flipper.FlipperTransportFactory import com.codebutler.farebot.persist.CardKeysPersister import com.codebutler.farebot.persist.CardPersister import com.codebutler.farebot.persist.db.DbCardKeysPersister @@ -19,8 +21,6 @@ import com.codebutler.farebot.shared.nfc.CardScanner import com.codebutler.farebot.shared.platform.Analytics import com.codebutler.farebot.shared.platform.AppPreferences import com.codebutler.farebot.shared.platform.NoOpAnalytics -import com.codebutler.farebot.flipper.AndroidFlipperTransportFactory -import com.codebutler.farebot.flipper.FlipperTransportFactory import com.codebutler.farebot.shared.serialize.CardImporter import com.codebutler.farebot.shared.serialize.FareBotSerializersModule import com.codebutler.farebot.shared.serialize.KotlinxCardSerializer diff --git a/app/src/commonMain/kotlin/com/codebutler/farebot/persist/CardKeysPersister.kt b/app/src/commonMain/kotlin/com/codebutler/farebot/persist/CardKeysPersister.kt index d3114c9b1..9368f90dd 100644 --- a/app/src/commonMain/kotlin/com/codebutler/farebot/persist/CardKeysPersister.kt +++ b/app/src/commonMain/kotlin/com/codebutler/farebot/persist/CardKeysPersister.kt @@ -13,7 +13,10 @@ interface CardKeysPersister { fun getGlobalKeys(): List - fun insertGlobalKeys(keys: List, source: String) + fun insertGlobalKeys( + keys: List, + source: String, + ) fun deleteAllGlobalKeys() } diff --git a/app/src/commonMain/kotlin/com/codebutler/farebot/persist/db/DbCardKeysPersister.kt b/app/src/commonMain/kotlin/com/codebutler/farebot/persist/db/DbCardKeysPersister.kt index de81d5c9d..87eb03a70 100644 --- a/app/src/commonMain/kotlin/com/codebutler/farebot/persist/db/DbCardKeysPersister.kt +++ b/app/src/commonMain/kotlin/com/codebutler/farebot/persist/db/DbCardKeysPersister.kt @@ -45,7 +45,10 @@ class DbCardKeysPersister( .executeAsList() .map { hexToBytes(it.key_data) } - override fun insertGlobalKeys(keys: List, source: String) { + override fun insertGlobalKeys( + keys: List, + source: String, + ) { val now = Clock.System.now().toEpochMilliseconds() keys.forEach { key -> db.savedKeyQueries.insertGlobalKey( diff --git a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/App.kt b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/App.kt index c85399b99..6e2177dad 100644 --- a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/App.kt +++ b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/App.kt @@ -31,8 +31,8 @@ import com.codebutler.farebot.shared.ui.screen.AdvancedTab import com.codebutler.farebot.shared.ui.screen.CardAdvancedScreen import com.codebutler.farebot.shared.ui.screen.CardAdvancedUiState import com.codebutler.farebot.shared.ui.screen.CardScreen -import com.codebutler.farebot.shared.ui.screen.FlipperScreen import com.codebutler.farebot.shared.ui.screen.CardsMapMarker +import com.codebutler.farebot.shared.ui.screen.FlipperScreen import com.codebutler.farebot.shared.ui.screen.HomeScreen import com.codebutler.farebot.shared.ui.screen.KeysScreen import com.codebutler.farebot.shared.ui.screen.TripMapScreen diff --git a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/di/AppGraph.kt b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/di/AppGraph.kt index 0bf2bd5c2..9f4206adb 100644 --- a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/di/AppGraph.kt +++ b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/di/AppGraph.kt @@ -1,6 +1,7 @@ package com.codebutler.farebot.shared.di import com.codebutler.farebot.card.serialize.CardSerializer +import com.codebutler.farebot.flipper.FlipperTransportFactory import com.codebutler.farebot.persist.CardKeysPersister import com.codebutler.farebot.persist.CardPersister import com.codebutler.farebot.shared.core.NavDataHolder @@ -11,7 +12,6 @@ import com.codebutler.farebot.shared.serialize.CardImporter import com.codebutler.farebot.shared.transit.TransitFactoryRegistry import com.codebutler.farebot.shared.viewmodel.AddKeyViewModel import com.codebutler.farebot.shared.viewmodel.CardViewModel -import com.codebutler.farebot.flipper.FlipperTransportFactory import com.codebutler.farebot.shared.viewmodel.FlipperViewModel import com.codebutler.farebot.shared.viewmodel.HistoryViewModel import com.codebutler.farebot.shared.viewmodel.HomeViewModel diff --git a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/ui/screen/FlipperScreen.kt b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/ui/screen/FlipperScreen.kt index 6ff9492f8..d494b6293 100644 --- a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/ui/screen/FlipperScreen.kt +++ b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/ui/screen/FlipperScreen.kt @@ -15,9 +15,9 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.InsertDriveFile import androidx.compose.material.icons.filled.Bluetooth import androidx.compose.material.icons.filled.Folder -import androidx.compose.material.icons.automirrored.filled.InsertDriveFile import androidx.compose.material.icons.filled.Usb import androidx.compose.material3.Button import androidx.compose.material3.Checkbox @@ -95,9 +95,10 @@ fun FlipperScreen( } } }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - ), + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + ), ) }, ) { padding -> @@ -301,21 +302,23 @@ private fun FileListItem( onToggleSelection: () -> Unit, ) { Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onTap) - .padding(horizontal = 16.dp, vertical = 12.dp), + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onTap) + .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { Icon( imageVector = if (file.isDirectory) Icons.Default.Folder else Icons.AutoMirrored.Filled.InsertDriveFile, contentDescription = null, modifier = Modifier.size(24.dp), - tint = if (file.isDirectory) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, + tint = + if (file.isDirectory) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, ) Spacer(modifier = Modifier.width(12.dp)) diff --git a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/viewmodel/FlipperViewModel.kt b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/viewmodel/FlipperViewModel.kt index 80f46f9aa..400c94da4 100644 --- a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/viewmodel/FlipperViewModel.kt +++ b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/viewmodel/FlipperViewModel.kt @@ -4,8 +4,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.codebutler.farebot.base.util.hex import com.codebutler.farebot.card.serialize.CardSerializer -import com.codebutler.farebot.flipper.FlipperRpcClient import com.codebutler.farebot.flipper.FlipperKeyDictParser +import com.codebutler.farebot.flipper.FlipperRpcClient import com.codebutler.farebot.flipper.FlipperTransport import com.codebutler.farebot.flipper.FlipperTransportFactory import com.codebutler.farebot.persist.CardKeysPersister @@ -43,9 +43,10 @@ class FlipperViewModel( if (transport != null) { connect(transport) } else { - _uiState.value = _uiState.value.copy( - error = "USB transport not available on this platform", - ) + _uiState.value = + _uiState.value.copy( + error = "USB transport not available on this platform", + ) } } } @@ -56,9 +57,10 @@ class FlipperViewModel( if (transport != null) { connect(transport) } else { - _uiState.value = _uiState.value.copy( - error = "Bluetooth transport not available on this platform", - ) + _uiState.value = + _uiState.value.copy( + error = "Bluetooth transport not available on this platform", + ) } } } @@ -68,10 +70,11 @@ class FlipperViewModel( val client = FlipperRpcClient(transport) this.rpcClient = client - _uiState.value = _uiState.value.copy( - connectionState = FlipperConnectionState.Connecting, - error = null, - ) + _uiState.value = + _uiState.value.copy( + connectionState = FlipperConnectionState.Connecting, + error = null, + ) viewModelScope.launch { try { @@ -85,17 +88,19 @@ class FlipperViewModel( println("[FlipperViewModel] Failed to get device info: ${e.message}") } - _uiState.value = _uiState.value.copy( - connectionState = FlipperConnectionState.Connected, - deviceInfo = deviceInfo, - ) + _uiState.value = + _uiState.value.copy( + connectionState = FlipperConnectionState.Connected, + deviceInfo = deviceInfo, + ) navigateToDirectory("/ext/nfc") } catch (e: Exception) { - _uiState.value = _uiState.value.copy( - connectionState = FlipperConnectionState.Disconnected, - error = "Connection failed: ${e.message}", - ) + _uiState.value = + _uiState.value.copy( + connectionState = FlipperConnectionState.Disconnected, + error = "Connection failed: ${e.message}", + ) } } } @@ -120,26 +125,30 @@ class FlipperViewModel( viewModelScope.launch { try { val entries = client.listDirectory(path) - val files = entries.map { entry -> - FlipperFileItem( - name = entry.name, - isDirectory = entry.isDirectory, - size = entry.size, - path = "$path/${entry.name}", - ) - }.sortedWith(compareByDescending { it.isDirectory }.thenBy { it.name }) + val files = + entries + .map { entry -> + FlipperFileItem( + name = entry.name, + isDirectory = entry.isDirectory, + size = entry.size, + path = "$path/${entry.name}", + ) + }.sortedWith(compareByDescending { it.isDirectory }.thenBy { it.name }) - _uiState.value = _uiState.value.copy( - currentPath = path, - files = files, - isLoading = false, - selectedFiles = emptySet(), - ) + _uiState.value = + _uiState.value.copy( + currentPath = path, + files = files, + isLoading = false, + selectedFiles = emptySet(), + ) } catch (e: Exception) { - _uiState.value = _uiState.value.copy( - isLoading = false, - error = "Failed to list directory: ${e.message}", - ) + _uiState.value = + _uiState.value.copy( + isLoading = false, + error = "Failed to list directory: ${e.message}", + ) } } } @@ -154,11 +163,12 @@ class FlipperViewModel( fun toggleFileSelection(path: String) { val current = _uiState.value.selectedFiles - val newSelected = if (current.contains(path)) { - current - path - } else { - current + path - } + val newSelected = + if (current.contains(path)) { + current - path + } else { + current + path + } _uiState.value = _uiState.value.copy(selectedFiles = newSelected) } @@ -170,13 +180,15 @@ class FlipperViewModel( viewModelScope.launch { for ((index, path) in selectedPaths.withIndex()) { val fileName = path.substringAfterLast('/') - _uiState.value = _uiState.value.copy( - importProgress = ImportProgress( - currentFile = fileName, - currentIndex = index + 1, - totalFiles = selectedPaths.size, - ), - ) + _uiState.value = + _uiState.value.copy( + importProgress = + ImportProgress( + currentFile = fileName, + currentIndex = index + 1, + totalFiles = selectedPaths.size, + ), + ) try { val fileData = client.readFile(path) @@ -194,12 +206,13 @@ class FlipperViewModel( ) } if (result.classicKeys != null) { - val keys = result.classicKeys.keys.flatMap { sectorKey -> - listOfNotNull( - sectorKey.keyA.takeIf { it.any { b -> b != 0.toByte() } }, - sectorKey.keyB.takeIf { it.any { b -> b != 0.toByte() } }, - ) - } + val keys = + result.classicKeys.keys.flatMap { sectorKey -> + listOfNotNull( + sectorKey.keyA.takeIf { it.any { b -> b != 0.toByte() } }, + sectorKey.keyB.takeIf { it.any { b -> b != 0.toByte() } }, + ) + } if (keys.isNotEmpty()) { cardKeysPersister.insertGlobalKeys(keys, "flipper_nfc_dump") } @@ -210,10 +223,11 @@ class FlipperViewModel( } } - _uiState.value = _uiState.value.copy( - importProgress = null, - selectedFiles = emptySet(), - ) + _uiState.value = + _uiState.value.copy( + importProgress = null, + selectedFiles = emptySet(), + ) } } @@ -221,13 +235,15 @@ class FlipperViewModel( val client = rpcClient ?: return viewModelScope.launch { - _uiState.value = _uiState.value.copy( - importProgress = ImportProgress( - currentFile = "mf_classic_dict_user.nfc", - currentIndex = 1, - totalFiles = 1, - ), - ) + _uiState.value = + _uiState.value.copy( + importProgress = + ImportProgress( + currentFile = "mf_classic_dict_user.nfc", + currentIndex = 1, + totalFiles = 1, + ), + ) try { val dictPath = "/ext/nfc/assets/mf_classic_dict_user.nfc" @@ -239,9 +255,10 @@ class FlipperViewModel( cardKeysPersister.insertGlobalKeys(keys, "flipper_user_dict") } } catch (e: Exception) { - _uiState.value = _uiState.value.copy( - error = "Failed to import key dictionary: ${e.message}", - ) + _uiState.value = + _uiState.value.copy( + error = "Failed to import key dictionary: ${e.message}", + ) } _uiState.value = _uiState.value.copy(importProgress = null) diff --git a/app/src/iosMain/kotlin/com/codebutler/farebot/shared/di/IosAppGraph.kt b/app/src/iosMain/kotlin/com/codebutler/farebot/shared/di/IosAppGraph.kt index 09d3b7cc4..5534d2778 100644 --- a/app/src/iosMain/kotlin/com/codebutler/farebot/shared/di/IosAppGraph.kt +++ b/app/src/iosMain/kotlin/com/codebutler/farebot/shared/di/IosAppGraph.kt @@ -2,6 +2,8 @@ package com.codebutler.farebot.shared.di import com.codebutler.farebot.base.util.BundledDatabaseDriverFactory import com.codebutler.farebot.card.serialize.CardSerializer +import com.codebutler.farebot.flipper.FlipperTransportFactory +import com.codebutler.farebot.flipper.IosFlipperTransportFactory import com.codebutler.farebot.persist.CardKeysPersister import com.codebutler.farebot.persist.CardPersister import com.codebutler.farebot.persist.db.DbCardKeysPersister @@ -16,8 +18,6 @@ import com.codebutler.farebot.shared.platform.IosAppPreferences import com.codebutler.farebot.shared.platform.IosPlatformActions import com.codebutler.farebot.shared.platform.NoOpAnalytics import com.codebutler.farebot.shared.platform.PlatformActions -import com.codebutler.farebot.flipper.FlipperTransportFactory -import com.codebutler.farebot.flipper.IosFlipperTransportFactory import com.codebutler.farebot.shared.serialize.CardImporter import com.codebutler.farebot.shared.serialize.FareBotSerializersModule import com.codebutler.farebot.shared.serialize.KotlinxCardSerializer diff --git a/app/web/src/wasmJsMain/kotlin/com/codebutler/farebot/web/LocalStorageCardKeysPersister.kt b/app/web/src/wasmJsMain/kotlin/com/codebutler/farebot/web/LocalStorageCardKeysPersister.kt index 0a13f9d95..50275b6d2 100644 --- a/app/web/src/wasmJsMain/kotlin/com/codebutler/farebot/web/LocalStorageCardKeysPersister.kt +++ b/app/web/src/wasmJsMain/kotlin/com/codebutler/farebot/web/LocalStorageCardKeysPersister.kt @@ -95,7 +95,10 @@ class LocalStorageCardKeysPersister( } @OptIn(ExperimentalStdlibApi::class) - override fun insertGlobalKeys(keys: List, source: String) { + override fun insertGlobalKeys( + keys: List, + source: String, + ) { val existing = getGlobalKeys().map { it.toHexString() }.toMutableSet() keys.forEach { existing.add(it.toHexString()) } val serialized = json.encodeToString>(existing.toList()) diff --git a/app/web/src/wasmJsMain/kotlin/com/codebutler/farebot/web/WebAppGraph.kt b/app/web/src/wasmJsMain/kotlin/com/codebutler/farebot/web/WebAppGraph.kt index 1da4c5059..df5eb778e 100644 --- a/app/web/src/wasmJsMain/kotlin/com/codebutler/farebot/web/WebAppGraph.kt +++ b/app/web/src/wasmJsMain/kotlin/com/codebutler/farebot/web/WebAppGraph.kt @@ -1,6 +1,8 @@ package com.codebutler.farebot.web import com.codebutler.farebot.card.serialize.CardSerializer +import com.codebutler.farebot.flipper.FlipperTransportFactory +import com.codebutler.farebot.flipper.WebFlipperTransportFactory import com.codebutler.farebot.persist.CardKeysPersister import com.codebutler.farebot.persist.CardPersister import com.codebutler.farebot.shared.core.NavDataHolder @@ -10,8 +12,6 @@ import com.codebutler.farebot.shared.nfc.CardScanner import com.codebutler.farebot.shared.platform.Analytics import com.codebutler.farebot.shared.platform.AppPreferences import com.codebutler.farebot.shared.platform.NoOpAnalytics -import com.codebutler.farebot.flipper.FlipperTransportFactory -import com.codebutler.farebot.flipper.WebFlipperTransportFactory import com.codebutler.farebot.shared.serialize.CardImporter import com.codebutler.farebot.shared.serialize.FareBotSerializersModule import com.codebutler.farebot.shared.serialize.KotlinxCardSerializer diff --git a/card/classic/src/commonTest/kotlin/com/codebutler/farebot/card/classic/ClassicCardReaderTest.kt b/card/classic/src/commonTest/kotlin/com/codebutler/farebot/card/classic/ClassicCardReaderTest.kt index 4e3a7c1aa..71fb90536 100644 --- a/card/classic/src/commonTest/kotlin/com/codebutler/farebot/card/classic/ClassicCardReaderTest.kt +++ b/card/classic/src/commonTest/kotlin/com/codebutler/farebot/card/classic/ClassicCardReaderTest.kt @@ -271,7 +271,8 @@ class ClassicCardReaderTest { @Test fun testGlobalKeysUsedWhenCardKeysFail() = runTest { - val globalKey = byteArrayOf(0xAA.toByte(), 0xBB.toByte(), 0xCC.toByte(), 0xDD.toByte(), 0xEE.toByte(), 0xFF.toByte()) + val globalKey = + byteArrayOf(0xAA.toByte(), 0xBB.toByte(), 0xCC.toByte(), 0xDD.toByte(), 0xEE.toByte(), 0xFF.toByte()) val blockData = ByteArray(16) { 0x42 } val tech = diff --git a/flipper/src/androidMain/kotlin/com/codebutler/farebot/flipper/AndroidBleSerialTransport.kt b/flipper/src/androidMain/kotlin/com/codebutler/farebot/flipper/AndroidBleSerialTransport.kt index 52b14131f..4ecdb8726 100644 --- a/flipper/src/androidMain/kotlin/com/codebutler/farebot/flipper/AndroidBleSerialTransport.kt +++ b/flipper/src/androidMain/kotlin/com/codebutler/farebot/flipper/AndroidBleSerialTransport.kt @@ -3,7 +3,6 @@ package com.codebutler.farebot.flipper import android.annotation.SuppressLint -import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothGatt import android.bluetooth.BluetoothGattCallback @@ -56,47 +55,60 @@ class AndroidBleSerialTransport( val connectionDeferred = CompletableDeferred() val servicesDeferred = CompletableDeferred() - val callback = object : BluetoothGattCallback() { - override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { - if (newState == BluetoothProfile.STATE_CONNECTED) { - connectionDeferred.complete(Unit) - gatt.discoverServices() - } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { - if (!connectionDeferred.isCompleted) { - connectionDeferred.completeExceptionally(FlipperException("BLE connection failed (status $status)")) + val callback = + object : BluetoothGattCallback() { + override fun onConnectionStateChange( + gatt: BluetoothGatt, + status: Int, + newState: Int, + ) { + if (newState == BluetoothProfile.STATE_CONNECTED) { + connectionDeferred.complete(Unit) + gatt.discoverServices() + } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { + if (!connectionDeferred.isCompleted) { + connectionDeferred.completeExceptionally( + FlipperException("BLE connection failed (status $status)"), + ) + } } } - } - override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { - if (status == BluetoothGatt.GATT_SUCCESS) { - val service = gatt.getService(SERIAL_SERVICE_UUID) - if (service != null) { - rxCharacteristic = service.getCharacteristic(SERIAL_RX_UUID) - txCharacteristic = service.getCharacteristic(SERIAL_TX_UUID) - servicesDeferred.complete(Unit) + override fun onServicesDiscovered( + gatt: BluetoothGatt, + status: Int, + ) { + if (status == BluetoothGatt.GATT_SUCCESS) { + val service = gatt.getService(SERIAL_SERVICE_UUID) + if (service != null) { + rxCharacteristic = service.getCharacteristic(SERIAL_RX_UUID) + txCharacteristic = service.getCharacteristic(SERIAL_TX_UUID) + servicesDeferred.complete(Unit) + } else { + servicesDeferred.completeExceptionally( + FlipperException("Serial service not found on device"), + ) + } } else { servicesDeferred.completeExceptionally( - FlipperException("Serial service not found on device"), + FlipperException("Service discovery failed (status $status)"), ) } - } else { - servicesDeferred.completeExceptionally( - FlipperException("Service discovery failed (status $status)"), - ) } - } - @Deprecated("Deprecated in API 33") - override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { - if (characteristic.uuid == SERIAL_TX_UUID) { - val data = characteristic.value - if (data != null && data.isNotEmpty()) { - receiveChannel.trySend(data) + @Deprecated("Deprecated in API 33") + override fun onCharacteristicChanged( + gatt: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + ) { + if (characteristic.uuid == SERIAL_TX_UUID) { + val data = characteristic.value + if (data != null && data.isNotEmpty()) { + receiveChannel.trySend(data) + } } } } - } val bluetoothGatt = targetDevice.connectGatt(context, false, callback) this.gatt = bluetoothGatt @@ -108,8 +120,9 @@ class AndroidBleSerialTransport( bluetoothGatt.requestMtu(512) // Enable notifications on the TX characteristic - val tx = txCharacteristic - ?: throw FlipperException("TX characteristic not found") + val tx = + txCharacteristic + ?: throw FlipperException("TX characteristic not found") bluetoothGatt.setCharacteristicNotification(tx, true) val descriptor = tx.getDescriptor(CCCD_UUID) if (descriptor != null) { @@ -118,7 +131,11 @@ class AndroidBleSerialTransport( } } - override suspend fun read(buffer: ByteArray, offset: Int, length: Int): Int { + override suspend fun read( + buffer: ByteArray, + offset: Int, + length: Int, + ): Int { val data = receiveChannel.receive() val bytesToCopy = minOf(data.size, length) data.copyInto(buffer, offset, 0, bytesToCopy) @@ -145,8 +162,9 @@ class AndroidBleSerialTransport( private suspend fun scanForFlipper(): BluetoothDevice { val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager - val adapter = bluetoothManager.adapter - ?: throw FlipperException("Bluetooth not available") + val adapter = + bluetoothManager.adapter + ?: throw FlipperException("Bluetooth not available") if (!adapter.isEnabled) { throw FlipperException("Bluetooth is disabled") @@ -154,26 +172,35 @@ class AndroidBleSerialTransport( return withTimeout(SCAN_TIMEOUT_MS) { suspendCancellableCoroutine { cont -> - val scanner = adapter.bluetoothLeScanner - ?: throw FlipperException("BLE scanner not available") - - val callback = object : ScanCallback() { - override fun onScanResult(callbackType: Int, result: ScanResult) { - scanner.stopScan(this) - cont.resume(result.device) + val scanner = + adapter.bluetoothLeScanner + ?: throw FlipperException("BLE scanner not available") + + val callback = + object : ScanCallback() { + override fun onScanResult( + callbackType: Int, + result: ScanResult, + ) { + scanner.stopScan(this) + cont.resume(result.device) + } + + override fun onScanFailed(errorCode: Int) { + cont.resumeWithException(FlipperException("BLE scan failed (error $errorCode)")) + } } - override fun onScanFailed(errorCode: Int) { - cont.resumeWithException(FlipperException("BLE scan failed (error $errorCode)")) - } - } - - val filter = ScanFilter.Builder() - .setServiceUuid(ParcelUuid(SERIAL_SERVICE_UUID)) - .build() - val settings = ScanSettings.Builder() - .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) - .build() + val filter = + ScanFilter + .Builder() + .setServiceUuid(ParcelUuid(SERIAL_SERVICE_UUID)) + .build() + val settings = + ScanSettings + .Builder() + .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) + .build() scanner.startScan(listOf(filter), settings, callback) diff --git a/flipper/src/androidMain/kotlin/com/codebutler/farebot/flipper/AndroidFlipperTransportFactory.kt b/flipper/src/androidMain/kotlin/com/codebutler/farebot/flipper/AndroidFlipperTransportFactory.kt index 1a5d46994..50f23e735 100644 --- a/flipper/src/androidMain/kotlin/com/codebutler/farebot/flipper/AndroidFlipperTransportFactory.kt +++ b/flipper/src/androidMain/kotlin/com/codebutler/farebot/flipper/AndroidFlipperTransportFactory.kt @@ -8,9 +8,7 @@ class AndroidFlipperTransportFactory( override val isUsbSupported: Boolean = true override val isBleSupported: Boolean = true - override suspend fun createUsbTransport(): FlipperTransport = - AndroidUsbSerialTransport(context) + override suspend fun createUsbTransport(): FlipperTransport = AndroidUsbSerialTransport(context) - override suspend fun createBleTransport(): FlipperTransport = - AndroidBleSerialTransport(context) + override suspend fun createBleTransport(): FlipperTransport = AndroidBleSerialTransport(context) } diff --git a/flipper/src/androidMain/kotlin/com/codebutler/farebot/flipper/AndroidUsbSerialTransport.kt b/flipper/src/androidMain/kotlin/com/codebutler/farebot/flipper/AndroidUsbSerialTransport.kt index ef0e6ae25..0972dfbe3 100644 --- a/flipper/src/androidMain/kotlin/com/codebutler/farebot/flipper/AndroidUsbSerialTransport.kt +++ b/flipper/src/androidMain/kotlin/com/codebutler/farebot/flipper/AndroidUsbSerialTransport.kt @@ -42,15 +42,17 @@ class AndroidUsbSerialTransport( override suspend fun connect() { val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager - val device = findFlipperDevice(usbManager) - ?: throw FlipperException("Flipper Zero not found. Is it connected via USB?") + val device = + findFlipperDevice(usbManager) + ?: throw FlipperException("Flipper Zero not found. Is it connected via USB?") if (!usbManager.hasPermission(device)) { requestPermission(usbManager, device) } - val conn = usbManager.openDevice(device) - ?: throw FlipperException("Failed to open USB device") + val conn = + usbManager.openDevice(device) + ?: throw FlipperException("Failed to open USB device") // Find the CDC Data interface (class 0x0A) var dataIface: UsbInterface? = null @@ -98,7 +100,11 @@ class AndroidUsbSerialTransport( outEndpoint = bulkOut } - override suspend fun read(buffer: ByteArray, offset: Int, length: Int): Int { + override suspend fun read( + buffer: ByteArray, + offset: Int, + length: Int, + ): Int { val conn = connection ?: throw FlipperException("Not connected") val ep = inEndpoint ?: throw FlipperException("No IN endpoint") @@ -140,10 +146,16 @@ class AndroidUsbSerialTransport( } @Suppress("UnspecifiedRegisterReceiverFlag") - private suspend fun requestPermission(usbManager: UsbManager, device: UsbDevice) = - suspendCancellableCoroutine { cont -> - val receiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { + private suspend fun requestPermission( + usbManager: UsbManager, + device: UsbDevice, + ) = suspendCancellableCoroutine { cont -> + val receiver = + object : BroadcastReceiver() { + override fun onReceive( + context: Context, + intent: Intent, + ) { context.unregisterReceiver(this) val granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false) if (granted) { @@ -154,26 +166,27 @@ class AndroidUsbSerialTransport( } } - val filter = IntentFilter(ACTION_USB_PERMISSION) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED) - } else { - context.registerReceiver(receiver, filter) - } + val filter = IntentFilter(ACTION_USB_PERMISSION) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED) + } else { + context.registerReceiver(receiver, filter) + } - val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val flags = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { PendingIntent.FLAG_MUTABLE } else { 0 } - val permissionIntent = PendingIntent.getBroadcast(context, 0, Intent(ACTION_USB_PERMISSION), flags) - usbManager.requestPermission(device, permissionIntent) + val permissionIntent = PendingIntent.getBroadcast(context, 0, Intent(ACTION_USB_PERMISSION), flags) + usbManager.requestPermission(device, permissionIntent) - cont.invokeOnCancellation { - try { - context.unregisterReceiver(receiver) - } catch (_: Exception) { - } + cont.invokeOnCancellation { + try { + context.unregisterReceiver(receiver) + } catch (_: Exception) { } } + } } diff --git a/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/FlipperKeyDictParser.kt b/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/FlipperKeyDictParser.kt index ceaf4f48c..c34b5bbab 100644 --- a/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/FlipperKeyDictParser.kt +++ b/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/FlipperKeyDictParser.kt @@ -30,11 +30,11 @@ package com.codebutler.farebot.flipper * Each key is 6 bytes (12 hex characters). */ object FlipperKeyDictParser { - private val HEX_KEY_REGEX = Regex("^[0-9A-Fa-f]{12}$") fun parse(data: String): List = - data.lineSequence() + data + .lineSequence() .map { it.trim() } .filter { it.isNotEmpty() && !it.startsWith('#') } .filter { HEX_KEY_REGEX.matches(it) } diff --git a/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/FlipperRpcClient.kt b/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/FlipperRpcClient.kt index 8aecce15e..fbd6db27c 100644 --- a/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/FlipperRpcClient.kt +++ b/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/FlipperRpcClient.kt @@ -80,9 +80,11 @@ class FlipperRpcClient( @OptIn(ExperimentalSerializationApi::class) suspend fun listDirectory(path: String): List { val commandId = nextCommandId++ - val requestBytes = ProtoBuf.encodeToByteArray( - com.codebutler.farebot.flipper.proto.StorageListRequest(path = path), - ) + val requestBytes = + ProtoBuf.encodeToByteArray( + com.codebutler.farebot.flipper.proto + .StorageListRequest(path = path), + ) sendRequest(commandId, FIELD_STORAGE_LIST_REQUEST, requestBytes) val allFiles = mutableListOf() @@ -106,9 +108,11 @@ class FlipperRpcClient( @OptIn(ExperimentalSerializationApi::class) suspend fun readFile(path: String): ByteArray { val commandId = nextCommandId++ - val requestBytes = ProtoBuf.encodeToByteArray( - com.codebutler.farebot.flipper.proto.StorageReadRequest(path = path), - ) + val requestBytes = + ProtoBuf.encodeToByteArray( + com.codebutler.farebot.flipper.proto + .StorageReadRequest(path = path), + ) sendRequest(commandId, FIELD_STORAGE_READ_REQUEST, requestBytes) val chunks = mutableListOf() @@ -141,9 +145,11 @@ class FlipperRpcClient( @OptIn(ExperimentalSerializationApi::class) suspend fun statFile(path: String): StorageFile { val commandId = nextCommandId++ - val requestBytes = ProtoBuf.encodeToByteArray( - com.codebutler.farebot.flipper.proto.StorageStatRequest(path = path), - ) + val requestBytes = + ProtoBuf.encodeToByteArray( + com.codebutler.farebot.flipper.proto + .StorageStatRequest(path = path), + ) sendRequest(commandId, FIELD_STORAGE_STAT_REQUEST, requestBytes) val response = readMainResponse(commandId) @@ -160,9 +166,11 @@ class FlipperRpcClient( @OptIn(ExperimentalSerializationApi::class) suspend fun getStorageInfo(path: String): StorageInfoResponse { val commandId = nextCommandId++ - val requestBytes = ProtoBuf.encodeToByteArray( - com.codebutler.farebot.flipper.proto.StorageInfoRequest(path = path), - ) + val requestBytes = + ProtoBuf.encodeToByteArray( + com.codebutler.farebot.flipper.proto + .StorageInfoRequest(path = path), + ) sendRequest(commandId, FIELD_STORAGE_INFO_REQUEST, requestBytes) val response = readMainResponse(commandId) @@ -178,9 +186,11 @@ class FlipperRpcClient( @OptIn(ExperimentalSerializationApi::class) suspend fun getDeviceInfo(): Map { val commandId = nextCommandId++ - val requestBytes = ProtoBuf.encodeToByteArray( - com.codebutler.farebot.flipper.proto.SystemDeviceInfoRequest(), - ) + val requestBytes = + ProtoBuf.encodeToByteArray( + com.codebutler.farebot.flipper.proto + .SystemDeviceInfoRequest(), + ) sendRequest(commandId, FIELD_SYSTEM_DEVICE_INFO_REQUEST, requestBytes) val info = mutableMapOf() @@ -204,15 +214,19 @@ class FlipperRpcClient( // --- Internal protocol implementation --- - private suspend fun sendRequest(commandId: Int, contentFieldNumber: Int, contentBytes: ByteArray) { + private suspend fun sendRequest( + commandId: Int, + contentFieldNumber: Int, + contentBytes: ByteArray, + ) { val envelope = buildMainEnvelope(commandId, contentFieldNumber, contentBytes) val framed = frameMessage(envelope) transport.write(framed) } /** Read a complete Main response from the transport, with timeout. */ - private suspend fun readMainResponse(expectedCommandId: Int): ParsedMainResponse { - return withTimeout(timeoutMs) { + private suspend fun readMainResponse(expectedCommandId: Int): ParsedMainResponse = + withTimeout(timeoutMs) { // Read varint length prefix byte-by-byte val length = readVarintFromTransport() @@ -222,7 +236,6 @@ class FlipperRpcClient( // Parse the Main envelope parseMainEnvelope(messageBytes) } - } /** Read a varint from the transport one byte at a time. */ private suspend fun readVarintFromTransport(): Int { diff --git a/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/FlipperTransport.kt b/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/FlipperTransport.kt index 9c24ac10a..2ffafc321 100644 --- a/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/FlipperTransport.kt +++ b/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/FlipperTransport.kt @@ -24,8 +24,16 @@ package com.codebutler.farebot.flipper interface FlipperTransport { suspend fun connect() - suspend fun read(buffer: ByteArray, offset: Int, length: Int): Int + + suspend fun read( + buffer: ByteArray, + offset: Int, + length: Int, + ): Int + suspend fun write(data: ByteArray) + suspend fun close() + val isConnected: Boolean } diff --git a/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/Varint.kt b/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/Varint.kt index c77120920..2c493422a 100644 --- a/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/Varint.kt +++ b/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/Varint.kt @@ -23,7 +23,6 @@ package com.codebutler.farebot.flipper object Varint { - fun encode(value: Int): ByteArray { val result = mutableListOf() var v = value @@ -36,7 +35,10 @@ object Varint { } /** Returns (decoded value, number of bytes consumed). */ - fun decode(data: ByteArray, offset: Int): Pair { + fun decode( + data: ByteArray, + offset: Int, + ): Pair { var result = 0 var shift = 0 var pos = offset diff --git a/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/proto/FlipperMain.kt b/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/proto/FlipperMain.kt index 8538ab32d..7241ede03 100644 --- a/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/proto/FlipperMain.kt +++ b/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/proto/FlipperMain.kt @@ -26,7 +26,9 @@ package com.codebutler.farebot.flipper.proto * Flipper RPC command status codes. * Matches CommandStatus enum in flipper.proto. */ -enum class CommandStatus(val value: Int) { +enum class CommandStatus( + val value: Int, +) { OK(0), ERROR(1), ERROR_STORAGE_NOT_READY(2), @@ -50,7 +52,6 @@ enum class CommandStatus(val value: Int) { ; companion object { - fun fromValue(value: Int): CommandStatus = - entries.firstOrNull { it.value == value } ?: ERROR + fun fromValue(value: Int): CommandStatus = entries.firstOrNull { it.value == value } ?: ERROR } } diff --git a/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/proto/FlipperStorage.kt b/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/proto/FlipperStorage.kt index 7208787b2..8f071ece0 100644 --- a/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/proto/FlipperStorage.kt +++ b/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/proto/FlipperStorage.kt @@ -32,7 +32,9 @@ import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.protobuf.ProtoNumber @Serializable(with = StorageFileTypeSerializer::class) -enum class StorageFileType(val value: Int) { +enum class StorageFileType( + val value: Int, +) { FILE(0), DIR(1), } @@ -40,7 +42,10 @@ enum class StorageFileType(val value: Int) { internal object StorageFileTypeSerializer : KSerializer { override val descriptor = PrimitiveSerialDescriptor("StorageFileType", PrimitiveKind.INT) - override fun serialize(encoder: Encoder, value: StorageFileType) { + override fun serialize( + encoder: Encoder, + value: StorageFileType, + ) { encoder.encodeInt(value.value) } @@ -62,8 +67,11 @@ data class StorageFile( override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is StorageFile) return false - return type == other.type && name == other.name && size == other.size && - data.contentEquals(other.data) && md5sum == other.md5sum + return type == other.type && + name == other.name && + size == other.size && + data.contentEquals(other.data) && + md5sum == other.md5sum } override fun hashCode(): Int { diff --git a/flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/FlipperIntegrationTest.kt b/flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/FlipperIntegrationTest.kt index a6ed7154a..67cede7ac 100644 --- a/flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/FlipperIntegrationTest.kt +++ b/flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/FlipperIntegrationTest.kt @@ -37,145 +37,153 @@ import kotlin.test.assertTrue * can process the retrieved data. */ class FlipperIntegrationTest { - @Test - fun testFullFlowConnectListReadFile() = runTest { - val transport = MockTransport() - val client = FlipperRpcClient(transport) - - // 1. Connect — enqueue ping response - val pingResponse = buildMainEnvelope(commandId = 1, contentFieldNumber = 5, contentBytes = byteArrayOf()) - transport.enqueueResponse(FlipperRpcClient.frameMessage(pingResponse)) - client.connect() - assertTrue(transport.isConnected) - - // 2. List directory — enqueue response with 2 NFC files and 1 directory - val listContent = buildStorageListResponseBytes( - listOf( - TestFileEntry("card.nfc", isDir = false, size = 512u), - TestFileEntry("assets", isDir = true, size = 0u), - TestFileEntry("backup.nfc", isDir = false, size = 256u), - ), - ) - val listResponse = buildMainEnvelope(commandId = 2, contentFieldNumber = 20, contentBytes = listContent) - transport.enqueueResponse(FlipperRpcClient.frameMessage(listResponse)) - - val entries = client.listDirectory("/ext/nfc") - assertEquals(3, entries.size) - assertEquals("card.nfc", entries[0].name) - assertEquals(false, entries[0].isDirectory) - assertEquals(512L, entries[0].size) - assertEquals("assets", entries[1].name) - assertEquals(true, entries[1].isDirectory) - assertEquals("backup.nfc", entries[2].name) - - // 3. Read an NFC dump file - val nfcContent = """ - Filetype: Flipper NFC device - Version: 4 - Device type: Mifare Classic - UID: 01 02 03 04 - """.trimIndent() - val fileData = nfcContent.encodeToByteArray() - val readContent = buildStorageReadResponseBytes(fileData) - val readResponse = buildMainEnvelope(commandId = 3, contentFieldNumber = 22, contentBytes = readContent) - transport.enqueueResponse(FlipperRpcClient.frameMessage(readResponse)) - - val data = client.readFile("/ext/nfc/card.nfc") - val content = data.decodeToString() - assertTrue(content.contains("Filetype: Flipper NFC device")) - assertTrue(content.contains("Device type: Mifare Classic")) - assertTrue(content.contains("UID: 01 02 03 04")) - } + fun testFullFlowConnectListReadFile() = + runTest { + val transport = MockTransport() + val client = FlipperRpcClient(transport) + + // 1. Connect — enqueue ping response + val pingResponse = buildMainEnvelope(commandId = 1, contentFieldNumber = 5, contentBytes = byteArrayOf()) + transport.enqueueResponse(FlipperRpcClient.frameMessage(pingResponse)) + client.connect() + assertTrue(transport.isConnected) + + // 2. List directory — enqueue response with 2 NFC files and 1 directory + val listContent = + buildStorageListResponseBytes( + listOf( + TestFileEntry("card.nfc", isDir = false, size = 512u), + TestFileEntry("assets", isDir = true, size = 0u), + TestFileEntry("backup.nfc", isDir = false, size = 256u), + ), + ) + val listResponse = buildMainEnvelope(commandId = 2, contentFieldNumber = 20, contentBytes = listContent) + transport.enqueueResponse(FlipperRpcClient.frameMessage(listResponse)) + + val entries = client.listDirectory("/ext/nfc") + assertEquals(3, entries.size) + assertEquals("card.nfc", entries[0].name) + assertEquals(false, entries[0].isDirectory) + assertEquals(512L, entries[0].size) + assertEquals("assets", entries[1].name) + assertEquals(true, entries[1].isDirectory) + assertEquals("backup.nfc", entries[2].name) + + // 3. Read an NFC dump file + val nfcContent = + """ + Filetype: Flipper NFC device + Version: 4 + Device type: Mifare Classic + UID: 01 02 03 04 + """.trimIndent() + val fileData = nfcContent.encodeToByteArray() + val readContent = buildStorageReadResponseBytes(fileData) + val readResponse = buildMainEnvelope(commandId = 3, contentFieldNumber = 22, contentBytes = readContent) + transport.enqueueResponse(FlipperRpcClient.frameMessage(readResponse)) + + val data = client.readFile("/ext/nfc/card.nfc") + val content = data.decodeToString() + assertTrue(content.contains("Filetype: Flipper NFC device")) + assertTrue(content.contains("Device type: Mifare Classic")) + assertTrue(content.contains("UID: 01 02 03 04")) + } @Test - fun testFullFlowConnectReadKeyDictionary() = runTest { - val transport = MockTransport() - val client = FlipperRpcClient(transport) - - // Connect - val pingResponse = buildMainEnvelope(commandId = 1, contentFieldNumber = 5, contentBytes = byteArrayOf()) - transport.enqueueResponse(FlipperRpcClient.frameMessage(pingResponse)) - client.connect() - - // Read key dictionary file from Flipper - val dictContent = """ - # Flipper user dictionary - A0A1A2A3A4A5 - B0B1B2B3B4B5 - # comment - FFFFFFFFFFFF - """.trimIndent() - val dictData = dictContent.encodeToByteArray() - val readContent = buildStorageReadResponseBytes(dictData) - val readResponse = buildMainEnvelope(commandId = 2, contentFieldNumber = 22, contentBytes = readContent) - transport.enqueueResponse(FlipperRpcClient.frameMessage(readResponse)) - - val data = client.readFile("/ext/nfc/assets/mf_classic_dict_user.nfc") - - // Parse with FlipperKeyDictParser - val keys = FlipperKeyDictParser.parse(data.decodeToString()) - - assertEquals(3, keys.size) - // Verify first key: A0 A1 A2 A3 A4 A5 - assertEquals(0xA0.toByte(), keys[0][0]) - assertEquals(0xA5.toByte(), keys[0][5]) - assertEquals(6, keys[0].size) - // Verify second key: B0 B1 B2 B3 B4 B5 - assertEquals(0xB0.toByte(), keys[1][0]) - assertEquals(0xB5.toByte(), keys[1][5]) - // Verify last key: FF FF FF FF FF FF - assertTrue(keys[2].all { it == 0xFF.toByte() }) - } + fun testFullFlowConnectReadKeyDictionary() = + runTest { + val transport = MockTransport() + val client = FlipperRpcClient(transport) + + // Connect + val pingResponse = buildMainEnvelope(commandId = 1, contentFieldNumber = 5, contentBytes = byteArrayOf()) + transport.enqueueResponse(FlipperRpcClient.frameMessage(pingResponse)) + client.connect() + + // Read key dictionary file from Flipper + val dictContent = + """ + # Flipper user dictionary + A0A1A2A3A4A5 + B0B1B2B3B4B5 + # comment + FFFFFFFFFFFF + """.trimIndent() + val dictData = dictContent.encodeToByteArray() + val readContent = buildStorageReadResponseBytes(dictData) + val readResponse = buildMainEnvelope(commandId = 2, contentFieldNumber = 22, contentBytes = readContent) + transport.enqueueResponse(FlipperRpcClient.frameMessage(readResponse)) + + val data = client.readFile("/ext/nfc/assets/mf_classic_dict_user.nfc") + + // Parse with FlipperKeyDictParser + val keys = FlipperKeyDictParser.parse(data.decodeToString()) + + assertEquals(3, keys.size) + // Verify first key: A0 A1 A2 A3 A4 A5 + assertEquals(0xA0.toByte(), keys[0][0]) + assertEquals(0xA5.toByte(), keys[0][5]) + assertEquals(6, keys[0].size) + // Verify second key: B0 B1 B2 B3 B4 B5 + assertEquals(0xB0.toByte(), keys[1][0]) + assertEquals(0xB5.toByte(), keys[1][5]) + // Verify last key: FF FF FF FF FF FF + assertTrue(keys[2].all { it == 0xFF.toByte() }) + } @Test - fun testMultiChunkFileRead() = runTest { - val transport = MockTransport() - val client = FlipperRpcClient(transport) - - // Connect - val pingResponse = buildMainEnvelope(commandId = 1, contentFieldNumber = 5, contentBytes = byteArrayOf()) - transport.enqueueResponse(FlipperRpcClient.frameMessage(pingResponse)) - client.connect() - - // Simulate reading a large file in two chunks (has_next = true for first chunk) - val chunk1 = "Filetype: Flipper NFC device\n".encodeToByteArray() - val chunk2 = "Version: 4\nDevice type: Mifare Classic\n".encodeToByteArray() - - val readResponse1 = buildMainEnvelope( - commandId = 2, - contentFieldNumber = 22, - contentBytes = buildStorageReadResponseBytes(chunk1), - hasNext = true, - ) - transport.enqueueResponse(FlipperRpcClient.frameMessage(readResponse1)) - - val readResponse2 = buildMainEnvelope( - commandId = 2, - contentFieldNumber = 22, - contentBytes = buildStorageReadResponseBytes(chunk2), - hasNext = false, - ) - transport.enqueueResponse(FlipperRpcClient.frameMessage(readResponse2)) - - val data = client.readFile("/ext/nfc/card.nfc") - val content = data.decodeToString() - assertEquals("Filetype: Flipper NFC device\nVersion: 4\nDevice type: Mifare Classic\n", content) - } + fun testMultiChunkFileRead() = + runTest { + val transport = MockTransport() + val client = FlipperRpcClient(transport) + + // Connect + val pingResponse = buildMainEnvelope(commandId = 1, contentFieldNumber = 5, contentBytes = byteArrayOf()) + transport.enqueueResponse(FlipperRpcClient.frameMessage(pingResponse)) + client.connect() + + // Simulate reading a large file in two chunks (has_next = true for first chunk) + val chunk1 = "Filetype: Flipper NFC device\n".encodeToByteArray() + val chunk2 = "Version: 4\nDevice type: Mifare Classic\n".encodeToByteArray() + + val readResponse1 = + buildMainEnvelope( + commandId = 2, + contentFieldNumber = 22, + contentBytes = buildStorageReadResponseBytes(chunk1), + hasNext = true, + ) + transport.enqueueResponse(FlipperRpcClient.frameMessage(readResponse1)) + + val readResponse2 = + buildMainEnvelope( + commandId = 2, + contentFieldNumber = 22, + contentBytes = buildStorageReadResponseBytes(chunk2), + hasNext = false, + ) + transport.enqueueResponse(FlipperRpcClient.frameMessage(readResponse2)) + + val data = client.readFile("/ext/nfc/card.nfc") + val content = data.decodeToString() + assertEquals("Filetype: Flipper NFC device\nVersion: 4\nDevice type: Mifare Classic\n", content) + } @Test - fun testDisconnectCleansUp() = runTest { - val transport = MockTransport() - val client = FlipperRpcClient(transport) - - // Connect - val pingResponse = buildMainEnvelope(commandId = 1, contentFieldNumber = 5, contentBytes = byteArrayOf()) - transport.enqueueResponse(FlipperRpcClient.frameMessage(pingResponse)) - client.connect() - assertTrue(transport.isConnected) - - // Disconnect via transport - transport.close() - assertTrue(!transport.isConnected) - } + fun testDisconnectCleansUp() = + runTest { + val transport = MockTransport() + val client = FlipperRpcClient(transport) + + // Connect + val pingResponse = buildMainEnvelope(commandId = 1, contentFieldNumber = 5, contentBytes = byteArrayOf()) + transport.enqueueResponse(FlipperRpcClient.frameMessage(pingResponse)) + client.connect() + assertTrue(transport.isConnected) + + // Disconnect via transport + transport.close() + assertTrue(!transport.isConnected) + } } diff --git a/flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/FlipperKeyDictParserTest.kt b/flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/FlipperKeyDictParserTest.kt index 51a5acbef..b15c0c555 100644 --- a/flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/FlipperKeyDictParserTest.kt +++ b/flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/FlipperKeyDictParserTest.kt @@ -27,31 +27,39 @@ import kotlin.test.assertContentEquals import kotlin.test.assertEquals class FlipperKeyDictParserTest { - @Test fun testParseValidDictionary() { - val input = """ + val input = + """ # Flipper NFC user dictionary FFFFFFFFFFFF A0A1A2A3A4A5 D3F7D3F7D3F7 000000000000 - """.trimIndent() + """.trimIndent() val keys = FlipperKeyDictParser.parse(input) assertEquals(4, keys.size) assertContentEquals( byteArrayOf( - 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), - 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), + 0xFF.toByte(), + 0xFF.toByte(), + 0xFF.toByte(), + 0xFF.toByte(), + 0xFF.toByte(), + 0xFF.toByte(), ), keys[0], ) assertContentEquals( byteArrayOf( - 0xA0.toByte(), 0xA1.toByte(), 0xA2.toByte(), - 0xA3.toByte(), 0xA4.toByte(), 0xA5.toByte(), + 0xA0.toByte(), + 0xA1.toByte(), + 0xA2.toByte(), + 0xA3.toByte(), + 0xA4.toByte(), + 0xA5.toByte(), ), keys[1], ) @@ -59,13 +67,14 @@ class FlipperKeyDictParserTest { @Test fun testSkipsCommentsAndBlanks() { - val input = """ + val input = + """ # Comment # Another comment FFFFFFFFFFFF - """.trimIndent() + """.trimIndent() val keys = FlipperKeyDictParser.parse(input) assertEquals(1, keys.size) @@ -73,12 +82,13 @@ class FlipperKeyDictParserTest { @Test fun testSkipsInvalidKeys() { - val input = """ + val input = + """ FFFFFFFFFFFF TOOSHORT FFFFFFFFFFFF00 A0A1A2A3A4A5 - """.trimIndent() + """.trimIndent() val keys = FlipperKeyDictParser.parse(input) assertEquals(2, keys.size) // Only valid 12-char hex strings diff --git a/flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/FlipperRpcClientTest.kt b/flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/FlipperRpcClientTest.kt index 24aece842..06021fd3d 100644 --- a/flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/FlipperRpcClientTest.kt +++ b/flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/FlipperRpcClientTest.kt @@ -28,7 +28,6 @@ import kotlin.test.assertEquals import kotlin.test.assertTrue class FlipperRpcClientTest { - @Test fun testFrameMessage() { // Verify that a message of N bytes is prefixed with varint(N) @@ -49,133 +48,147 @@ class FlipperRpcClientTest { } @Test - fun testConnectSendsStartRpcSession() = runTest { - val transport = MockTransport() - val client = FlipperRpcClient(transport) - - // Enqueue a valid ping response (Main envelope with command_id=1, status=OK, ping_response) - // Main fields: command_id=1 (field 1, varint), command_status=0 (field 2, varint), - // has_next=false (field 3, varint=0), system_ping_response (field 5, LEN) - val pingResponse = buildMainEnvelope(commandId = 1, contentFieldNumber = 5, contentBytes = byteArrayOf()) - transport.enqueueResponse(FlipperRpcClient.frameMessage(pingResponse)) - - client.connect() - - assertTrue(transport.isConnected) - assertTrue(transport.writtenData.isNotEmpty()) - val firstWrite = transport.writtenData[0].decodeToString() - assertTrue(firstWrite.contains("start_rpc_session"), "First write should be start_rpc_session") - } + fun testConnectSendsStartRpcSession() = + runTest { + val transport = MockTransport() + val client = FlipperRpcClient(transport) + + // Enqueue a valid ping response (Main envelope with command_id=1, status=OK, ping_response) + // Main fields: command_id=1 (field 1, varint), command_status=0 (field 2, varint), + // has_next=false (field 3, varint=0), system_ping_response (field 5, LEN) + val pingResponse = buildMainEnvelope(commandId = 1, contentFieldNumber = 5, contentBytes = byteArrayOf()) + transport.enqueueResponse(FlipperRpcClient.frameMessage(pingResponse)) + + client.connect() + + assertTrue(transport.isConnected) + assertTrue(transport.writtenData.isNotEmpty()) + val firstWrite = transport.writtenData[0].decodeToString() + assertTrue(firstWrite.contains("start_rpc_session"), "First write should be start_rpc_session") + } @Test fun testBuildMainEnvelope() { // Build envelope with command_id=1, empty ping request (field 4) - val envelope = FlipperRpcClient.buildMainEnvelope( - commandId = 1, - contentFieldNumber = 4, - contentBytes = byteArrayOf(), - ) + val envelope = + FlipperRpcClient.buildMainEnvelope( + commandId = 1, + contentFieldNumber = 4, + contentBytes = byteArrayOf(), + ) // Should start with field 1 (command_id) tag = 0x08, then varint 1 assertEquals(0x08.toByte(), envelope[0]) assertEquals(0x01.toByte(), envelope[1]) } @Test - fun testListDirectory() = runTest { - val transport = MockTransport() - val client = FlipperRpcClient(transport) - - // Enqueue ping response for connect - val pingResponse = buildMainEnvelope(commandId = 1, contentFieldNumber = 5, contentBytes = byteArrayOf()) - transport.enqueueResponse(FlipperRpcClient.frameMessage(pingResponse)) - - client.connect() - - // Build a StorageListResponse with two files - val listResponseContent = buildStorageListResponseBytes( - listOf( - TestFileEntry("card.nfc", isDir = false, size = 1024u), - TestFileEntry("keys", isDir = true, size = 0u), - ), - ) - val listResponse = buildMainEnvelope( - commandId = 2, - contentFieldNumber = 20, // storage_list_response - contentBytes = listResponseContent, - ) - transport.enqueueResponse(FlipperRpcClient.frameMessage(listResponse)) - - val files = client.listDirectory("/ext/nfc") - assertEquals(2, files.size) - assertEquals("card.nfc", files[0].name) - assertEquals(false, files[0].isDirectory) - assertEquals("keys", files[1].name) - assertEquals(true, files[1].isDirectory) - } + fun testListDirectory() = + runTest { + val transport = MockTransport() + val client = FlipperRpcClient(transport) + + // Enqueue ping response for connect + val pingResponse = buildMainEnvelope(commandId = 1, contentFieldNumber = 5, contentBytes = byteArrayOf()) + transport.enqueueResponse(FlipperRpcClient.frameMessage(pingResponse)) + + client.connect() + + // Build a StorageListResponse with two files + val listResponseContent = + buildStorageListResponseBytes( + listOf( + TestFileEntry("card.nfc", isDir = false, size = 1024u), + TestFileEntry("keys", isDir = true, size = 0u), + ), + ) + val listResponse = + buildMainEnvelope( + commandId = 2, + contentFieldNumber = 20, // storage_list_response + contentBytes = listResponseContent, + ) + transport.enqueueResponse(FlipperRpcClient.frameMessage(listResponse)) + + val files = client.listDirectory("/ext/nfc") + assertEquals(2, files.size) + assertEquals("card.nfc", files[0].name) + assertEquals(false, files[0].isDirectory) + assertEquals("keys", files[1].name) + assertEquals(true, files[1].isDirectory) + } @Test - fun testReadFile() = runTest { - val transport = MockTransport() - val client = FlipperRpcClient(transport) - - // Enqueue ping response for connect - val pingResponse = buildMainEnvelope(commandId = 1, contentFieldNumber = 5, contentBytes = byteArrayOf()) - transport.enqueueResponse(FlipperRpcClient.frameMessage(pingResponse)) - - client.connect() - - // Build a StorageReadResponse with file data - val fileData = "Filetype: Flipper NFC device\n".encodeToByteArray() - val readResponseContent = buildStorageReadResponseBytes(fileData) - val readResponse = buildMainEnvelope( - commandId = 2, - contentFieldNumber = 22, // storage_read_response - contentBytes = readResponseContent, - ) - transport.enqueueResponse(FlipperRpcClient.frameMessage(readResponse)) - - val data = client.readFile("/ext/nfc/card.nfc") - assertEquals("Filetype: Flipper NFC device\n", data.decodeToString()) - } + fun testReadFile() = + runTest { + val transport = MockTransport() + val client = FlipperRpcClient(transport) + + // Enqueue ping response for connect + val pingResponse = buildMainEnvelope(commandId = 1, contentFieldNumber = 5, contentBytes = byteArrayOf()) + transport.enqueueResponse(FlipperRpcClient.frameMessage(pingResponse)) + + client.connect() + + // Build a StorageReadResponse with file data + val fileData = "Filetype: Flipper NFC device\n".encodeToByteArray() + val readResponseContent = buildStorageReadResponseBytes(fileData) + val readResponse = + buildMainEnvelope( + commandId = 2, + contentFieldNumber = 22, // storage_read_response + contentBytes = readResponseContent, + ) + transport.enqueueResponse(FlipperRpcClient.frameMessage(readResponse)) + + val data = client.readFile("/ext/nfc/card.nfc") + assertEquals("Filetype: Flipper NFC device\n", data.decodeToString()) + } @Test - fun testMultiPartReadFile() = runTest { - val transport = MockTransport() - val client = FlipperRpcClient(transport) - - // Enqueue ping response for connect - val pingResponse = buildMainEnvelope(commandId = 1, contentFieldNumber = 5, contentBytes = byteArrayOf()) - transport.enqueueResponse(FlipperRpcClient.frameMessage(pingResponse)) - - client.connect() - - // Part 1: has_next = true - val chunk1 = "Hello, ".encodeToByteArray() - val readResponse1 = buildMainEnvelope( - commandId = 2, - contentFieldNumber = 22, - contentBytes = buildStorageReadResponseBytes(chunk1), - hasNext = true, - ) - transport.enqueueResponse(FlipperRpcClient.frameMessage(readResponse1)) - - // Part 2: has_next = false (final) - val chunk2 = "World!".encodeToByteArray() - val readResponse2 = buildMainEnvelope( - commandId = 2, - contentFieldNumber = 22, - contentBytes = buildStorageReadResponseBytes(chunk2), - hasNext = false, - ) - transport.enqueueResponse(FlipperRpcClient.frameMessage(readResponse2)) - - val data = client.readFile("/ext/nfc/card.nfc") - assertEquals("Hello, World!", data.decodeToString()) - } + fun testMultiPartReadFile() = + runTest { + val transport = MockTransport() + val client = FlipperRpcClient(transport) + + // Enqueue ping response for connect + val pingResponse = buildMainEnvelope(commandId = 1, contentFieldNumber = 5, contentBytes = byteArrayOf()) + transport.enqueueResponse(FlipperRpcClient.frameMessage(pingResponse)) + + client.connect() + + // Part 1: has_next = true + val chunk1 = "Hello, ".encodeToByteArray() + val readResponse1 = + buildMainEnvelope( + commandId = 2, + contentFieldNumber = 22, + contentBytes = buildStorageReadResponseBytes(chunk1), + hasNext = true, + ) + transport.enqueueResponse(FlipperRpcClient.frameMessage(readResponse1)) + + // Part 2: has_next = false (final) + val chunk2 = "World!".encodeToByteArray() + val readResponse2 = + buildMainEnvelope( + commandId = 2, + contentFieldNumber = 22, + contentBytes = buildStorageReadResponseBytes(chunk2), + hasNext = false, + ) + transport.enqueueResponse(FlipperRpcClient.frameMessage(readResponse2)) + + val data = client.readFile("/ext/nfc/card.nfc") + assertEquals("Hello, World!", data.decodeToString()) + } // --- Test helpers to build raw protobuf bytes --- - data class TestFileEntry(val name: String, val isDir: Boolean, val size: UInt) + data class TestFileEntry( + val name: String, + val isDir: Boolean, + val size: UInt, + ) companion object { /** Build a raw protobuf Main envelope. */ diff --git a/flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/MockTransport.kt b/flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/MockTransport.kt index e15881621..85e0f7fc9 100644 --- a/flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/MockTransport.kt +++ b/flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/MockTransport.kt @@ -41,7 +41,11 @@ class MockTransport : FlipperTransport { writtenData.add(data.copyOf()) } - override suspend fun read(buffer: ByteArray, offset: Int, length: Int): Int { + override suspend fun read( + buffer: ByteArray, + offset: Int, + length: Int, + ): Int { if (responseBuffer.isEmpty()) return 0 val toCopy = minOf(length, responseBuffer.size) for (i in 0 until toCopy) { diff --git a/flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/VarintTest.kt b/flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/VarintTest.kt index dea4d7491..9404b2b0b 100644 --- a/flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/VarintTest.kt +++ b/flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/VarintTest.kt @@ -27,7 +27,6 @@ import kotlin.test.assertContentEquals import kotlin.test.assertEquals class VarintTest { - @Test fun testEncodeSmallValue() { assertContentEquals(byteArrayOf(0x01), Varint.encode(1)) diff --git a/flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/proto/FlipperProtoTest.kt b/flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/proto/FlipperProtoTest.kt index da8899ec2..87014b7ba 100644 --- a/flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/proto/FlipperProtoTest.kt +++ b/flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/proto/FlipperProtoTest.kt @@ -31,7 +31,6 @@ import kotlin.test.assertEquals @OptIn(ExperimentalSerializationApi::class) class FlipperProtoTest { - @Test fun testStorageListRequestRoundTrip() { val request = StorageListRequest(path = "/ext/nfc") @@ -42,11 +41,12 @@ class FlipperProtoTest { @Test fun testStorageFileRoundTrip() { - val file = StorageFile( - type = StorageFileType.FILE, - name = "card.nfc", - size = 1234u, - ) + val file = + StorageFile( + type = StorageFileType.FILE, + name = "card.nfc", + size = 1234u, + ) val bytes = ProtoBuf.encodeToByteArray(file) val decoded = ProtoBuf.decodeFromByteArray(bytes) assertEquals("card.nfc", decoded.name) @@ -56,12 +56,14 @@ class FlipperProtoTest { @Test fun testStorageListResponseRoundTrip() { - val response = StorageListResponse( - files = listOf( - StorageFile(type = StorageFileType.FILE, name = "card.nfc", size = 100u), - StorageFile(type = StorageFileType.DIR, name = "assets", size = 0u), - ), - ) + val response = + StorageListResponse( + files = + listOf( + StorageFile(type = StorageFileType.FILE, name = "card.nfc", size = 100u), + StorageFile(type = StorageFileType.DIR, name = "assets", size = 0u), + ), + ) val bytes = ProtoBuf.encodeToByteArray(response) val decoded = ProtoBuf.decodeFromByteArray(bytes) assertEquals(2, decoded.files.size) diff --git a/flipper/src/iosMain/kotlin/com/codebutler/farebot/flipper/IosBleSerialTransport.kt b/flipper/src/iosMain/kotlin/com/codebutler/farebot/flipper/IosBleSerialTransport.kt index 796aae8a1..dfd92508a 100644 --- a/flipper/src/iosMain/kotlin/com/codebutler/farebot/flipper/IosBleSerialTransport.kt +++ b/flipper/src/iosMain/kotlin/com/codebutler/farebot/flipper/IosBleSerialTransport.kt @@ -1,5 +1,9 @@ package com.codebutler.farebot.flipper +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.usePinned import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.withTimeout @@ -15,20 +19,16 @@ import platform.CoreBluetooth.CBUUID import platform.Foundation.NSData import platform.Foundation.NSError import platform.Foundation.NSNumber +import platform.Foundation.create import platform.darwin.NSObject import platform.posix.memcpy -import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.cinterop.addressOf -import kotlinx.cinterop.usePinned -import kotlinx.cinterop.allocArrayOf -import kotlinx.cinterop.memScoped -import platform.Foundation.create +import kotlin.experimental.ExperimentalObjCRefinement /** * FlipperTransport implementation using iOS Core Bluetooth. * Connects to Flipper Zero's BLE Serial service. */ -@OptIn(ExperimentalForeignApi::class) +@OptIn(ExperimentalForeignApi::class, ExperimentalObjCRefinement::class) class IosBleSerialTransport( private val peripheral: CBPeripheral? = null, ) : FlipperTransport { @@ -78,12 +78,17 @@ class IosBleSerialTransport( } // Enable notifications on TX characteristic - val tx = txCharacteristic - ?: throw FlipperException("TX characteristic not found") + val tx = + txCharacteristic + ?: throw FlipperException("TX characteristic not found") target.setNotifyValue(true, forCharacteristic = tx) } - override suspend fun read(buffer: ByteArray, offset: Int, length: Int): Int { + override suspend fun read( + buffer: ByteArray, + offset: Int, + length: Int, + ): Int { val data = receiveChannel.receive() val bytesToCopy = minOf(data.size, length) data.copyInto(buffer, offset, 0, bytesToCopy) @@ -130,112 +135,136 @@ class IosBleSerialTransport( } } - private val centralDelegate = object : NSObject(), CBCentralManagerDelegateProtocol { - override fun centralManagerDidUpdateState(central: CBCentralManager) { - if (central.state == CBCentralManagerStatePoweredOn) { - if (scanDeferred != null && scanDeferred?.isCompleted == false) { - central.scanForPeripheralsWithServices( - serviceUUIDs = listOf(SERIAL_SERVICE_UUID), - options = null, - ) + private val centralDelegate = + object : NSObject(), CBCentralManagerDelegateProtocol { + override fun centralManagerDidUpdateState(central: CBCentralManager) { + if (central.state == CBCentralManagerStatePoweredOn) { + if (scanDeferred != null && scanDeferred?.isCompleted == false) { + central.scanForPeripheralsWithServices( + serviceUUIDs = listOf(SERIAL_SERVICE_UUID), + options = null, + ) + } } } - } - - override fun centralManager( - central: CBCentralManager, - didDiscoverPeripheral: CBPeripheral, - advertisementData: Map, - RSSI: NSNumber, - ) { - scanDeferred?.complete(didDiscoverPeripheral) - } - - override fun centralManager(central: CBCentralManager, didConnectPeripheral: CBPeripheral) { - connectionDeferred?.complete(Unit) - } - override fun centralManager( - central: CBCentralManager, - didFailToConnectPeripheral: CBPeripheral, - error: NSError?, - ) { - connectionDeferred?.completeExceptionally( - FlipperException("BLE connection failed: ${error?.localizedDescription}"), - ) - } + override fun centralManager( + central: CBCentralManager, + didDiscoverPeripheral: CBPeripheral, + advertisementData: Map, + RSSI: NSNumber, + ) { + scanDeferred?.complete(didDiscoverPeripheral) + } - override fun centralManager( - central: CBCentralManager, - didDisconnectPeripheral: CBPeripheral, - error: NSError?, - ) { - connectedPeripheral = null - } - } + override fun centralManager( + central: CBCentralManager, + didConnectPeripheral: CBPeripheral, + ) { + connectionDeferred?.complete(Unit) + } - private val peripheralDelegate = object : NSObject(), CBPeripheralDelegateProtocol { - override fun peripheral(peripheral: CBPeripheral, didDiscoverServices: NSError?) { - if (didDiscoverServices != null) { - servicesDeferred?.completeExceptionally( - FlipperException("Service discovery failed: ${didDiscoverServices.localizedDescription}"), + @ObjCSignatureOverride + override fun centralManager( + central: CBCentralManager, + didFailToConnectPeripheral: CBPeripheral, + error: NSError?, + ) { + connectionDeferred?.completeExceptionally( + FlipperException("BLE connection failed: ${error?.localizedDescription}"), ) - return } - val service = peripheral.services?.firstOrNull { (it as? CBService)?.UUID == SERIAL_SERVICE_UUID } as? CBService - if (service != null) { - peripheral.discoverCharacteristics( - listOf(SERIAL_RX_UUID, SERIAL_TX_UUID), - forService = service, - ) - } else { - servicesDeferred?.completeExceptionally( - FlipperException("Serial service not found"), - ) + @ObjCSignatureOverride + override fun centralManager( + central: CBCentralManager, + didDisconnectPeripheral: CBPeripheral, + error: NSError?, + ) { + connectedPeripheral = null } } - override fun peripheral(peripheral: CBPeripheral, didDiscoverCharacteristicsForService: CBService, error: NSError?) { - if (error != null) { - servicesDeferred?.completeExceptionally( - FlipperException("Characteristic discovery failed: ${error.localizedDescription}"), - ) - return - } + private val peripheralDelegate = + object : NSObject(), CBPeripheralDelegateProtocol { + override fun peripheral( + peripheral: CBPeripheral, + didDiscoverServices: NSError?, + ) { + if (didDiscoverServices != null) { + servicesDeferred?.completeExceptionally( + FlipperException("Service discovery failed: ${didDiscoverServices.localizedDescription}"), + ) + return + } - val characteristics = didDiscoverCharacteristicsForService.characteristics ?: emptyList() - for (char in characteristics) { - val characteristic = char as? CBCharacteristic ?: continue - when (characteristic.UUID) { - SERIAL_RX_UUID -> rxCharacteristic = characteristic - SERIAL_TX_UUID -> txCharacteristic = characteristic + val service = + peripheral.services?.firstOrNull { + (it as? CBService)?.UUID == SERIAL_SERVICE_UUID + } as? CBService + if (service != null) { + peripheral.discoverCharacteristics( + listOf(SERIAL_RX_UUID, SERIAL_TX_UUID), + forService = service, + ) + } else { + servicesDeferred?.completeExceptionally( + FlipperException("Serial service not found"), + ) } } - servicesDeferred?.complete(Unit) - } + @ObjCSignatureOverride + override fun peripheral( + peripheral: CBPeripheral, + didDiscoverCharacteristicsForService: CBService, + error: NSError?, + ) { + if (error != null) { + servicesDeferred?.completeExceptionally( + FlipperException("Characteristic discovery failed: ${error.localizedDescription}"), + ) + return + } + + val characteristics = didDiscoverCharacteristicsForService.characteristics ?: return + for (char in characteristics) { + val characteristic = char as? CBCharacteristic ?: continue + when (characteristic.UUID) { + SERIAL_RX_UUID -> rxCharacteristic = characteristic + SERIAL_TX_UUID -> txCharacteristic = characteristic + } + } - override fun peripheral(peripheral: CBPeripheral, didUpdateValueForCharacteristic: CBCharacteristic, error: NSError?) { - if (error != null) return - if (didUpdateValueForCharacteristic.UUID == SERIAL_TX_UUID) { - val nsData = didUpdateValueForCharacteristic.value ?: return - val bytes = nsData.toByteArray() - if (bytes.isNotEmpty()) { - receiveChannel.trySend(bytes) + servicesDeferred?.complete(Unit) + } + + @ObjCSignatureOverride + override fun peripheral( + peripheral: CBPeripheral, + didUpdateValueForCharacteristic: CBCharacteristic, + error: NSError?, + ) { + if (error != null) return + if (didUpdateValueForCharacteristic.UUID == SERIAL_TX_UUID) { + val nsData = didUpdateValueForCharacteristic.value ?: return + val bytes = nsData.toByteArray() + if (bytes.isNotEmpty()) { + receiveChannel.trySend(bytes) + } } } } - } } @OptIn(ExperimentalForeignApi::class) -private fun ByteArray.toNSData(): NSData = memScoped { - if (isEmpty()) return NSData() - usePinned { pinned -> - NSData.create(bytes = pinned.addressOf(0), length = size.toULong()) +private fun ByteArray.toNSData(): NSData = + memScoped { + if (isEmpty()) return NSData() + usePinned { pinned -> + NSData.create(bytes = pinned.addressOf(0), length = size.toULong()) + } } -} @OptIn(ExperimentalForeignApi::class) private fun NSData.toByteArray(): ByteArray { diff --git a/flipper/src/iosMain/kotlin/com/codebutler/farebot/flipper/IosFlipperTransportFactory.kt b/flipper/src/iosMain/kotlin/com/codebutler/farebot/flipper/IosFlipperTransportFactory.kt index e0842d104..3ed02f5f2 100644 --- a/flipper/src/iosMain/kotlin/com/codebutler/farebot/flipper/IosFlipperTransportFactory.kt +++ b/flipper/src/iosMain/kotlin/com/codebutler/farebot/flipper/IosFlipperTransportFactory.kt @@ -6,6 +6,5 @@ class IosFlipperTransportFactory : FlipperTransportFactory { override suspend fun createUsbTransport(): FlipperTransport? = null - override suspend fun createBleTransport(): FlipperTransport = - IosBleSerialTransport() + override suspend fun createBleTransport(): FlipperTransport = IosBleSerialTransport() } diff --git a/flipper/src/jvmMain/kotlin/com/codebutler/farebot/flipper/JvmFlipperTransportFactory.kt b/flipper/src/jvmMain/kotlin/com/codebutler/farebot/flipper/JvmFlipperTransportFactory.kt index 3e036dac2..aff0fe4b8 100644 --- a/flipper/src/jvmMain/kotlin/com/codebutler/farebot/flipper/JvmFlipperTransportFactory.kt +++ b/flipper/src/jvmMain/kotlin/com/codebutler/farebot/flipper/JvmFlipperTransportFactory.kt @@ -4,8 +4,7 @@ class JvmFlipperTransportFactory : FlipperTransportFactory { override val isUsbSupported: Boolean = true override val isBleSupported: Boolean = false - override suspend fun createUsbTransport(): FlipperTransport = - JvmUsbSerialTransport() + override suspend fun createUsbTransport(): FlipperTransport = JvmUsbSerialTransport() override suspend fun createBleTransport(): FlipperTransport? = null } diff --git a/flipper/src/jvmMain/kotlin/com/codebutler/farebot/flipper/JvmUsbSerialTransport.kt b/flipper/src/jvmMain/kotlin/com/codebutler/farebot/flipper/JvmUsbSerialTransport.kt index 286aec945..f72784d25 100644 --- a/flipper/src/jvmMain/kotlin/com/codebutler/farebot/flipper/JvmUsbSerialTransport.kt +++ b/flipper/src/jvmMain/kotlin/com/codebutler/farebot/flipper/JvmUsbSerialTransport.kt @@ -22,12 +22,13 @@ class JvmUsbSerialTransport( get() = serialPort?.isOpen == true override suspend fun connect() { - val port = if (portDescriptor != null) { - SerialPort.getCommPort(portDescriptor) - } else { - findFlipperPort() - ?: throw FlipperException("Flipper Zero not found. Is it connected via USB?") - } + val port = + if (portDescriptor != null) { + SerialPort.getCommPort(portDescriptor) + } else { + findFlipperPort() + ?: throw FlipperException("Flipper Zero not found. Is it connected via USB?") + } port.baudRate = BAUD_RATE port.setComPortTimeouts(SerialPort.TIMEOUT_READ_SEMI_BLOCKING, READ_TIMEOUT_MS, 0) @@ -39,7 +40,11 @@ class JvmUsbSerialTransport( serialPort = port } - override suspend fun read(buffer: ByteArray, offset: Int, length: Int): Int { + override suspend fun read( + buffer: ByteArray, + offset: Int, + length: Int, + ): Int { val port = serialPort ?: throw FlipperException("Not connected") val tempBuffer = ByteArray(length) val bytesRead = port.readBytes(tempBuffer, length) diff --git a/flipper/src/wasmJsMain/kotlin/com/codebutler/farebot/flipper/WebBleTransport.kt b/flipper/src/wasmJsMain/kotlin/com/codebutler/farebot/flipper/WebBleTransport.kt index 526246b91..89e7e50ed 100644 --- a/flipper/src/wasmJsMain/kotlin/com/codebutler/farebot/flipper/WebBleTransport.kt +++ b/flipper/src/wasmJsMain/kotlin/com/codebutler/farebot/flipper/WebBleTransport.kt @@ -55,7 +55,11 @@ class WebBleTransport : FlipperTransport { connected = true } - override suspend fun read(buffer: ByteArray, offset: Int, length: Int): Int { + override suspend fun read( + buffer: ByteArray, + offset: Int, + length: Int, + ): Int { var elapsed = 0L while (jsWebBleAvailable() == 0) { delay(POLL_INTERVAL_MS) @@ -66,8 +70,9 @@ class WebBleTransport : FlipperTransport { } jsWebBleStartRead(length) - val csv = jsWebBleGetReadResult()?.toString() - ?: throw FlipperException("BLE read returned no data") + val csv = + jsWebBleGetReadResult()?.toString() + ?: throw FlipperException("BLE read returned no data") if (csv.isEmpty()) throw FlipperException("BLE read returned empty data") val bytes = csv.split(",").map { it.toInt().toByte() }.toByteArray() @@ -121,11 +126,9 @@ private fun jsWebBleRequestDevice() { ) } -private fun jsWebBleIsReady(): Boolean = - js("window._fbBle && window._fbBle.ready === true") +private fun jsWebBleIsReady(): Boolean = js("window._fbBle && window._fbBle.ready === true") -private fun jsWebBleHasDevice(): Boolean = - js("window._fbBle && window._fbBle.device !== null") +private fun jsWebBleHasDevice(): Boolean = js("window._fbBle && window._fbBle.device !== null") private fun jsWebBleConnect() { js( @@ -162,14 +165,11 @@ private fun jsWebBleConnect() { ) } -private fun jsWebBleIsConnected(): Boolean = - js("window._fbBle && window._fbBle.connected === true") +private fun jsWebBleIsConnected(): Boolean = js("window._fbBle && window._fbBle.connected === true") -private fun jsWebBleGetConnectError(): JsString? = - js("(window._fbBle && window._fbBle.connectError) || null") +private fun jsWebBleGetConnectError(): JsString? = js("(window._fbBle && window._fbBle.connectError) || null") -private fun jsWebBleAvailable(): Int = - js("(window._fbBle && window._fbBle.buffer) ? window._fbBle.buffer.length : 0") +private fun jsWebBleAvailable(): Int = js("(window._fbBle && window._fbBle.buffer) ? window._fbBle.buffer.length : 0") private fun jsWebBleStartRead(length: Int) { js( @@ -185,8 +185,7 @@ private fun jsWebBleStartRead(length: Int) { ) } -private fun jsWebBleGetReadResult(): JsString? = - js("window._fbBleReadResult || null") +private fun jsWebBleGetReadResult(): JsString? = js("window._fbBleReadResult || null") private fun jsWebBleStartWrite(dataStr: JsString) { js( @@ -208,11 +207,9 @@ private fun jsWebBleStartWrite(dataStr: JsString) { ) } -private fun jsWebBleIsWriteReady(): Boolean = - js("window._fbBle && window._fbBle.writeReady === true") +private fun jsWebBleIsWriteReady(): Boolean = js("window._fbBle && window._fbBle.writeReady === true") -private fun jsWebBleGetWriteError(): JsString? = - js("(window._fbBle && window._fbBle.writeError) || null") +private fun jsWebBleGetWriteError(): JsString? = js("(window._fbBle && window._fbBle.writeError) || null") private fun jsWebBleDisconnect() { js( diff --git a/flipper/src/wasmJsMain/kotlin/com/codebutler/farebot/flipper/WebFlipperTransportFactory.kt b/flipper/src/wasmJsMain/kotlin/com/codebutler/farebot/flipper/WebFlipperTransportFactory.kt index 6205890a9..6221e4252 100644 --- a/flipper/src/wasmJsMain/kotlin/com/codebutler/farebot/flipper/WebFlipperTransportFactory.kt +++ b/flipper/src/wasmJsMain/kotlin/com/codebutler/farebot/flipper/WebFlipperTransportFactory.kt @@ -4,9 +4,7 @@ class WebFlipperTransportFactory : FlipperTransportFactory { override val isUsbSupported: Boolean = true override val isBleSupported: Boolean = true - override suspend fun createUsbTransport(): FlipperTransport = - WebSerialTransport() + override suspend fun createUsbTransport(): FlipperTransport = WebSerialTransport() - override suspend fun createBleTransport(): FlipperTransport = - WebBleTransport() + override suspend fun createBleTransport(): FlipperTransport = WebBleTransport() } diff --git a/flipper/src/wasmJsMain/kotlin/com/codebutler/farebot/flipper/WebSerialTransport.kt b/flipper/src/wasmJsMain/kotlin/com/codebutler/farebot/flipper/WebSerialTransport.kt index 1f16868ba..7042c813d 100644 --- a/flipper/src/wasmJsMain/kotlin/com/codebutler/farebot/flipper/WebSerialTransport.kt +++ b/flipper/src/wasmJsMain/kotlin/com/codebutler/farebot/flipper/WebSerialTransport.kt @@ -51,7 +51,11 @@ class WebSerialTransport : FlipperTransport { opened = true } - override suspend fun read(buffer: ByteArray, offset: Int, length: Int): Int { + override suspend fun read( + buffer: ByteArray, + offset: Int, + length: Int, + ): Int { jsWebSerialStartRead(length) var elapsed = 0L @@ -117,11 +121,9 @@ private fun jsWebSerialRequestPort() { ) } -private fun jsWebSerialIsReady(): Boolean = - js("window._fbSerial && window._fbSerial.ready === true") +private fun jsWebSerialIsReady(): Boolean = js("window._fbSerial && window._fbSerial.ready === true") -private fun jsWebSerialHasPort(): Boolean = - js("window._fbSerial && window._fbSerial.port !== null") +private fun jsWebSerialHasPort(): Boolean = js("window._fbSerial && window._fbSerial.port !== null") private fun jsWebSerialOpen() { js( @@ -138,8 +140,7 @@ private fun jsWebSerialOpen() { ) } -private fun jsWebSerialIsOpen(): Boolean = - js("window._fbSerial && window._fbSerial.open === true") +private fun jsWebSerialIsOpen(): Boolean = js("window._fbSerial && window._fbSerial.open === true") private fun jsWebSerialStartRead(length: Int) { js( @@ -164,11 +165,9 @@ private fun jsWebSerialStartRead(length: Int) { ) } -private fun jsWebSerialIsReadReady(): Boolean = - js("window._fbSerialIn && window._fbSerialIn.ready === true") +private fun jsWebSerialIsReadReady(): Boolean = js("window._fbSerialIn && window._fbSerialIn.ready === true") -private fun jsWebSerialGetReadData(): JsString? = - js("(window._fbSerialIn && window._fbSerialIn.data) || null") +private fun jsWebSerialGetReadData(): JsString? = js("(window._fbSerialIn && window._fbSerialIn.data) || null") private fun jsWebSerialStartWrite(dataStr: JsString) { js( @@ -192,11 +191,9 @@ private fun jsWebSerialStartWrite(dataStr: JsString) { ) } -private fun jsWebSerialIsWriteReady(): Boolean = - js("window._fbSerialOut && window._fbSerialOut.ready === true") +private fun jsWebSerialIsWriteReady(): Boolean = js("window._fbSerialOut && window._fbSerialOut.ready === true") -private fun jsWebSerialGetWriteError(): JsString? = - js("(window._fbSerialOut && window._fbSerialOut.error) || null") +private fun jsWebSerialGetWriteError(): JsString? = js("(window._fbSerialOut && window._fbSerialOut.error) || null") private fun jsWebSerialClose() { js( From 429f9d8382ea271a8f11b1dd6662553021ac2d3c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Feb 2026 05:31:10 -0800 Subject: [PATCH 4/5] fix(flipper): ktlint fixes for MockTransport and CommandStatus - Rename _connected to connected (backing property rule) - Rename FlipperMain.kt to CommandStatus.kt (single class naming) Co-Authored-By: Claude Opus 4.6 --- .../flipper/proto/{FlipperMain.kt => CommandStatus.kt} | 2 +- .../com/codebutler/farebot/flipper/MockTransport.kt | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) rename flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/proto/{FlipperMain.kt => CommandStatus.kt} (98%) diff --git a/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/proto/FlipperMain.kt b/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/proto/CommandStatus.kt similarity index 98% rename from flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/proto/FlipperMain.kt rename to flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/proto/CommandStatus.kt index 7241ede03..7c4b5cf9b 100644 --- a/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/proto/FlipperMain.kt +++ b/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/proto/CommandStatus.kt @@ -1,5 +1,5 @@ /* - * FlipperMain.kt + * CommandStatus.kt * * This file is part of FareBot. * Learn more at: https://codebutler.github.io/farebot/ diff --git a/flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/MockTransport.kt b/flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/MockTransport.kt index 85e0f7fc9..cc38f3305 100644 --- a/flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/MockTransport.kt +++ b/flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/MockTransport.kt @@ -25,16 +25,16 @@ package com.codebutler.farebot.flipper class MockTransport : FlipperTransport { val writtenData = mutableListOf() private val responseBuffer = mutableListOf() - private var _connected = false + private var connected = false - override val isConnected: Boolean get() = _connected + override val isConnected: Boolean get() = connected override suspend fun connect() { - _connected = true + connected = true } override suspend fun close() { - _connected = false + connected = false } override suspend fun write(data: ByteArray) { From 5755532d55b477e28a15cebcee6c432509884f39 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Feb 2026 05:38:57 -0800 Subject: [PATCH 5/5] fix(ios): use correct ObjCSignatureOverride import and NSData.dataWithBytes - Import ObjCSignatureOverride from kotlinx.cinterop (not kotlin.experimental) - Use NSData.dataWithBytes() instead of NSData.create() matching existing NfcDataConversions.kt pattern - Remove unnecessary memScoped wrapper from toNSData() Co-Authored-By: Claude Opus 4.6 --- .../farebot/flipper/IosBleSerialTransport.kt | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/flipper/src/iosMain/kotlin/com/codebutler/farebot/flipper/IosBleSerialTransport.kt b/flipper/src/iosMain/kotlin/com/codebutler/farebot/flipper/IosBleSerialTransport.kt index dfd92508a..bb2fb7d8d 100644 --- a/flipper/src/iosMain/kotlin/com/codebutler/farebot/flipper/IosBleSerialTransport.kt +++ b/flipper/src/iosMain/kotlin/com/codebutler/farebot/flipper/IosBleSerialTransport.kt @@ -1,8 +1,8 @@ package com.codebutler.farebot.flipper import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.ObjCSignatureOverride import kotlinx.cinterop.addressOf -import kotlinx.cinterop.memScoped import kotlinx.cinterop.usePinned import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.channels.Channel @@ -19,16 +19,15 @@ import platform.CoreBluetooth.CBUUID import platform.Foundation.NSData import platform.Foundation.NSError import platform.Foundation.NSNumber -import platform.Foundation.create +import platform.Foundation.dataWithBytes import platform.darwin.NSObject import platform.posix.memcpy -import kotlin.experimental.ExperimentalObjCRefinement /** * FlipperTransport implementation using iOS Core Bluetooth. * Connects to Flipper Zero's BLE Serial service. */ -@OptIn(ExperimentalForeignApi::class, ExperimentalObjCRefinement::class) +@OptIn(ExperimentalForeignApi::class) class IosBleSerialTransport( private val peripheral: CBPeripheral? = null, ) : FlipperTransport { @@ -258,13 +257,12 @@ class IosBleSerialTransport( } @OptIn(ExperimentalForeignApi::class) -private fun ByteArray.toNSData(): NSData = - memScoped { - if (isEmpty()) return NSData() - usePinned { pinned -> - NSData.create(bytes = pinned.addressOf(0), length = size.toULong()) - } +private fun ByteArray.toNSData(): NSData { + if (isEmpty()) return NSData() + return usePinned { pinned -> + NSData.dataWithBytes(pinned.addressOf(0), size.toULong()) } +} @OptIn(ExperimentalForeignApi::class) private fun NSData.toByteArray(): ByteArray {