diff --git a/.github/workflows/example-app.yml b/.github/workflows/example-app.yml index f7faa45..13a840d 100644 --- a/.github/workflows/example-app.yml +++ b/.github/workflows/example-app.yml @@ -8,17 +8,34 @@ on: jobs: build: - runs-on: ubuntu-latest if: ${{ !contains(github.event.head_commit.message, 'ci skip') }} strategy: fail-fast: false matrix: variant: [debug, release] + target: [apk, ios] + include: + - target: apk + os: ubuntu-latest + pre-build-script: "" + build-args: "" + debug-artifact-path: example/build/app/outputs/flutter-apk/app-debug.apk + release-artifact-path: example/build/app/outputs/flutter-apk/app-release.apk + - target: ios + os: macos-latest + pre-build-script: "" + build-args: "--no-codesign" + artifact-path: | + example/build/ios/iphoneos/Runner.app + + runs-on: ${{ matrix.os }} + name: ${{ matrix.target }}-${{ matrix.variant }} steps: - - uses: actions/checkout@v4 - - uses: actions/setup-java@v4 + - uses: actions/checkout@v6 + - uses: actions/setup-java@v5 + if: ${{ matrix.target == 'apk' }} with: distribution: 'temurin' java-version: '21' @@ -26,35 +43,22 @@ jobs: with: channel: 'stable' cache: true - - run: dart pub get - - run: dart format --output=none --set-exit-if-changed . - - run: dart analyze - - run: flutter pub get - working-directory: example/ - #- run: flutter test - - run: flutter build apk --${{ matrix.variant }} --verbose - working-directory: example/ - - uses: actions/upload-artifact@v4 - with: - name: example-apk-${{ matrix.variant }} - path: | - example/build/app/outputs/flutter-apk/app-${{ matrix.variant }}.apk - example/build/reports/* - build-ios: - runs-on: macos-latest - strategy: - fail-fast: false - matrix: - variant: [debug, release] + - name: pre-build-script for ${{ matrix.target }} + run: ${{ matrix.pre-build-script }} + if: ${{ matrix.pre-build-script != '' }} - steps: - - uses: actions/checkout@v4 - - uses: subosito/flutter-action@v2 - with: - channel: 'stable' - cache: true - run: flutter pub get working-directory: example/ - - run: flutter build ios --${{ matrix.variant }} --verbose --no-codesign + + - name: Run flutter ${{ matrix.variant }} build on ${{ matrix.target }} + run: flutter build ${{ matrix.target }} --${{ matrix.variant }} ${{ matrix.build-args }} --verbose working-directory: example/ + + - uses: actions/upload-artifact@v5 + with: + name: example-${{ matrix.target }}-${{ matrix.variant }} + path: | + ${{ matrix.debug-artifact-path }} + ${{ matrix.release-artifact-path }} + ${{ matrix.artifact-path }} diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 7d70259..d38aabf 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -1,8 +1,5 @@ name: Publish to pub.dev -env: - FLUTTER_VERSION: 3.24.5 - on: push: tags: @@ -15,13 +12,12 @@ jobs: id-token: write runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: dart-lang/setup-dart@v1 - name: Setup Flutter SDK - uses: flutter-actions/setup-flutter@v3 + uses: flutter-actions/setup-flutter@v4 with: channel: stable - version: ${{ env.FLUTTER_VERSION }} cache: true - name: Install dependencies run: flutter pub get diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..8527b95 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,36 @@ +name: Test + +on: + push: + branches: [master, develop] + pull_request: + branches: [master, develop] + +jobs: + test: + runs-on: ubuntu-latest + if: ${{ !contains(github.event.head_commit.message, 'ci skip') }} + strategy: + matrix: + channel: [stable, beta] + + steps: + - uses: actions/checkout@v6 + - uses: subosito/flutter-action@v2 + with: + channel: ${{ matrix.channel }} + cache: true + - run: dart pub get + - run: dart format --output=none --set-exit-if-changed lib/ + - run: dart analyze + # - run: dart test + - run: dart doc + - name: Upload generated dartdoc + uses: actions/upload-artifact@v5 + with: + name: docs-${{ matrix.channel }} + path: doc/ + - name: Evaluate score with pana + run: | + dart pub global activate pana + dart pub global run pana . diff --git a/CHANGELOG.md b/CHANGELOG.md index ac2127a..c5717d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -191,3 +191,15 @@ * Add Swift package manager support for iOS plugin, bump dependencies * Fix WebUSB interop on Web, add onDisconnect callback * Add support for foreground polling on Android (#16, #179) + +## 3.6.1 + +* Refactor with new `dart:js_interop` APIs to fix build with WASM (#223) +* Support `readBlock` / `writeBlock` on Mifare tags on iOS (#205 by @rushank-shah) +* More robust logic on Android + * ensure NFC Handler is always alive (#219) + * prevent an NPE due to wrong API typing (#220) + * add comment on `poll` related to Samsung API bug (#190, #200) +* Add more detailed error message in iOS APIs +* Bump tools to Gradle 9.2.1, AGP 8.13.0, Kotlin 2.2.21 + * Now `minSdkVersion` is lowered to 24 (#212) diff --git a/README.md b/README.md index f48442c..03f9546 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # Flutter NFC Kit [![pub version](https://img.shields.io/pub/v/flutter_nfc_kit)](https://pub.dev/packages/flutter_nfc_kit) -![Build Example App](https://github.com/nfcim/flutter_nfc_kit/workflows/Build%20Example%20App/badge.svg) +[![Test](https://github.com/nfcim/flutter_nfc_kit/actions/workflows/test.yml/badge.svg)](https://github.com/nfcim/flutter_nfc_kit/actions/workflows/test.yml) +[![Build Example App](https://github.com/nfcim/flutter_nfc_kit/actions/workflows/example-app.yml/badge.svg)](https://github.com/nfcim/flutter_nfc_kit/actions/workflows/example-app.yml) Yet another plugin to provide NFC functionality on Android, iOS and browsers (by WebUSB, see below). @@ -10,9 +11,9 @@ This plugin's functionalities include: * read metadata and read & write NDEF records of tags / cards complying with: * ISO 14443 Type A & Type B (NFC-A / NFC-B / MIFARE Classic / MIFARE Plus / MIFARE Ultralight / MIFARE DESFire) * ISO 18092 (NFC-F / FeliCa) - * ISO 15963 (NFC-V) + * ISO 15693 (NFC-V) * R/W block / page / sector level data of tags complying with: - * MIFARE Classic / Ultralight (Android only) + * MIFARE Classic / Ultralight (Android only, MIFARE Classic Read & Write block for iOS) * ISO 15693 (iOS only) * transceive raw commands with tags / cards complying with: * ISO 7816 Smart Cards (layer 4, in APDUs) @@ -35,7 +36,7 @@ We have the following minimum version requirements for Android plugin: * Java 17 * Gradle 8.9 -* Android SDK 26 (you must set corresponding `jvmTarget` in you app's `build.gradle`) +* Android SDK 24 (you must set corresponding `jvmTarget` in you app's `build.gradle`) * Android Gradle Plugin 8.7 To use this plugin on Android, you also need to: diff --git a/android/build.gradle b/android/build.gradle index 374d28d..3fca3ea 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -34,7 +34,7 @@ android { main.java.srcDirs += 'src/main/kotlin' } defaultConfig { - minSdkVersion 26 + minSdkVersion 24 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } lintOptions { diff --git a/android/gradle.properties b/android/gradle.properties index 48dd80c..16d6682 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,4 +1,4 @@ -org.gradle.jvmargs=-Xmx1536M +org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError android.enableJetifier=true -AGPVersion=8.7.3 -KotlinVersion=2.1.0 +AGPVersion=8.13.0 +KotlinVersion=2.2.21 diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 543c293..547f883 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,5 @@ -#Fri Sep 08 22:01:14 CST 2023 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/android/src/main/kotlin/im/nfc/flutter_nfc_kit/FlutterNfcKitPlugin.kt b/android/src/main/kotlin/im/nfc/flutter_nfc_kit/FlutterNfcKitPlugin.kt index 2ed8160..e54c4c3 100644 --- a/android/src/main/kotlin/im/nfc/flutter_nfc_kit/FlutterNfcKitPlugin.kt +++ b/android/src/main/kotlin/im/nfc/flutter_nfc_kit/FlutterNfcKitPlugin.kt @@ -71,6 +71,13 @@ class FlutterNfcKitPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { return transceiveMethod.invoke(this, data) as ByteArray } + private fun ensureNfcHandler() { + if (!::nfcHandlerThread.isInitialized || !nfcHandlerThread.isAlive) { + nfcHandlerThread = HandlerThread("FlutterNfcKit-NfcHandlerThread").apply { start() } + nfcHandler = Handler(nfcHandlerThread.looper) + } + } + private fun runOnNfcThread(result: Result, desc: String, fn: () -> Unit) { val handledFn = Runnable { try { @@ -89,6 +96,7 @@ class FlutterNfcKitPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { } } } + ensureNfcHandler() if (!nfcHandler.post(handledFn)) { result.error("500", "Failed to post job to NFC Handler thread.", null) } @@ -131,7 +139,9 @@ class FlutterNfcKitPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { type = "iso7816" val isoDep = IsoDep.get(tag) tagTechnology = isoDep - historicalBytes = isoDep.historicalBytes.toHexString() + // historicalBytes() may return null but is wrongly typed as ByteArray! + // https://developer.android.com/reference/kotlin/android/nfc/tech/IsoDep#gethistoricalbytes + historicalBytes = (isoDep.historicalBytes as ByteArray?)?.toHexString() ?: "" } tag.techList.contains(MifareClassic::class.java.name) -> { standard = "ISO 14443-3 (Type A)" @@ -241,9 +251,7 @@ class FlutterNfcKitPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { } override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { - nfcHandlerThread = HandlerThread("NfcHandlerThread") - nfcHandlerThread.start() - nfcHandler = Handler(nfcHandlerThread.looper) + ensureNfcHandler() methodChannel = MethodChannel(flutterPluginBinding.binaryMessenger, "flutter_nfc_kit/method") methodChannel.setMethodCallHandler(this) diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index 35f4d98..b2b3f17 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -45,7 +45,7 @@ android { defaultConfig { applicationId "im.nfc.flutter_nfc_kit_example" - minSdkVersion 26 + minSdkVersion 24 targetSdkVersion flutter.targetSdkVersion versionCode flutterVersionCode.toInteger() versionName flutterVersionName diff --git a/example/android/gradle.properties b/example/android/gradle.properties index 1e7bb61..5eb1ab1 100644 --- a/example/android/gradle.properties +++ b/example/android/gradle.properties @@ -1,4 +1,4 @@ -org.gradle.jvmargs=-Xmx1536M +org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true android.enableJetifier=true @@ -6,5 +6,5 @@ android.defaults.buildfeatures.buildconfig=true android.nonTransitiveRClass=false android.nonFinalResIds=false -AGPVersion=8.7.3 -KotlinVersion=2.1.0 +AGPVersion=8.13.0 +KotlinVersion=2.2.21 diff --git a/example/android/gradle/wrapper/gradle-wrapper.properties b/example/android/gradle/wrapper/gradle-wrapper.properties index 371c357..e4d69a7 100644 --- a/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/example/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStorePath=wrapper/dists zipStoreBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-8.11-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip diff --git a/example/lib/main.dart b/example/lib/main.dart index 4c8156a..0d460ca 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -158,7 +158,7 @@ class _MyAppState extends State with SingleTickerProviderStateMixin { padding: const EdgeInsets.symmetric(horizontal: 20), child: _tag != null ? Text( - 'ID: ${_tag!.id}\nStandard: ${_tag!.standard}\nType: ${_tag!.type}\nATQA: ${_tag!.atqa}\nSAK: ${_tag!.sak}\nHistorical Bytes: ${_tag!.historicalBytes}\nProtocol Info: ${_tag!.protocolInfo}\nApplication Data: ${_tag!.applicationData}\nHigher Layer Response: ${_tag!.hiLayerResponse}\nManufacturer: ${_tag!.manufacturer}\nSystem Code: ${_tag!.systemCode}\nDSF ID: ${_tag!.dsfId}\nNDEF Available: ${_tag!.ndefAvailable}\nNDEF Type: ${_tag!.ndefType}\nNDEF Writable: ${_tag!.ndefWritable}\nNDEF Can Make Read Only: ${_tag!.ndefCanMakeReadOnly}\nNDEF Capacity: ${_tag!.ndefCapacity}\nMifare Info:${_tag!.mifareInfo} Transceive Result:\n$_result\n\nBlock Message:\n$_mifareResult') + 'ID: ${_tag!.id}\nStandard: ${_tag!.standard}\nType: ${_tag!.type}\nATQA: ${_tag!.atqa}\nSAK: ${_tag!.sak}\nHistorical Bytes: ${_tag!.historicalBytes}\nProtocol Info: ${_tag!.protocolInfo}\nApplication Data: ${_tag!.applicationData}\nHigher Layer Response: ${_tag!.hiLayerResponse}\nManufacturer: ${_tag!.manufacturer}\nSystem Code: ${_tag!.systemCode}\nDSF ID: ${_tag!.dsfId}\nNDEF Available: ${_tag!.ndefAvailable}\nNDEF Type: ${_tag!.ndefType}\nNDEF Writable: ${_tag!.ndefWritable}\nNDEF Can Make Read Only: ${_tag!.ndefCanMakeReadOnly}\nNDEF Capacity: ${_tag!.ndefCapacity}\nMifare Info:${_tag!.mifareInfo}\nTransceive Result:\n$_result\n\nBlock Message:\n$_mifareResult') : const Text('No tag polled yet.')), ])))), Center( diff --git a/example/pubspec.lock b/example/pubspec.lock index 83e871a..5cde388 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -5,42 +5,42 @@ packages: dependency: transitive description: name: async - sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" url: "https://pub.dev" source: hosted - version: "2.11.0" + version: "2.13.0" boolean_selector: dependency: transitive description: name: boolean_selector - sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" characters: dependency: transitive description: name: characters - sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.4.0" clock: dependency: transitive description: name: clock - sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.2" collection: dependency: transitive description: name: collection - sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.1" convert: dependency: transitive description: @@ -53,10 +53,10 @@ packages: dependency: transitive description: name: crypto - sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf url: "https://pub.dev" source: hosted - version: "3.0.6" + version: "3.0.7" cupertino_icons: dependency: "direct main" description: @@ -69,10 +69,10 @@ packages: dependency: transitive description: name: fake_async - sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" url: "https://pub.dev" source: hosted - version: "1.3.1" + version: "1.3.3" fixnum: dependency: transitive description: @@ -92,7 +92,7 @@ packages: path: ".." relative: true source: path - version: "3.6.0" + version: "3.6.1" flutter_test: dependency: "direct dev" description: flutter @@ -115,26 +115,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" url: "https://pub.dev" source: hosted - version: "10.0.5" + version: "11.0.2" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" url: "https://pub.dev" source: hosted - version: "3.0.5" + version: "3.0.10" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" logging: dependency: "direct main" description: @@ -147,10 +147,10 @@ packages: dependency: transitive description: name: matcher - sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.16+1" + version: "0.12.17" material_color_utilities: dependency: transitive description: @@ -163,87 +163,79 @@ packages: dependency: transitive description: name: meta - sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.15.0" + version: "1.17.0" ndef: dependency: "direct main" description: name: ndef - sha256: "5083507cff4bb823b2a198a27ea2c70c4d6bc27a97b66097d966a250e1615d54" + sha256: "198ba3798e80cea381648569d84059dbba64cd140079fb7b0d9c3f1e0f5973f3" url: "https://pub.dev" source: hosted - version: "0.3.4" + version: "0.4.0" path: dependency: transitive description: name: path - sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" url: "https://pub.dev" source: hosted - version: "1.9.0" + version: "1.9.1" sky_engine: dependency: transitive description: flutter source: sdk - version: "0.0.99" + version: "0.0.0" source_span: dependency: transitive description: name: source_span - sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" url: "https://pub.dev" source: hosted - version: "1.10.0" - sprintf: - dependency: transitive - description: - name: sprintf - sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" - url: "https://pub.dev" - source: hosted - version: "7.0.0" + version: "1.10.1" stack_trace: dependency: transitive description: name: stack_trace - sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" url: "https://pub.dev" source: hosted - version: "1.11.1" + version: "1.12.1" stream_channel: dependency: transitive description: name: stream_channel - sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.4" string_scanner: dependency: transitive description: name: string_scanner - sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.4.1" term_glyph: dependency: transitive description: name: term_glyph - sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.2.2" test_api: dependency: transitive description: name: test_api - sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.2" + version: "0.7.7" typed_data: dependency: transitive description: @@ -252,30 +244,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + universal_platform: + dependency: transitive + description: + name: universal_platform + sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec" + url: "https://pub.dev" + source: hosted + version: "1.1.0" uuid: dependency: transitive description: name: uuid - sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff + sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 url: "https://pub.dev" source: hosted - version: "4.5.1" + version: "4.5.2" vector_math: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" vm_service: dependency: transitive description: name: vm_service - sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" url: "https://pub.dev" source: hosted - version: "14.2.5" + version: "15.0.2" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" sdks: - dart: ">=3.5.0 <4.0.0" + dart: ">=3.8.0-0 <4.0.0" flutter: ">=3.24.0" diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 5000b03..4bc0d17 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -11,7 +11,7 @@ dependencies: flutter_nfc_kit: path: ../ logging: ^1.3.0 - ndef: ^0.3.3 + ndef: ^0.4.0 cupertino_icons: ^1.0.8 dev_dependencies: diff --git a/example/web/index.html b/example/web/index.html index 776346a..17556cd 100644 --- a/example/web/index.html +++ b/example/web/index.html @@ -1,16 +1,6 @@ - @@ -26,20 +16,12 @@ - flutter_web_plugin_test_example + Flutter NFC Kit Example - - - + diff --git a/ios/flutter_nfc_kit/Sources/flutter_nfc_kit/FlutterNfcKitPlugin.swift b/ios/flutter_nfc_kit/Sources/flutter_nfc_kit/FlutterNfcKitPlugin.swift index 3ef6b34..3993122 100644 --- a/ios/flutter_nfc_kit/Sources/flutter_nfc_kit/FlutterNfcKitPlugin.swift +++ b/ios/flutter_nfc_kit/Sources/flutter_nfc_kit/FlutterNfcKitPlugin.swift @@ -108,12 +108,12 @@ public class FlutterNfcKitPlugin: NSObject, FlutterPlugin, NFCTagReaderSessionDe case let .iso7816(tag): let apdu: NFCISO7816APDU? = NFCISO7816APDU(data: data) if apdu == nil { - result(FlutterError(code: "400", message: "Command format error", details: nil)) + result(FlutterError(code: "400", message: "APDU format error", details: nil)) return } tag.sendCommand(apdu: apdu!) { (response: Data, sw1: UInt8, sw2: UInt8, error: Error?) in if let error = error { - result(FlutterError(code: "500", message: "Communication error", details: error.localizedDescription)) + result(FlutterError(code: "500", message: "Communication error with iso7816 tag", details: error.localizedDescription)) } else { var response = response response.append(contentsOf: [sw1, sw2]) @@ -133,7 +133,7 @@ public class FlutterNfcKitPlugin: NSObject, FlutterPlugin, NFCTagReaderSessionDe // the first byte in data is length, and iOS will add it for us, so skip it tag.sendFeliCaCommand(commandPacket: data.advanced(by: 1)) { (response: Data, error: Error?) in if let error = error { - result(FlutterError(code: "500", message: "Communication error", details: error.localizedDescription)) + result(FlutterError(code: "500", message: "Communication error with felica tag", details: error.localizedDescription)) } else { if req is String { result(response.hexEncodedString()) @@ -145,7 +145,7 @@ public class FlutterNfcKitPlugin: NSObject, FlutterPlugin, NFCTagReaderSessionDe case let .miFare(tag): tag.sendMiFareCommand(commandPacket: data) { (response: Data, error: Error?) in if let error = error { - result(FlutterError(code: "500", message: "Communication error", details: error.localizedDescription)) + result(FlutterError(code: "500", message: "Communication error with mifare tag", details: error.localizedDescription)) } else { if req is String { result(response.hexEncodedString()) @@ -198,7 +198,7 @@ public class FlutterNfcKitPlugin: NSObject, FlutterPlugin, NFCTagReaderSessionDe let extendedMode = (arguments["iso15693ExtendedMode"] as? Bool) ?? false let handler = { (dataBlock: Data, error: Error?) in if let error = error { - result(FlutterError(code: "500", message: "Communication error", details: error.localizedDescription)) + result(FlutterError(code: "500", message: "Cannot read iso15693 tag", details: error.localizedDescription)) } else { result(dataBlock) } @@ -210,7 +210,19 @@ public class FlutterNfcKitPlugin: NSObject, FlutterPlugin, NFCTagReaderSessionDe let blockNumber = arguments["index"] as! Int tag.extendedReadSingleBlock(requestFlags: RequestFlag(rawValue: rawFlags), blockNumber: blockNumber, completionHandler: handler) } - } else { + } + else if case let .miFare(tag) = tag { + let blockNumber = arguments["index"] as! UInt8 + let commandPacket = Data([0x30, blockNumber]) // MiFARE Classic / Ultralight READ command + tag.sendMiFareCommand(commandPacket: commandPacket) { (data, error) in + if let error = error { + result(FlutterError(code: "500", message: "Cannot read mifare tag", details: error.localizedDescription)) + } else { + result(data) + } + } + } + else { result(FlutterError(code: "405", message: "readBlock not supported on this type of card", details: nil)) } } else if call.method == "writeBlock" { @@ -221,7 +233,7 @@ public class FlutterNfcKitPlugin: NSObject, FlutterPlugin, NFCTagReaderSessionDe let extendedMode = (arguments["iso15693ExtendedMode"] as? Bool) ?? false let handler = { (error: Error?) in if let error = error { - result(FlutterError(code: "500", message: "Communication error", details: error.localizedDescription)) + result(FlutterError(code: "500", message: "Cannot write iso15693 tag", details: error.localizedDescription)) } else { result(nil) } @@ -233,7 +245,25 @@ public class FlutterNfcKitPlugin: NSObject, FlutterPlugin, NFCTagReaderSessionDe let blockNumber = arguments["index"] as! Int tag.extendedWriteSingleBlock(requestFlags: RequestFlag(rawValue: rawFlags), blockNumber: blockNumber, dataBlock: data, completionHandler: handler) } - } else { + } + else if case let .miFare(tag) = tag { + let blockNumber = arguments["index"] as! UInt8 + let command = switch tag.mifareFamily { + case .ultralight: + 0xA2 // MiFARE Ultralight WRITE command + default: + 0xA0 // MiFARE Classic WRITE command + } as UInt8 + let writeCommand = Data([command, blockNumber]) + data + tag.sendMiFareCommand(commandPacket: writeCommand) { (response, error) in + if let error = error { + result(FlutterError(code: "500", message: "Cannot write mifare tag", details: error.localizedDescription)) + } else { + result(nil) + } + } + } + else { result(FlutterError(code: "405", message: "writeBlock not supported on this type of card", details: nil)) } } else if call.method == "readNDEF" { diff --git a/lib/flutter_nfc_kit.dart b/lib/flutter_nfc_kit.dart index c051837..1df0778 100644 --- a/lib/flutter_nfc_kit.dart +++ b/lib/flutter_nfc_kit.dart @@ -7,6 +7,7 @@ import 'package:ndef/ndef.dart' as ndef; import 'package:ndef/ndef.dart' show TypeNameFormat; // for generated file import 'package:ndef/utilities.dart'; import 'package:json_annotation/json_annotation.dart'; +import 'package:universal_platform/universal_platform.dart'; part 'flutter_nfc_kit.g.dart'; @@ -277,7 +278,7 @@ class FlutterNfcKit { static const MethodChannel _channel = MethodChannel('flutter_nfc_kit/method'); - static const EventChannel _tagEventChannel = + static final EventChannel _tagEventChannel = EventChannel('flutter_nfc_kit/event'); /// Stream of NFC tag events. Each event is a [NFCTag] object. @@ -285,6 +286,9 @@ class FlutterNfcKit { /// This is only supported on Android. /// On other platforms, this stream will always be empty. static Stream get tagStream { + if (!UniversalPlatform.isAndroid) { + return const Stream.empty(); + } return _tagEventChannel.receiveBroadcastStream().map((dynamic event) { final Map json = jsonDecode(event as String); return NFCTag.fromJson(json); @@ -313,6 +317,8 @@ class FlutterNfcKit { /// /// The four boolean flags [readIso14443A], [readIso14443B], [readIso18092], [readIso15693] control the NFC technology that would be tried. /// On iOS, setting any of [readIso14443A] and [readIso14443B] will enable `iso14443` in `pollingOption`. + /// On Samsung Android devices, you may need to have [readIso18092] set to read other types of cards (e.g. Felica). + /// See for detailed discussion. /// /// On Web, all parameters are ignored except [timeout] and [probeWebUSBMagic]. /// If [probeWebUSBMagic] is set, the library will use the `PROBE` request to check whether the device supports our API (see [FlutterNfcKitWeb] for details). diff --git a/lib/flutter_nfc_kit_web.dart b/lib/flutter_nfc_kit_web.dart index 1fbc876..9298e9e 100644 --- a/lib/flutter_nfc_kit_web.dart +++ b/lib/flutter_nfc_kit_web.dart @@ -1,10 +1,7 @@ import 'dart:async'; -// In order to *not* need this ignore, consider extracting the "web" version -// of your plugin as a separate package, instead of inlining it in the same -// package as the core of your plugin. -// ignore: avoid_web_libraries_in_flutter -import 'dart:html' as html show window; -import 'dart:js_util'; +import 'dart:js_interop'; +import 'dart:js_interop_unsafe'; +import 'package:web/web.dart' show window; import 'package:convert/convert.dart'; import 'package:flutter/services.dart'; @@ -35,7 +32,7 @@ class FlutterNfcKitWeb { Future handleMethodCall(MethodCall call) async { switch (call.method) { case 'getNFCAvailability': - if (hasProperty(html.window.navigator, 'usb')) { + if (window.navigator.hasProperty('usb'.toJS).toDart) { return 'available'; } else { return 'not_supported'; diff --git a/lib/webusb_interop.dart b/lib/webusb_interop.dart index a0a750d..1c3b2df 100644 --- a/lib/webusb_interop.dart +++ b/lib/webusb_interop.dart @@ -6,10 +6,9 @@ library; import 'dart:convert'; -import 'dart:js_util'; import 'dart:async'; -import 'dart:typed_data'; import 'dart:js_interop'; +import 'dart:js_interop_unsafe' show JSObjectUnsafeUtilExtension; import 'package:convert/convert.dart'; import 'package:flutter/services.dart'; @@ -22,7 +21,8 @@ const int USB_CLASS_CODE_VENDOR_SPECIFIC = 0xFF; @JS('navigator.usb') extension type _USB._(JSObject _) implements JSObject { - external static JSObject requestDevice(_USBDeviceRequestOptions options); + external static JSPromise requestDevice( + _USBDeviceRequestOptions options); external static set ondisconnect(JSFunction value); } @@ -54,12 +54,18 @@ extension type _USBControlTransferParameters._(JSObject _) implements JSObject { /// /// Note: you should **NEVER use this class directly**, but instead use the [FlutterNfcKit] class in your project. class WebUSB { - static dynamic _device; + static JSObject? _device; static String customProbeData = ""; static Function? onDisconnect; static bool _deviceAvailable() { - return _device != null && getProperty(_device, 'opened'); + return _device != null && + _device!.getProperty('opened'.toJS).toDart; + } + + static Uint8List _getDataBufferFromResponse(JSObject response) { + var dataView = response.getProperty('data'.toJS).toDart; + return dataView.buffer.asUint8List(); } static const USB_PROBE_MAGIC = '_NFC_IM_'; @@ -71,11 +77,13 @@ class WebUSB { var devicePromise = _USB.requestDevice(_USBDeviceRequestOptions( filters: [_USBDeviceFilter(classCode: USB_CLASS_CODE_VENDOR_SPECIFIC)] .toJS)); - dynamic device = await promiseToFuture(devicePromise); + var device = await devicePromise.toDart; try { - await promiseToFuture(callMethod(device, 'open', List.empty())) + var openPromise = device.callMethod('open'.toJS) as JSPromise; + await openPromise.toDart .then((_) => - promiseToFuture(callMethod(device, 'claimInterface', [1]))) + (device.callMethod('claimInterface'.toJS, 1.toJS) as JSPromise) + .toDart) .timeout(Duration(milliseconds: timeout)); _device = device; _USB.ondisconnect = () { @@ -95,22 +103,21 @@ class WebUSB { if (probeMagic) { try { // PROBE request - var promise = callMethod(_device, 'controlTransferIn', [ - _USBControlTransferParameters( - requestType: 'vendor', - recipient: 'interface', - request: 0xff, - value: 0, - index: 1), - 1 - ]); - var resp = await promiseToFuture(promise); - if (getProperty(resp, 'status') == 'stalled') { + var promise = device.callMethod( + 'controlTransferIn'.toJS, + _USBControlTransferParameters( + requestType: 'vendor', + recipient: 'interface', + request: 0xff, + value: 0, + index: 1), + 1.toJS) as JSPromise; + var resp = await promise.toDart; + if (resp.getProperty('status'.toJS).toDart == 'stalled') { throw PlatformException( code: "500", message: "Device error: transfer stalled"); } - var result = - (getProperty(resp, 'data').buffer as ByteBuffer).asUint8List(); + var result = _getDataBufferFromResponse(resp); if (result.length < USB_PROBE_MAGIC.length || result.sublist(0, USB_PROBE_MAGIC.length) != Uint8List.fromList(USB_PROBE_MAGIC.codeUnits)) { @@ -129,9 +136,10 @@ class WebUSB { customProbeData = ""; } } + assert(_device != null); // get VID & PID - int vendorId = getProperty(_device, 'vendorId'); - int productId = getProperty(_device, 'productId'); + var vendorId = _device!.getProperty('vendorId'.toJS).toDartInt; + var productId = _device!.getProperty('productId'.toJS).toDartInt; String id = '${vendorId.toRadixString(16).padLeft(4, '0')}:${productId.toRadixString(16).padLeft(4, '0')}'; return json.encode({ @@ -144,33 +152,33 @@ class WebUSB { static Future _doTransceive(Uint8List capdu) async { // send a command (CMD) - var promise = callMethod(_device, 'controlTransferOut', [ - _USBControlTransferParameters( - requestType: 'vendor', - recipient: 'interface', - request: 0, - value: 0, - index: 1), - capdu - ]); - await promiseToFuture(promise); - // wait for execution to finish (STAT) - while (true) { - promise = callMethod(_device, 'controlTransferIn', [ + var promise = _device!.callMethod( + 'controlTransferOut'.toJS, _USBControlTransferParameters( requestType: 'vendor', recipient: 'interface', - request: 2, + request: 0, value: 0, index: 1), - 1 - ]); - var resp = await promiseToFuture(promise); - if (getProperty(resp, 'status') == 'stalled') { + capdu.toJS) as JSPromise; + await promise.toDart; + // wait for execution to finish (STAT) + while (true) { + promise = _device!.callMethod( + 'controlTransferIn'.toJS, + _USBControlTransferParameters( + requestType: 'vendor', + recipient: 'interface', + request: 2, + value: 0, + index: 1), + 1.toJS) as JSPromise; + var resp = await promise.toDart; + if (resp.getProperty('status'.toJS).toDart == 'stalled') { throw PlatformException( code: "500", message: "Device error: transfer stalled"); } - var code = getProperty(resp, 'data').buffer.asUint8List()[0]; + var code = _getDataBufferFromResponse(resp)[0]; if (code == 0) { break; } else if (code == 1) { @@ -181,24 +189,24 @@ class WebUSB { } } // get the response (RESP) - promise = callMethod(_device, 'controlTransferIn', [ - _USBControlTransferParameters( - requestType: 'vendor', - recipient: 'interface', - request: 1, - value: 0, - index: 1), - 1500 - ]); - var resp = await promiseToFuture(promise); - var deviceStatus = getProperty(resp, 'status'); + promise = _device!.callMethod( + 'controlTransferIn'.toJS, + _USBControlTransferParameters( + requestType: 'vendor', + recipient: 'interface', + request: 1, + value: 0, + index: 1), + 1500.toJS) as JSPromise; + var resp = await promise.toDart; + var deviceStatus = resp.getProperty('status'.toJS).toDart; if (deviceStatus != 'ok') { throw PlatformException( code: "500", message: "Device error: status should be \"ok\", got \"$deviceStatus\""); } - return getProperty(resp, 'data').buffer.asUint8List(); + return _getDataBufferFromResponse(resp); } /// Transceive data with polled WebUSB device according to our protocol. @@ -232,7 +240,8 @@ class WebUSB { if (_deviceAvailable()) { if (closeWebUSB) { try { - await promiseToFuture(callMethod(_device, "close", List.empty())); + await (_device!.callMethod("close".toJS) as JSPromise) + .toDart; } on Exception catch (e) { log.severe("Finish error: ", e); throw PlatformException( diff --git a/pubspec.yaml b/pubspec.yaml index 84eb15e..75a2ef1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: flutter_nfc_kit description: Provide NFC functionality on Android, iOS & Web, including reading metadata, read & write NDEF records, and transceive layer 3 & 4 data with NFC tags / cards -version: 3.6.0 +version: 3.6.1 homepage: "https://github.com/nfcim/flutter_nfc_kit" environment: @@ -13,14 +13,16 @@ dependencies: flutter_web_plugins: sdk: flutter json_annotation: ^4.8.1 - ndef: ^0.3.3 + ndef: ^0.4.0 convert: ^3.1.1 logging: ^1.2.0 + web: ^1.1.1 + universal_platform: ^1.1.0 dev_dependencies: flutter_test: sdk: flutter - lints: ^5.0.0 + lints: ^6.0.0 build_runner: ^2.4.9 json_serializable: ^6.7.1