Skip to content

Commit 9e5e95f

Browse files
codebutlerClaudeclaude
authored
feat(flipper): Flipper Zero integration for NFC dump import (#239)
* 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 * 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 <noreply@anthropic.com> * 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<CBCharacteristic>()) - Add Flipper Zero integration section to README - Add flipper/ to project structure in README Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * fix(flipper): correct BLE RPC protocol and protobuf field numbers Remove CLI start_rpc_session handshake — BLE goes directly into protobuf mode. Fix all protobuf field numbers to match official flipper.proto (ping=5/6, storage_list=7/8, storage_read=9/10, device_info=32/33). Fix CommandStatus enum values to match proto (was missing ERROR_DECODE, ERROR_NOT_IMPLEMENTED, ERROR_BUSY). Add FlipperDebugLog with build version counter. Fix import progress indicator to be indeterminate. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(flipper): extract shared BLE transport logic to commonMain base class Extract FlipperBleTransportBase with shared UUID constants, read buffering (Channel + readBuffer), flow control parsing, and close lifecycle. Android and iOS BLE transports now extend the base class instead of duplicating this logic. Web transport drops its local UUID aliases (JS interop has them hardcoded). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore(flipper): remove debug logging infrastructure Remove FlipperDebugLog, all log() calls in IosBleSerialTransport and FlipperRpcClient, debugLog() calls in FlipperViewModel, the DebugLogView composable, the debugLog field from FlipperUiState, and the iOS file writer setup in MainViewController. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(ui): consolidate import options into bottom sheet with Flipper UX improvements Replace separate "Import from File" and "Flipper Zero" menu items with a single "Import" item that opens a ModalBottomSheet showing platform-aware options (File Browser, Flipper BLE, Flipper USB). Hoist FlipperViewModel to enable immediate connection on transport selection. Add scrim overlay to Flipper import progress, and snackbar feedback on import completion. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(ui): polish import sheet, Flipper screen, and card status icons Disconnect Flipper on back navigation instead of a separate button. Show 'Flipper Zero' title during connecting state, center the spinner. Remove Import menu item from Explore tab (Cards tab only). Fix transparent backgrounds for bottom sheet list items. Always show Keys menu item regardless of platform. Pass supportedCardTypes/loadedKeyBundles to HistoryContent so imported cards display correct status icons instead of always showing unsupported. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(ui): replace Share/Save with file-based Share icon, simplify Flipper retry Replace two text menu items (Share, Save) with a single Share icon button in the card screen top bar that shares a .json file via platform-native file sharing (FileProvider on Android, temp file URL on iOS, download on web, save dialog on desktop). Simplify Flipper disconnected screen: since the connection protocol is already chosen from the home screen, replace USB/BLE buttons with a single Retry button that re-attempts the same protocol. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(flipper): use name-prefix filter for Web Bluetooth device discovery Chrome's requestDevice with a services filter requires the device to advertise the service UUID in its BLE advertisement packets. Flipper Zero advertises by name but doesn't include the serial service UUID in its advertisement data. Switch to namePrefix: 'Flipper' filter (matching Android/iOS behavior) and move the service UUID to optionalServices. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(ui): move sample cards into import bottom sheet Replace the overflow menu item with a 'Sample Cards' entry (with Code icon) as the last option in the import bottom sheet. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(ui): use IosShare icon on iOS for share button Add expect/actual platformShareIcon so iOS gets the native-looking IosShare icon while other platforms keep the standard Share icon. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(flipper): add console logging to Web BLE transport Add [FareBot BLE] prefixed console.log/error messages throughout the Web Bluetooth JS interop for easier debugging in Chrome DevTools. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(ui): always show Sample Cards, move Keys to Cards tab only Show the Sample Cards import option in all builds (not just debug). Remove Keys menu item from Explore tab since it only applies to the Cards tab. Add Import option to Explore tab menu. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(ui): remove Import from Explore tab menu Import is only relevant on the Cards tab. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(ui): include card name and serial in share filename Share files are now named like 'farebot-suica-04AB1234.json' instead of the generic 'farebot-card.json'. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(flipper): filter BLE devices by advertised service UUIDs instead of name prefix Match the official Flipper iOS app approach: scan for advertised service UUIDs (0x3080-0x3083) corresponding to hardware variants (f6, black, white, clear) instead of filtering by device name prefix. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(flipper): correct protobuf field numbers in tests to match Flipper proto spec Tests were using wrong Main envelope field numbers (e.g. 20 for storage_list_response instead of 8, 22 for storage_read_response instead of 10). Fixed to match the actual Flipper protobuf definition. Also fixed CommandStatus.ERROR_STORAGE_NOT_READY assertion (value is 5, not 2) and updated testConnectSendsStartRpcSession since BLE doesn't use a text handshake. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(ui): use selection-mode top bar for Flipper file selection Replace the bottom action bar (Import Selected / Import Keys buttons) with a selection-mode TopAppBar matching the Home screen pattern: selected files show [X] N selected [Download] in the top bar. Import Keys moves to an overflow menu on the regular top bar. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(ui): use proper plurals and localized strings for Flipper import messages Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(ui): polish Flipper file browser path bar, list item heights, and file sizes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(flipper): send start_rpc_session handshake for USB serial connections USB serial connects to Flipper's CLI mode. Must send 'start_rpc_session' and drain the CLI response before entering protobuf RPC mode. BLE transports go directly to protobuf mode and skip this step. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(flipper): drain CLI buffer before starting RPC session over USB The Flipper's serial buffer may contain existing CLI output. Send an empty command to trigger a fresh prompt, drain until '>: ' is seen, then send start_rpc_session and wait for the confirming newline. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(flipper): remove debug logging from RPC handshake Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(flipper): buffer excess bytes in Web Serial reads and fix RPC handshake Web Serial reader.read() returns variable-size chunks but the transport was discarding all bytes beyond the requested length. Added a JS-side buffer so excess bytes are preserved for subsequent reads. Also fixed the USB handshake to passively wait for the CLI prompt instead of sending an extra CR that created a stale second prompt in the buffer. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude <claude@codebutler.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 037f2fe commit 9e5e95f

