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 +

+ FareBot +

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

FareBot

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

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

-## Platform Compatibility +

+ Android +    + iOS +    + Web +

+ +FareBot runs on: + +- **Android** — built-in NFC (6.0+) +- **iOS** — built-in NFC (iPhone 7+) +- **macOS** (experimental) — PC/SC smart card readers or PN533 USB NFC readers +- **Web** (experimental) — PN533 USB NFC readers (Chrome/Edge/Opera) + +## Download + + +- **Android:** Coming soon on Google Play +- **iOS:** Coming soon on the App Store +- **Web:** Coming soon +- **Build from source:** See [Building](#building) + +## Written By + +* [Eric Butler](https://x.com/codebutler) + +## Thanks To -| Protocol | Android | iOS | -|----------|---------|-----| -| [CEPAS](https://en.wikipedia.org/wiki/CEPAS) | Yes | Yes | -| [FeliCa](https://en.wikipedia.org/wiki/FeliCa) | Yes | Yes | -| [ISO 7816](https://en.wikipedia.org/wiki/ISO/IEC_7816) | Yes | Yes | -| [MIFARE Classic](https://en.wikipedia.org/wiki/MIFARE#MIFARE_Classic) | NXP NFC chips only | No | -| [MIFARE DESFire](https://en.wikipedia.org/wiki/MIFARE#MIFARE_DESFire) | Yes | Yes | -| [MIFARE Ultralight](https://en.wikipedia.org/wiki/MIFARE#MIFARE_Ultralight_and_MIFARE_Ultralight_EV1) | Yes | Yes | -| [NFC-V / Vicinity](https://en.wikipedia.org/wiki/Near-field_communication#Standards) | Yes | Yes | +> [!NOTE] +> Huge thanks to [the Metrodroid project](https://github.com/metrodroid/metrodroid), a fork of FareBot that added support for many additional transit systems. All features as of [v3.1.0 (`04a603ba`)](https://github.com/metrodroid/metrodroid/commit/04a603ba639f) have been backported. -MIFARE Classic requires proprietary NXP hardware and is not supported on iOS or on Android devices with non-NXP NFC controllers (e.g. most Samsung and some other devices). All other protocols work on both platforms. Cards marked **Android only** in the tables below use MIFARE Classic. +* [Karl Koscher](https://x.com/supersat) (ORCA) +* [Sean Cross](https://x.com/xobs) (CEPAS/EZ-Link) +* Anonymous Contributor (Clipper) +* [nfc-felica](http://code.google.com/p/nfc-felica/) and [IC SFCard Fan](http://www014.upp.so-net.ne.jp/SFCardFan/) projects (Suica) +* [Wilbert Duijvenvoorde](https://github.com/wandcode) (MIFARE Classic/OV-chipkaart) +* [tbonang](https://github.com/tbonang) (NETS FlashPay) +* [Marcelo Liberato](https://github.com/mliberato) (Bilhete Unico) +* [Lauri Andler](https://github.com/landler/) (HSL) +* [Michael Farrell](https://github.com/micolous/) (Opal, Manly Fast Ferry, Go card, Myki, Octopus) +* [Rob O'Regan](http://www.robx1.net/nswtkt/private/manlyff/manlyff.htm) (Manly Fast Ferry card image) +* [b33f](http://www.fuzzysecurity.com/tutorials/rfid/4.html) (EasyCard) +* [Bondan Sumbodo](http://sybond.web.id) (Kartu Multi Trip, COMMET) ## Supported Cards ### Asia -| Card | Location | Protocol | Platform | -|------|----------|----------|----------| -| [Beijing Municipal Card](https://en.wikipedia.org/wiki/Yikatong) | Beijing, China | ISO 7816 | Android, iOS | -| [City Union](https://en.wikipedia.org/wiki/China_T-Union) | China | ISO 7816 | Android, iOS | -| [Edy](https://en.wikipedia.org/wiki/Edy) | Japan | FeliCa | Android, iOS | -| [EZ-Link](http://www.ezlink.com.sg/) | Singapore | CEPAS | Android, iOS | -| [Kartu Multi Trip](https://en.wikipedia.org/wiki/Kereta_Commuter_Indonesia) | Jakarta, Indonesia | FeliCa | Android, iOS | -| [KomuterLink](https://en.wikipedia.org/wiki/KTM_Komuter) | Malaysia | Classic | Android only | -| [NETS FlashPay](https://www.nets.com.sg/) | Singapore | CEPAS | Android, iOS | -| [Octopus](https://www.octopus.com.hk/) | Hong Kong | FeliCa | Android, iOS | -| [One Card All Pass](https://en.wikipedia.org/wiki/One_Card_All_Pass) | South Korea | ISO 7816 | Android, iOS | -| [Shanghai Public Transportation Card](https://en.wikipedia.org/wiki/Shanghai_Public_Transportation_Card) | Shanghai, China | ISO 7816 | Android, iOS | -| [Shenzhen Tong](https://en.wikipedia.org/wiki/Shenzhen_Tong) | Shenzhen, China | ISO 7816 | Android, iOS | -| [Suica](https://en.wikipedia.org/wiki/Suica) / ICOCA / PASMO | Japan | FeliCa | Android, iOS | -| [T-money](https://en.wikipedia.org/wiki/T-money) | South Korea | ISO 7816 | Android, iOS | -| [T-Union](https://en.wikipedia.org/wiki/China_T-Union) | China | ISO 7816 | Android, iOS | -| [Touch 'n Go](https://www.touchngo.com.my/) | Malaysia | Classic | Android only | -| [Wuhan Tong](https://en.wikipedia.org/wiki/Wuhan_Metro) | Wuhan, China | ISO 7816 | Android, iOS | +| Card | Location | Protocol | Android | iOS | macOS | Web | +|------|----------|----------|---------|-----|-------|-----| +| [Beijing Municipal Card](https://en.wikipedia.org/wiki/Yikatong) | Beijing, China | ISO 7816 | ✅ | ✅ | ✅ | ✅ | +| [City Union](https://en.wikipedia.org/wiki/China_T-Union) | China | ISO 7816 | ✅ | ✅ | ✅ | ✅ | +| [Edy](https://en.wikipedia.org/wiki/Edy) | Japan | FeliCa | ✅ | ✅ | ✅ | ✅ | +| [EZ-Link](http://www.ezlink.com.sg/) | Singapore | CEPAS | ✅ | ✅ | ✅ | ✅ | +| [Kartu Multi Trip](https://en.wikipedia.org/wiki/Kereta_Commuter_Indonesia) | Jakarta, Indonesia | FeliCa | ✅ | ✅ | ✅ | ✅ | +| [KomuterLink](https://en.wikipedia.org/wiki/KTM_Komuter) | Malaysia | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [NETS FlashPay](https://www.nets.com.sg/) | Singapore | CEPAS | ✅ | ✅ | ✅ | ✅ | +| [Octopus](https://www.octopus.com.hk/) | Hong Kong | FeliCa | ✅ | ✅ | ✅ | ✅ | +| [One Card All Pass](https://en.wikipedia.org/wiki/One_Card_All_Pass) | South Korea | ISO 7816 | ✅ | ✅ | ✅ | ✅ | +| [Shanghai Public Transportation Card](https://en.wikipedia.org/wiki/Shanghai_Public_Transportation_Card) | Shanghai, China | ISO 7816 | ✅ | ✅ | ✅ | ✅ | +| [Shenzhen Tong](https://en.wikipedia.org/wiki/Shenzhen_Tong) | Shenzhen, China | ISO 7816 | ✅ | ✅ | ✅ | ✅ | +| [Suica](https://en.wikipedia.org/wiki/Suica) / ICOCA / PASMO | Japan | FeliCa | ✅ | ✅ | ✅ | ✅ | +| [T-money](https://en.wikipedia.org/wiki/T-money) | South Korea | ISO 7816 | ✅ | ✅ | ✅ | ✅ | +| [T-Union](https://en.wikipedia.org/wiki/China_T-Union) | China | ISO 7816 | ✅ | ✅ | ✅ | ✅ | +| [Touch 'n Go](https://www.touchngo.com.my/) | Malaysia | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Wuhan Tong](https://en.wikipedia.org/wiki/Wuhan_Metro) | Wuhan, China | ISO 7816 | ✅ | ✅ | ✅ | ✅ | ### Australia & New Zealand -| Card | Location | Protocol | Platform | -|------|----------|----------|----------| -| [Adelaide Metrocard](https://www.adelaidemetro.com.au/) | Adelaide, SA | DESFire | Android, iOS | -| [BUSIT](https://www.busit.co.nz/) | Waikato, NZ | Classic | Android only | -| [Manly Fast Ferry](http://www.manlyfastferry.com.au/) | Sydney, NSW | Classic | Android only | -| [Metrocard](https://www.metroinfo.co.nz/) | Christchurch, NZ | Classic | Android only | -| [Myki](https://www.ptv.vic.gov.au/tickets/myki/) | Melbourne, VIC | DESFire | Android, iOS | -| [Opal](https://www.opal.com.au/) | Sydney, NSW | DESFire | Android, iOS | -| [Otago GoCard](https://www.orc.govt.nz/) | Otago, NZ | Classic | Android only | -| [SeqGo](https://translink.com.au/) | Queensland | Classic | Android only | -| [SmartRide](https://www.busit.co.nz/) | Rotorua, NZ | Classic | Android only | -| [SmartRider](https://www.transperth.wa.gov.au/) | Perth, WA | Classic | Android only | -| [Snapper](https://www.snapper.co.nz/) | Wellington, NZ | ISO 7816 | Android, iOS | +| Card | Location | Protocol | Android | iOS | macOS | Web | +|------|----------|----------|---------|-----|-------|-----| +| [Adelaide Metrocard](https://www.adelaidemetro.com.au/) | Adelaide, SA | DESFire | ✅ | ✅ | ✅ | ✅ | +| [BUSIT](https://www.busit.co.nz/) | Waikato, NZ | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Manly Fast Ferry](http://www.manlyfastferry.com.au/) | Sydney, NSW | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Metrocard](https://www.metroinfo.co.nz/) | Christchurch, NZ | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Myki](https://www.ptv.vic.gov.au/tickets/myki/) | Melbourne, VIC | DESFire | ✅ | ✅ | ✅ | ✅ | +| [Opal](https://www.opal.com.au/) | Sydney, NSW | DESFire | ✅ | ✅ | ✅ | ✅ | +| [Otago GoCard](https://www.orc.govt.nz/) | Otago, NZ | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [SeqGo](https://translink.com.au/) | Queensland | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [SmartRide](https://www.busit.co.nz/) | Rotorua, NZ | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [SmartRider](https://www.transperth.wa.gov.au/) | Perth, WA | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Snapper](https://www.snapper.co.nz/) | Wellington, NZ | ISO 7816 | ✅ | ✅ | ✅ | ✅ | ### Europe -| Card | Location | Protocol | Platform | -|------|----------|----------|----------| -| [Bonobus](https://www.bonobus.es/) | Cadiz, Spain | Classic | Android only | -| [Carta Mobile](https://www.at-bus.it/) | Pisa, Italy | ISO 7816 (Calypso) | Android, iOS | -| [Envibus](https://www.envibus.fr/) | Sophia Antipolis, France | ISO 7816 (Calypso) | Android, iOS | -| [HSL](https://www.hsl.fi/) | Helsinki, Finland | DESFire | Android, iOS | -| [KorriGo](https://www.star.fr/) | Brittany, France | ISO 7816 (Calypso) | Android, iOS | -| [Leap](https://www.leapcard.ie/) | Dublin, Ireland | DESFire | Android, iOS | -| [Lisboa Viva](https://www.portalviva.pt/) | Lisbon, Portugal | ISO 7816 (Calypso) | Android, iOS | -| [Mobib](https://mobib.be/) | Brussels, Belgium | ISO 7816 (Calypso) | Android, iOS | -| [Navigo](https://www.iledefrance-mobilites.fr/) | Paris, France | ISO 7816 (Calypso) | Android, iOS | -| [OuRA](https://www.oura.com/) | Grenoble, France | ISO 7816 (Calypso) | Android, iOS | -| [OV-chipkaart](https://www.ov-chipkaart.nl/) | Netherlands | Classic / Ultralight | Android only (Classic), Android + iOS (Ultralight) | -| [Oyster](https://oyster.tfl.gov.uk/) | London, UK | Classic | Android only | -| [Pass Pass](https://www.passpass.fr/) | Hauts-de-France, France | ISO 7816 (Calypso) | Android, iOS | -| [Pastel](https://www.tisseo.fr/) | Toulouse, France | ISO 7816 (Calypso) | Android, iOS | -| [Rejsekort](https://www.rejsekort.dk/) | Denmark | Classic | Android only | -| [RicaricaMi](https://www.atm.it/) | Milan, Italy | Classic | Android only | -| [SLaccess](https://sl.se/) | Stockholm, Sweden | Classic | Android only | -| [TaM](https://www.tam-voyages.com/) | Montpellier, France | ISO 7816 (Calypso) | Android, iOS | -| [Tampere](https://www.nysse.fi/) | Tampere, Finland | DESFire | Android, iOS | -| [Tartu Bus](https://www.tartu.ee/) | Tartu, Estonia | Classic | Android only | -| [TransGironde](https://transgironde.fr/) | Gironde, France | ISO 7816 (Calypso) | Android, iOS | -| [Västtrafik](https://www.vasttrafik.se/) | Gothenburg, Sweden | Classic | Android only | -| [Venezia Unica](https://actv.avmspa.it/) | Venice, Italy | ISO 7816 (Calypso) | Android, iOS | -| [Waltti](https://waltti.fi/) | Finland | DESFire | Android, iOS | -| [Warsaw](https://www.ztm.waw.pl/) | Warsaw, Poland | Classic | Android only | +| Card | Location | Protocol | Android | iOS | macOS | Web | +|------|----------|----------|---------|-----|-------|-----| +| [Bonobus](https://www.bonobus.es/) | Cadiz, Spain | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Carta Mobile](https://www.at-bus.it/) | Pisa, Italy | ISO 7816 (Calypso) | ✅ | ✅ | ✅ | ✅ | +| [Envibus](https://www.envibus.fr/) | Sophia Antipolis, France | ISO 7816 (Calypso) | ✅ | ✅ | ✅ | ✅ | +| [HSL](https://www.hsl.fi/) | Helsinki, Finland | DESFire | ✅ | ✅ | ✅ | ✅ | +| [KorriGo](https://www.star.fr/) | Brittany, France | ISO 7816 (Calypso) | ✅ | ✅ | ✅ | ✅ | +| [Leap](https://www.leapcard.ie/) | Dublin, Ireland | DESFire | ✅ | ✅ | ✅ | ✅ | +| [Lisboa Viva](https://www.portalviva.pt/) | Lisbon, Portugal | ISO 7816 (Calypso) | ✅ | ✅ | ✅ | ✅ | +| [Mobib](https://mobib.be/) | Brussels, Belgium | ISO 7816 (Calypso) | ✅ | ✅ | ✅ | ✅ | +| [Navigo](https://www.iledefrance-mobilites.fr/) | Paris, France | ISO 7816 (Calypso) | ✅ | ✅ | ✅ | ✅ | +| [OuRA](https://www.oura.com/) | Grenoble, France | ISO 7816 (Calypso) | ✅ | ✅ | ✅ | ✅ | +| [OV-chipkaart](https://www.ov-chipkaart.nl/) | Netherlands | Classic 🔒 / Ultralight | ✅ | ✅³ | ✅ | ✅ | +| [Oyster](https://oyster.tfl.gov.uk/) | London, UK | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Pass Pass](https://www.passpass.fr/) | Hauts-de-France, France | ISO 7816 (Calypso) | ✅ | ✅ | ✅ | ✅ | +| [Pastel](https://www.tisseo.fr/) | Toulouse, France | ISO 7816 (Calypso) | ✅ | ✅ | ✅ | ✅ | +| [Rejsekort](https://www.rejsekort.dk/) | Denmark | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [RicaricaMi](https://www.atm.it/) | Milan, Italy | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [SLaccess](https://sl.se/) | Stockholm, Sweden | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [TaM](https://www.tam-voyages.com/) | Montpellier, France | ISO 7816 (Calypso) | ✅ | ✅ | ✅ | ✅ | +| [Tampere](https://www.nysse.fi/) | Tampere, Finland | DESFire | ✅ | ✅ | ✅ | ✅ | +| [Tartu Bus](https://www.tartu.ee/) | Tartu, Estonia | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [TransGironde](https://transgironde.fr/) | Gironde, France | ISO 7816 (Calypso) | ✅ | ✅ | ✅ | ✅ | +| [Västtrafik](https://www.vasttrafik.se/) | Gothenburg, Sweden | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Venezia Unica](https://actv.avmspa.it/) | Venice, Italy | ISO 7816 (Calypso) | ✅ | ✅ | ✅ | ✅ | +| [Waltti](https://waltti.fi/) | Finland | DESFire | ✅ | ✅ | ✅ | ✅ | +| [Warsaw](https://www.ztm.waw.pl/) | Warsaw, Poland | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | ### Middle East & Africa -| Card | Location | Protocol | Platform | -|------|----------|----------|----------| -| [Gautrain](https://www.gautrain.co.za/) | Gauteng, South Africa | Classic | Android only | -| [Hafilat](https://www.dot.abudhabi/) | Abu Dhabi, UAE | DESFire | Android, iOS | -| [Metro Q](https://www.qr.com.qa/) | Qatar | Classic | Android only | -| [RavKav](https://ravkav.co.il/) | Israel | ISO 7816 (Calypso) | Android, iOS | +| Card | Location | Protocol | Android | iOS | macOS | Web | +|------|----------|----------|---------|-----|-------|-----| +| [Gautrain](https://www.gautrain.co.za/) | Gauteng, South Africa | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Hafilat](https://www.dot.abudhabi/) | Abu Dhabi, UAE | DESFire | ✅ | ✅ | ✅ | ✅ | +| [Metro Q](https://www.qr.com.qa/) | Qatar | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [RavKav](https://ravkav.co.il/) | Israel | ISO 7816 (Calypso) | ✅ | ✅ | ✅ | ✅ | ### North America -| Card | Location | Protocol | Platform | -|------|----------|----------|----------| -| [Charlie Card](https://www.mbta.com/fares/charliecard) | Boston, MA | Classic | Android only | -| [Clipper](https://www.clippercard.com/) | San Francisco, CA | DESFire / Ultralight | Android, iOS | -| [Compass](https://www.compasscard.ca/) | Vancouver, Canada | Ultralight | Android, iOS | -| [LAX TAP](https://www.taptogo.net/) | Los Angeles, CA | Classic | Android only | -| [MSP GoTo](https://www.metrotransit.org/) | Minneapolis, MN | Classic | Android only | -| [Opus](https://www.stm.info/) | Montreal, Canada | ISO 7816 (Calypso) | Android, iOS | -| [ORCA](https://www.orcacard.com/) | Seattle, WA | DESFire | Android, iOS | -| [Ventra](https://www.ventrachicago.com/) | Chicago, IL | Ultralight | Android, iOS | +| Card | Location | Protocol | Android | iOS | macOS | Web | +|------|----------|----------|---------|-----|-------|-----| +| [Charlie Card](https://www.mbta.com/fares/charliecard) | Boston, MA | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Clipper](https://www.clippercard.com/) | San Francisco, CA | DESFire / Ultralight | ✅ | ✅ | ✅ | ✅ | +| [Compass](https://www.compasscard.ca/) | Vancouver, Canada | Ultralight | ✅ | ✅ | ✅ | ✅ | +| [LAX TAP](https://www.taptogo.net/) | Los Angeles, CA | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [MSP GoTo](https://www.metrotransit.org/) | Minneapolis, MN | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Opus](https://www.stm.info/) | Montreal, Canada | ISO 7816 (Calypso) | ✅ | ✅ | ✅ | ✅ | +| [ORCA](https://www.orcacard.com/) | Seattle, WA | DESFire | ✅ | ✅ | ✅ | ✅ | +| [Ventra](https://www.ventrachicago.com/) | Chicago, IL | Ultralight | ✅ | ✅ | ✅ | ✅ | ### Russia & Former Soviet Union -| Card | Location | Protocol | Platform | -|------|----------|----------|----------| -| [Crimea Trolleybus Card](https://www.korona.net/) | Crimea | Classic | Android only | -| [Ekarta](https://www.korona.net/) | Yekaterinburg, Russia | Classic | Android only | -| [Electronic Barnaul](https://umarsh.com/) | Barnaul, Russia | Classic | Android only | -| [Kazan](https://en.wikipedia.org/wiki/Kazan_Metro) | Kazan, Russia | Classic | Android only | -| [Kirov transport card](https://umarsh.com/) | Kirov, Russia | Classic | Android only | -| [Krasnodar ETK](https://www.korona.net/) | Krasnodar, Russia | Classic | Android only | -| [Kyiv Digital](https://www.eway.in.ua/) | Kyiv, Ukraine | Classic | Android only | -| [Kyiv Metro](https://www.eway.in.ua/) | Kyiv, Ukraine | Classic | Android only | -| [MetroMoney](https://www.tbilisi.gov.ge/) | Tbilisi, Georgia | Classic | Android only | -| [OMKA](https://umarsh.com/) | Omsk, Russia | Classic | Android only | -| [Orenburg EKG](https://www.korona.net/) | Orenburg, Russia | Classic | Android only | -| [Parus school card](https://www.korona.net/) | Crimea | Classic | Android only | -| [Penza transport card](https://umarsh.com/) | Penza, Russia | Classic | Android only | -| [Podorozhnik](https://podorozhnik.spb.ru/) | St. Petersburg, Russia | Classic | Android only | -| [Samara ETK](https://www.korona.net/) | Samara, Russia | Classic | Android only | -| [SitiCard](https://umarsh.com/) | Nizhniy Novgorod, Russia | Classic | Android only | -| [SitiCard (Vladimir)](https://umarsh.com/) | Vladimir, Russia | Classic | Android only | -| [Strizh](https://umarsh.com/) | Izhevsk, Russia | Classic | Android only | -| [Troika](https://troika.mos.ru/) | Moscow, Russia | Classic / Ultralight | Android only (Classic), Android + iOS (Ultralight) | -| [YarGor](https://yargor.ru/) | Yaroslavl, Russia | Classic | Android only | -| [Yaroslavl ETK](https://www.korona.net/) | Yaroslavl, Russia | Classic | Android only | -| [Yoshkar-Ola transport card](https://umarsh.com/) | Yoshkar-Ola, Russia | Classic | Android only | -| [Zolotaya Korona](https://www.korona.net/) | Russia | Classic | Android only | +| Card | Location | Protocol | Android | iOS | macOS | Web | +|------|----------|----------|---------|-----|-------|-----| +| [Crimea Trolleybus Card](https://www.korona.net/) | Crimea | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Ekarta](https://www.korona.net/) | Yekaterinburg, Russia | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Electronic Barnaul](https://umarsh.com/) | Barnaul, Russia | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Kazan](https://en.wikipedia.org/wiki/Kazan_Metro) | Kazan, Russia | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Kirov transport card](https://umarsh.com/) | Kirov, Russia | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Krasnodar ETK](https://www.korona.net/) | Krasnodar, Russia | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Kyiv Digital](https://www.eway.in.ua/) | Kyiv, Ukraine | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Kyiv Metro](https://www.eway.in.ua/) | Kyiv, Ukraine | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [MetroMoney](https://www.tbilisi.gov.ge/) | Tbilisi, Georgia | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [OMKA](https://umarsh.com/) | Omsk, Russia | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Orenburg EKG](https://www.korona.net/) | Orenburg, Russia | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Parus school card](https://www.korona.net/) | Crimea | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Penza transport card](https://umarsh.com/) | Penza, Russia | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Podorozhnik](https://podorozhnik.spb.ru/) | St. Petersburg, Russia | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Samara ETK](https://www.korona.net/) | Samara, Russia | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [SitiCard](https://umarsh.com/) | Nizhniy Novgorod, Russia | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [SitiCard (Vladimir)](https://umarsh.com/) | Vladimir, Russia | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Strizh](https://umarsh.com/) | Izhevsk, Russia | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Troika](https://troika.mos.ru/) | Moscow, Russia | Classic 🔒 / Ultralight | ✅ | ✅³ | ✅ | ✅ | +| [YarGor](https://yargor.ru/) | Yaroslavl, Russia | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Yaroslavl ETK](https://www.korona.net/) | Yaroslavl, Russia | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Yoshkar-Ola transport card](https://umarsh.com/) | Yoshkar-Ola, Russia | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Zolotaya Korona](https://www.korona.net/) | Russia | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | ### South America -| Card | Location | Protocol | Platform | -|------|----------|----------|----------| -| [Bilhete Único](http://www.sptrans.com.br/bilhete_unico/) | São Paulo, Brazil | Classic | Android only | -| [Bip!](https://www.red.cl/tarjeta-bip) | Santiago, Chile | Classic | Android only | +| Card | Location | Protocol | Android | iOS | macOS | Web | +|------|----------|----------|---------|-----|-------|-----| +| [Bilhete Único](http://www.sptrans.com.br/bilhete_unico/) | São Paulo, Brazil | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | +| [Bip!](https://www.red.cl/tarjeta-bip) | Santiago, Chile | Classic 🔒 | ✅¹ | ❌ | ✅ | ✅ | ### Taiwan -| Card | Location | Protocol | Platform | -|------|----------|----------|----------| -| [EasyCard](https://www.easycard.com.tw/) | Taipei | Classic / DESFire | Android only (Classic), Android + iOS (DESFire) | +| Card | Location | Protocol | Android | iOS | macOS | Web | +|------|----------|----------|---------|-----|-------|-----| +| [EasyCard](https://www.easycard.com.tw/) | Taipei | Classic 🔒 / DESFire | ✅ | ✅⁴ | ✅ | ✅ | ### Identification Only (Serial Number) These cards can be detected and identified, but their data is locked or not stored on-card: -| Card | Location | Protocol | Platform | Reason | -|------|----------|----------|----------|--------| -| [AT HOP](https://at.govt.nz/bus-train-ferry/at-hop-card/) | Auckland, NZ | DESFire | Android, iOS | Locked | -| [Holo](https://www.holocard.net/) | Oahu, HI | DESFire | Android, iOS | Not stored on card | -| [Istanbul Kart](https://www.istanbulkart.istanbul/) | Istanbul, Turkey | DESFire | Android, iOS | Locked | -| [Nextfare DESFire](https://en.wikipedia.org/wiki/Cubic_Transportation_Systems) | Various | DESFire | Android, iOS | Locked | -| [Nol](https://www.nol.ae/) | Dubai, UAE | DESFire | Android, iOS | Locked | -| [Nortic](https://rfrend.no/) | Scandinavia | DESFire | Android, iOS | Locked | -| [Presto](https://www.prestocard.ca/) | Ontario, Canada | DESFire | Android, iOS | Locked | -| [Strelka](https://strelkacard.ru/) | Moscow Region, Russia | Classic | Android only | Locked | -| [Sun Card](https://sunrail.com/) | Orlando, FL | Classic | Android only | Locked | -| [TPF](https://www.tpf.ch/) | Fribourg, Switzerland | DESFire | Android, iOS | Locked | -| [TriMet Hop](https://myhopcard.com/) | Portland, OR | DESFire | Android, iOS | Not stored on card | +| Card | Location | Protocol | Reason | Android | iOS | macOS | Web | +|------|----------|----------|--------|---------|-----|-------|-----| +| [AT HOP](https://at.govt.nz/bus-train-ferry/at-hop-card/) | Auckland, NZ | DESFire | Locked | ✅ | ✅ | ✅ | ✅ | +| [Holo](https://www.holocard.net/) | Oahu, HI | DESFire | Not stored on card | ✅ | ✅ | ✅ | ✅ | +| [Istanbul Kart](https://www.istanbulkart.istanbul/) | Istanbul, Turkey | DESFire | Locked | ✅ | ✅ | ✅ | ✅ | +| [Nextfare DESFire](https://en.wikipedia.org/wiki/Cubic_Transportation_Systems) | Various | DESFire | Locked | ✅ | ✅ | ✅ | ✅ | +| [Nol](https://www.nol.ae/) | Dubai, UAE | DESFire | Locked | ✅ | ✅ | ✅ | ✅ | +| [Nortic](https://rfrend.no/) | Scandinavia | DESFire | Locked | ✅ | ✅ | ✅ | ✅ | +| [Presto](https://www.prestocard.ca/) | Ontario, Canada | DESFire | Locked | ✅ | ✅ | ✅ | ✅ | +| [Strelka](https://strelkacard.ru/) | Moscow Region, Russia | Classic 🔒 | Locked | ✅¹ | ❌ | ✅ | ✅ | +| [Sun Card](https://sunrail.com/) | Orlando, FL | Classic 🔒 | Locked | ✅¹ | ❌ | ✅ | ✅ | +| [TPF](https://www.tpf.ch/) | Fribourg, Switzerland | DESFire | Locked | ✅ | ✅ | ✅ | ✅ | +| [TriMet Hop](https://myhopcard.com/) | Portland, OR | DESFire | Not stored on card | ✅ | ✅ | ✅ | ✅ | + +## Platform Compatibility + +| Protocol | Android | iOS | macOS | Web | +|----------|---------|-----|-------|-----| +| [CEPAS](https://en.wikipedia.org/wiki/CEPAS) | ✅ | ✅ | ✅ | ✅ | +| [FeliCa](https://en.wikipedia.org/wiki/FeliCa) | ✅ | ✅ | ✅ | ✅ | +| [ISO 7816](https://en.wikipedia.org/wiki/ISO/IEC_7816) | ✅ | ✅ | ✅ | ✅ | +| [MIFARE Classic](https://en.wikipedia.org/wiki/MIFARE#MIFARE_Classic) | ✅¹ | ❌ | ✅ | ✅ | +| [MIFARE DESFire](https://en.wikipedia.org/wiki/MIFARE#MIFARE_DESFire) | ✅ | ✅ | ✅ | ✅ | +| [MIFARE Ultralight](https://en.wikipedia.org/wiki/MIFARE#MIFARE_Ultralight_and_MIFARE_Ultralight_EV1) | ✅ | ✅ | ✅ | ✅ | +| [NFC-V / Vicinity](https://en.wikipedia.org/wiki/Near-field_communication#Standards) | ✅ | ✅ | ✅² | ❌ | + +¹ Requires NXP NFC chip — most Samsung and some other Android devices use non-NXP controllers and cannot read MIFARE Classic. +² PC/SC readers only. PN533-based USB readers do not support NFC-V. +³ Ultralight variant only. +⁴ DESFire variant only. +🔒 Requires encryption keys — see [Cards Requiring Keys](#cards-requiring-keys). ## Cards Requiring Keys -Some MIFARE Classic cards require encryption keys to read. You can obtain keys using a [Proxmark3](https://github.com/Proxmark/proxmark3/wiki/Mifare-HowTo) or [MFOC](https://github.com/nfc-tools/mfoc). These include: +Some MIFARE Classic cards require encryption keys to read. You can obtain keys using a [Flipper Zero](https://docs.flipper.net/nfc/mf-classic), [Proxmark3](https://github.com/Proxmark/proxmark3/wiki/Mifare-HowTo), or [MFOC](https://github.com/nfc-tools/mfoc). These include: * Bilhete Único * Charlie Card @@ -179,12 +232,18 @@ Some MIFARE Classic cards require encryption keys to read. You can obtain keys u * Oyster * And most other MIFARE Classic-based cards -## Requirements +## Flipper Zero Integration -* **Android:** NFC-enabled device running Android 6.0 (API 23) or later -* **iOS:** iPhone 7 or later with iOS support for CoreNFC -* **macOS** (experimental): Mac with a PC/SC-compatible NFC smart card reader (e.g., ACR122U), a PN533-based USB NFC controller (e.g., SCL3711), or a Sony RC-S956 (PaSoRi) USB NFC reader -* **Web** (experimental): Any modern browser with WebAssembly support. Card data can be imported from JSON files exported by other platforms. Live NFC card reading is supported in Chrome/Edge/Opera via WebUSB with a PN533-based USB NFC reader (e.g., SCL3711). +FareBot supports connecting to a [Flipper Zero](https://flipperzero.one/) to browse and import NFC card dumps and MIFARE Classic key dictionaries. + +| Platform | USB | Bluetooth | +|----------|-----|-----------| +| Android | Yes | Yes | +| iOS | — | Yes | +| macOS | Yes | — | +| Web | Yes | Yes | + +From the home screen menu, tap **Flipper Zero** to connect via USB serial or Bluetooth Low Energy, browse the `/ext/nfc` file system, select card dump files (`.nfc`), and import them into your card history. You can also import the Flipper user key dictionary (`mf_classic_dict_user.nfc`) into the app's global key store, which is used as a fallback when reading MIFARE Classic cards. ## Building @@ -207,38 +266,7 @@ $ make # show all targets | `make test` | Run all tests | | `make clean` | Clean all build artifacts | -## Development Container - -A devcontainer is provided for sandboxed development with [Claude Code](https://claude.com/claude-code). It runs Claude with `--dangerously-skip-permissions` inside a network-restricted Docker container so agents can work unattended without risk of arbitrary network access. - -### What's included - -* Bun runtime + Claude Code -* Java 21 + Gradle (via devcontainer feature) -* tmux, zsh, git-delta, fzf, gh CLI -* iptables firewall allowing only: Anthropic API, GitHub, Maven Central, Google Maven, Gradle Plugin Portal, JetBrains repos, npm/bun registries -* All other outbound traffic is blocked - -### Quick start - -```bash -bun install -g @devcontainers/cli # one-time -.devcontainer/dc up # build and start -.devcontainer/dc auth # one-time: authenticate with GitHub -.devcontainer/dc claude # run Claude (--dangerously-skip-permissions, in tmux) -``` - -The `dc claude` command runs Claude inside a tmux session. Re-running it reattaches to the existing session instead of starting a new one. Other commands: - -``` -.devcontainer/dc shell # zsh shell in the container -.devcontainer/dc run # run any command (e.g. ./gradlew allTests) -.devcontainer/dc down # stop the container -``` - -Git push uses HTTPS via `gh auth` — no SSH keys are mounted. Credentials persist in a Docker volume across container restarts. - -Compatible with [Zed](https://zed.dev/docs/dev-containers), VS Code (Remote - Containers extension), and the `devcontainer` CLI. +A [development container](.devcontainer/README.md) is available for sandboxed development with Claude Code. ## Tech Stack @@ -256,34 +284,13 @@ Compatible with [Zed](https://zed.dev/docs/dev-containers), VS Code (Remote - Co - `card/*/` — Card protocol implementations (classic, desfire, felica, etc.) - `transit/` — Shared transit abstractions (Trip, Station, TransitInfo, etc.) - `transit/*/` — Transit system implementations (one per system) +- `flipper/` — Flipper Zero integration (RPC client, transport abstractions, parsers) - `app/` — KMP app framework (UI, ViewModels, DI, platform code) - `app/android/` — Android app shell (Activities, manifest, resources) - `app/ios/` — iOS app shell (Swift entry point, assets, config) - `app/desktop/` — macOS desktop app (experimental, PC/SC + PN533 + RC-S956 USB NFC) - `app/web/` — Web app (experimental, WebAssembly via Kotlin/Wasm) -## Written By - -* [Eric Butler](https://x.com/codebutler) - -## Thanks To - -> [!NOTE] -> Huge thanks to [the Metrodroid project](https://github.com/metrodroid/metrodroid), a fork of FareBot that added support for many additional transit systems. All features as of [v3.1.0 (`04a603ba`)](https://github.com/metrodroid/metrodroid/commit/04a603ba639f) have been backported. - -* [Karl Koscher](https://x.com/supersat) (ORCA) -* [Sean Cross](https://x.com/xobs) (CEPAS/EZ-Link) -* Anonymous Contributor (Clipper) -* [nfc-felica](http://code.google.com/p/nfc-felica/) and [IC SFCard Fan](http://www014.upp.so-net.ne.jp/SFCardFan/) projects (Suica) -* [Wilbert Duijvenvoorde](https://github.com/wandcode) (MIFARE Classic/OV-chipkaart) -* [tbonang](https://github.com/tbonang) (NETS FlashPay) -* [Marcelo Liberato](https://github.com/mliberato) (Bilhete Unico) -* [Lauri Andler](https://github.com/landler/) (HSL) -* [Michael Farrell](https://github.com/micolous/) (Opal, Manly Fast Ferry, Go card, Myki, Octopus) -* [Rob O'Regan](http://www.robx1.net/nswtkt/private/manlyff/manlyff.htm) (Manly Fast Ferry card image) -* [b33f](http://www.fuzzysecurity.com/tutorials/rfid/4.html) (EasyCard) -* [Bondan Sumbodo](http://sybond.web.id) (Kartu Multi Trip, COMMET) - ## License This program is free software: you can redistribute it and/or modify diff --git a/app/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")