diff --git a/.gitmodules b/.gitmodules
deleted file mode 100644
index e69de29bb..000000000
diff --git a/README.md b/README.md
index 5a2d38612..16f1d0930 100644
--- a/README.md
+++ b/README.md
@@ -1,176 +1,229 @@
-# 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
+
+
+
+
+
+
+
+
+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/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..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
@@ -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/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/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..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
@@ -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..9368f90dd 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,13 @@ 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..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
@@ -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,30 @@ 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 +72,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..6e2177dad 100644
--- a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/App.kt
+++ b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/App.kt
@@ -32,6 +32,7 @@ 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.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
@@ -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..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,6 +12,7 @@ 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.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..d494b6293
--- /dev/null
+++ b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/ui/screen/FlipperScreen.kt
@@ -0,0 +1,377 @@
+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.automirrored.filled.InsertDriveFile
+import androidx.compose.material.icons.filled.Bluetooth
+import androidx.compose.material.icons.filled.Folder
+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..400c94da4
--- /dev/null
+++ b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/viewmodel/FlipperViewModel.kt
@@ -0,0 +1,267 @@
+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.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
+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..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
@@ -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/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/LocalStorageCardKeysPersister.kt b/app/web/src/wasmJsMain/kotlin/com/codebutler/farebot/web/LocalStorageCardKeysPersister.kt
index 5bd7a0d15..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
@@ -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,32 @@ 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..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
@@ -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/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/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..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
@@ -268,6 +268,34 @@ 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/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);
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..4ecdb8726
--- /dev/null
+++ b/flipper/src/androidMain/kotlin/com/codebutler/farebot/flipper/AndroidBleSerialTransport.kt
@@ -0,0 +1,213 @@
+@file:Suppress("MissingPermission")
+
+package com.codebutler.farebot.flipper
+
+import android.annotation.SuppressLint
+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..50f23e735
--- /dev/null
+++ b/flipper/src/androidMain/kotlin/com/codebutler/farebot/flipper/AndroidFlipperTransportFactory.kt
@@ -0,0 +1,14 @@
+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..0972dfbe3
--- /dev/null
+++ b/flipper/src/androidMain/kotlin/com/codebutler/farebot/flipper/AndroidUsbSerialTransport.kt
@@ -0,0 +1,192 @@
+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..c34b5bbab
--- /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..fbd6db27c
--- /dev/null
+++ b/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/FlipperRpcClient.kt
@@ -0,0 +1,446 @@
+/*
+ * 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 =
+ 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..2ffafc321
--- /dev/null
+++ b/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/FlipperTransport.kt
@@ -0,0 +1,39 @@
+/*
+ * 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..2c493422a
--- /dev/null
+++ b/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/Varint.kt
@@ -0,0 +1,54 @@
+/*
+ * 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/CommandStatus.kt b/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/proto/CommandStatus.kt
new file mode 100644
index 000000000..7c4b5cf9b
--- /dev/null
+++ b/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/proto/CommandStatus.kt
@@ -0,0 +1,57 @@
+/*
+ * CommandStatus.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..8f071ece0
--- /dev/null
+++ b/flipper/src/commonMain/kotlin/com/codebutler/farebot/flipper/proto/FlipperStorage.kt
@@ -0,0 +1,136 @@
+/*
+ * 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..67cede7ac
--- /dev/null
+++ b/flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/FlipperIntegrationTest.kt
@@ -0,0 +1,189 @@
+/*
+ * 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..b15c0c555
--- /dev/null
+++ b/flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/FlipperKeyDictParserTest.kt
@@ -0,0 +1,102 @@
+/*
+ * 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..06021fd3d
--- /dev/null
+++ b/flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/FlipperRpcClientTest.kt
@@ -0,0 +1,291 @@
+/*
+ * 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..cc38f3305
--- /dev/null
+++ b/flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/MockTransport.kt
@@ -0,0 +1,60 @@
+/*
+ * 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..9404b2b0b
--- /dev/null
+++ b/flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/VarintTest.kt
@@ -0,0 +1,71 @@
+/*
+ * 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..87014b7ba
--- /dev/null
+++ b/flipper/src/commonTest/kotlin/com/codebutler/farebot/flipper/proto/FlipperProtoTest.kt
@@ -0,0 +1,97 @@
+/*
+ * 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..bb2fb7d8d
--- /dev/null
+++ b/flipper/src/iosMain/kotlin/com/codebutler/farebot/flipper/IosBleSerialTransport.kt
@@ -0,0 +1,276 @@
+package com.codebutler.farebot.flipper
+
+import kotlinx.cinterop.ExperimentalForeignApi
+import kotlinx.cinterop.ObjCSignatureOverride
+import kotlinx.cinterop.addressOf
+import kotlinx.cinterop.usePinned
+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.Foundation.dataWithBytes
+import platform.darwin.NSObject
+import platform.posix.memcpy
+
+/**
+ * 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)
+ }
+
+ @ObjCSignatureOverride
+ override fun centralManager(
+ central: CBCentralManager,
+ didFailToConnectPeripheral: CBPeripheral,
+ error: NSError?,
+ ) {
+ connectionDeferred?.completeExceptionally(
+ FlipperException("BLE connection failed: ${error?.localizedDescription}"),
+ )
+ }
+
+ @ObjCSignatureOverride
+ 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"),
+ )
+ }
+ }
+
+ @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
+ }
+ }
+
+ 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 {
+ if (isEmpty()) return NSData()
+ return usePinned { pinned ->
+ NSData.dataWithBytes(pinned.addressOf(0), 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..3ed02f5f2
--- /dev/null
+++ b/flipper/src/iosMain/kotlin/com/codebutler/farebot/flipper/IosFlipperTransportFactory.kt
@@ -0,0 +1,10 @@
+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..aff0fe4b8
--- /dev/null
+++ b/flipper/src/jvmMain/kotlin/com/codebutler/farebot/flipper/JvmFlipperTransportFactory.kt
@@ -0,0 +1,10 @@
+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..f72784d25
--- /dev/null
+++ b/flipper/src/jvmMain/kotlin/com/codebutler/farebot/flipper/JvmUsbSerialTransport.kt
@@ -0,0 +1,75 @@
+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..89e7e50ed
--- /dev/null
+++ b/flipper/src/wasmJsMain/kotlin/com/codebutler/farebot/flipper/WebBleTransport.kt
@@ -0,0 +1,229 @@
+@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..6221e4252
--- /dev/null
+++ b/flipper/src/wasmJsMain/kotlin/com/codebutler/farebot/flipper/WebFlipperTransportFactory.kt
@@ -0,0 +1,10 @@
+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..7042c813d
--- /dev/null
+++ b/flipper/src/wasmJsMain/kotlin/com/codebutler/farebot/flipper/WebSerialTransport.kt
@@ -0,0 +1,219 @@
+@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")