67 files changed

Lines changed: 4895 additions & 405 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Makefile

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
IOS_DEVICE_ID := $(shell xcrun xctrace list devices 2>/dev/null | grep -v Simulator | grep -E '\([0-9A-F-]+\)$$' | grep -v Mac | head -1 | grep -oE '[0-9A-F]{8}-[0-9A-F]{16}')
2-
IOS_APP_PATH = $(shell ls -d ~/Library/Developer/Xcode/DerivedData/FareBot-*/Build/Products/Debug-iphoneos/FareBot.app 2>/dev/null | head -1)
2+
IOS_DERIVED_DATA := build/ios-derived-data
3+
IOS_APP_PATH := $(IOS_DERIVED_DATA)/Build/Products/Debug-iphoneos/FareBot.app
4+
IOS_SIM_APP_PATH := $(IOS_DERIVED_DATA)/Build/Products/Debug-iphonesimulator/FareBot.app
35

46
.PHONY: android android-install ios ios-sim ios-install desktop web web-run test clean help
57

@@ -16,11 +18,13 @@ android-install: android ## Build and install on connected Android device
1618
ios: ## Build iOS app for physical device
1719
./gradlew :app:linkDebugFrameworkIosArm64
1820
xcodebuild -project app/ios/FareBot.xcodeproj -scheme FareBot \
21+
-derivedDataPath $(IOS_DERIVED_DATA) \
1922
-destination 'id=$(IOS_DEVICE_ID)' -allowProvisioningUpdates build
2023

2124
ios-sim: ## Build iOS app for simulator
22-
./gradlew :app:linkDebugFrameworkIosSimulatorArm64
25+
./gradlew --no-daemon :app:linkDebugFrameworkIosSimulatorArm64
2326
xcodebuild -project app/ios/FareBot.xcodeproj -scheme FareBot \
27+
-derivedDataPath $(IOS_DERIVED_DATA) \
2428
-destination 'platform=iOS Simulator,name=iPhone 16' build
2529

2630
ios-install: ios ## Build and install on connected iOS device
@@ -48,7 +52,7 @@ test: ## Run all tests
4852

4953
clean: ## Clean all build artifacts
5054
./gradlew clean
51-
xcodebuild -project app/ios/FareBot.xcodeproj -scheme FareBot clean 2>/dev/null || true
55+
rm -rf $(IOS_DERIVED_DATA)
5256

5357
help: ## Show this help
5458
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}'

README.md

Lines changed: 200 additions & 193 deletions
Large diffs are not rendered by default.

app/android/src/main/AndroidManifest.xml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,16 @@
9494
android:resource="@xml/filter_nfc" />
9595
</activity>
9696

97+
<provider
98+
android:name="androidx.core.content.FileProvider"
99+
android:authorities="${applicationId}.fileprovider"
100+
android:exported="false"
101+
android:grantUriPermissions="true">
102+
<meta-data
103+
android:name="android.support.FILE_PROVIDER_PATHS"
104+
android:resource="@xml/file_paths" />
105+
</provider>
106+
97107
<meta-data
98108
android:name="com.google.android.maps.v2.API_KEY"
99109
android:value="AIzaSyBUUm1_1cyaCLkIvcE60gF4xO3pyb6SyP4"/>
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<paths>
3+
<cache-path name="shared" path="shared/" />
4+
</paths>

app/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ kotlin {
131131
api(project(":transit:warsaw"))
132132
api(project(":transit:zolotayakorona"))
133133
api(project(":transit:serialonly"))
134+
api(project(":flipper"))
134135
api(project(":transit:krocap"))
135136
api(project(":transit:snapper"))
136137
api(project(":transit:ndef"))

app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/DesktopAppGraph.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package com.codebutler.farebot.desktop
22

33
import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
44
import com.codebutler.farebot.card.serialize.CardSerializer
5+
import com.codebutler.farebot.flipper.FlipperTransportFactory
6+
import com.codebutler.farebot.flipper.JvmFlipperTransportFactory
57
import com.codebutler.farebot.persist.CardKeysPersister
68
import com.codebutler.farebot.persist.CardPersister
79
import com.codebutler.farebot.persist.db.DbCardKeysPersister
@@ -87,6 +89,10 @@ abstract class DesktopAppGraph : AppGraph {
8789
json: Json,
8890
): CardImporter = CardImporter(cardSerializer, json)
8991

92+
@Provides
93+
@SingleIn(AppScope::class)
94+
fun provideFlipperTransportFactory(): FlipperTransportFactory = JvmFlipperTransportFactory()
95+
9096
@Provides
9197
fun provideNullableCardScanner(scanner: CardScanner): CardScanner? = scanner
9298
}

app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/DesktopPlatformActions.kt

Lines changed: 14 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,20 @@ class DesktopPlatformActions : PlatformActions {
2424
clipboard.setContents(StringSelection(text), null)
2525
}
2626

27-
override fun shareText(text: String) {
28-
copyToClipboard(text)
29-
showToast("Copied to clipboard")
27+
override fun shareFile(
28+
content: String,
29+
fileName: String,
30+
mimeType: String,
31+
) {
32+
val chooser =
33+
JFileChooser().apply {
34+
selectedFile = File(fileName)
35+
dialogTitle = "Export Card"
36+
}
37+
val result = chooser.showSaveDialog(null)
38+
if (result == JFileChooser.APPROVE_OPTION) {
39+
chooser.selectedFile.writeText(content)
40+
}
3041
}
3142

3243
override fun showToast(message: String) {
@@ -47,21 +58,6 @@ class DesktopPlatformActions : PlatformActions {
4758
}
4859
}
4960

50-
override fun saveFileForExport(
51-
content: String,
52-
defaultFileName: String,
53-
) {
54-
val chooser =
55-
JFileChooser().apply {
56-
selectedFile = File(defaultFileName)
57-
dialogTitle = "Export Card"
58-
}
59-
val result = chooser.showSaveDialog(null)
60-
if (result == JFileChooser.APPROVE_OPTION) {
61-
chooser.selectedFile.writeText(content)
62-
}
63-
}
64-
6561
override fun pickFileForBytes(onResult: (ByteArray?) -> Unit) {
6662
val chooser =
6763
JFileChooser().apply {

app/ios/FareBot/Info.plist

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@
3333
</array>
3434
<key>CADisableMinimumFrameDurationOnPhone</key>
3535
<true/>
36+
<key>NSBluetoothAlwaysUsageDescription</key>
37+
<string>FareBot uses Bluetooth to connect to Flipper Zero for NFC dump import</string>
3638
<key>NFCReaderUsageDescription</key>
3739
<string>FareBot needs NFC access to read transit cards</string>
3840
<key>com.apple.developer.nfc.readersession.iso7816.select-identifiers</key>

app/src/androidMain/kotlin/com/codebutler/farebot/app/core/di/AndroidAppGraph.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import com.codebutler.farebot.app.core.nfc.TagReaderFactory
77
import com.codebutler.farebot.app.core.platform.AndroidAppPreferences
88
import com.codebutler.farebot.app.feature.home.AndroidCardScanner
99
import com.codebutler.farebot.card.serialize.CardSerializer
10+
import com.codebutler.farebot.flipper.AndroidFlipperTransportFactory
11+
import com.codebutler.farebot.flipper.FlipperTransportFactory
1012
import com.codebutler.farebot.persist.CardKeysPersister
1113
import com.codebutler.farebot.persist.CardPersister
1214
import com.codebutler.farebot.persist.db.DbCardKeysPersister
@@ -114,6 +116,11 @@ abstract class AndroidAppGraph : AppGraph {
114116
json: Json,
115117
): CardImporter = CardImporter(cardSerializer, json)
116118

119+
@Provides
120+
@SingleIn(AppScope::class)
121+
fun provideFlipperTransportFactory(context: Context): FlipperTransportFactory =
122+
AndroidFlipperTransportFactory(context)
123+
117124
@Provides
118125
fun provideNullableCardScanner(scanner: CardScanner): CardScanner? = scanner
119126
}

app/src/androidMain/kotlin/com/codebutler/farebot/app/core/platform/AndroidPlatformActions.kt

Lines changed: 15 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ import android.widget.Toast
1111
import androidx.activity.ComponentActivity
1212
import androidx.activity.result.ActivityResultLauncher
1313
import androidx.activity.result.contract.ActivityResultContracts
14+
import androidx.core.content.FileProvider
1415
import com.codebutler.farebot.shared.platform.PlatformActions
16+
import java.io.File
1517

1618
class AndroidPlatformActions(
1719
private val context: Context,
@@ -84,11 +86,21 @@ class AndroidPlatformActions(
8486
clipboard.setPrimaryClip(ClipData.newPlainText("FareBot", text))
8587
}
8688

87-
override fun shareText(text: String) {
89+
override fun shareFile(
90+
content: String,
91+
fileName: String,
92+
mimeType: String,
93+
) {
94+
val sharedDir = File(context.cacheDir, "shared")
95+
sharedDir.mkdirs()
96+
val file = File(sharedDir, fileName)
97+
file.writeText(content)
98+
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
8899
val intent =
89100
Intent(Intent.ACTION_SEND).apply {
90-
type = "text/plain"
91-
putExtra(Intent.EXTRA_TEXT, text)
101+
type = mimeType
102+
putExtra(Intent.EXTRA_STREAM, uri)
103+
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
92104
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
93105
}
94106
context.startActivity(
@@ -138,22 +150,4 @@ class AndroidPlatformActions(
138150
}
139151
launcher.launch(intent)
140152
}
141-
142-
override fun saveFileForExport(
143-
content: String,
144-
defaultFileName: String,
145-
) {
146-
val intent =
147-
Intent(Intent.ACTION_SEND).apply {
148-
type = "application/json"
149-
putExtra(Intent.EXTRA_TEXT, content)
150-
putExtra(Intent.EXTRA_SUBJECT, defaultFileName)
151-
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
152-
}
153-
context.startActivity(
154-
Intent.createChooser(intent, "Save").apply {
155-
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
156-
},
157-
)
158-
}
159153
}

0 commit comments

Comments
 (0)