diff --git a/package-lock.json b/package-lock.json index 620a480..141dd3d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -100,6 +100,7 @@ "react-router-dom": "^6.26.2", "recharts": "^2.12.7", "skatespots-background": "file:packages/skatespots-background", + "skatespots-nfc": "file:packages/skatespots-nfc", "tailwind-merge": "^2.5.2", "tailwindcss-animate": "^1.0.7", "three": "^0.185.1", @@ -11300,6 +11301,10 @@ "resolved": "packages/skatespots-background", "link": true }, + "node_modules/skatespots-nfc": { + "resolved": "packages/skatespots-nfc", + "link": true + }, "node_modules/slice-ansi": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", @@ -13629,6 +13634,12 @@ "dependencies": { "@capacitor/core": "^8.4.2" } + }, + "packages/skatespots-nfc": { + "version": "0.0.1", + "dependencies": { + "@capacitor/core": "^8.4.2" + } } } } diff --git a/package.json b/package.json index f85b9e3..78b7ea8 100644 --- a/package.json +++ b/package.json @@ -119,6 +119,7 @@ "react-router-dom": "^6.26.2", "recharts": "^2.12.7", "skatespots-background": "file:packages/skatespots-background", + "skatespots-nfc": "file:packages/skatespots-nfc", "tailwind-merge": "^2.5.2", "tailwindcss-animate": "^1.0.7", "three": "^0.185.1", diff --git a/packages/skatespots-nfc/README.md b/packages/skatespots-nfc/README.md new file mode 100644 index 0000000..8c7914b --- /dev/null +++ b/packages/skatespots-nfc/README.md @@ -0,0 +1,26 @@ +# skatespots-nfc (local Capacitor Android plugin) + +Writes the card/pass **verify URL** to an NFC tag from inside the SkateSpots APK. + +**Why a plugin?** Web NFC (`NDEFReader`) is not exposed inside Capacitor's +WebView, so the browser path in `src/lib/nfc.ts` can't run in the APK. On the web +(Chrome/Android) the app uses Web NFC; in the APK it uses this plugin. + +**What it writes:** only a single NDEF **URL record** — the same pointer the QR +carries. A tag is passive and can be cloned like a QR; trust comes from the relay +signature, verified at `/#/verify/`. + +## Wiring +- `package.json` has `"skatespots-nfc": "file:packages/skatespots-nfc"` so + `npx cap sync android` adds the module. +- The app registers the plugin **by name** (`registerPlugin('SkateSpotsNfc')`); it + does not import this package into the web bundle. +- Permissions (`NFC`, optional `uses-feature`) merge from the plugin manifest. + +## API +`isSupported(): { supported }` · `writeUrl({ url })` · `cancel()` + +## ⚠️ Unverified +Needs `npm run android:sync` + a real device. Android-only (iOS sandboxes NFC +writing). The Kotlin uses `NfcAdapter.enableReaderMode`; tune per the app's +Capacitor version if Gradle complains. diff --git a/packages/skatespots-nfc/android/build.gradle b/packages/skatespots-nfc/android/build.gradle new file mode 100644 index 0000000..d218c12 --- /dev/null +++ b/packages/skatespots-nfc/android/build.gradle @@ -0,0 +1,45 @@ +// SkateSpots NFC writer — a local Capacitor Android plugin. +// Mirrors the official Capacitor plugin template; tune versions to match the +// app's Capacitor major if a build complains. + +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:8.2.1' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.24" + } +} + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + namespace "org.skatespots.nfc" + compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 35 + + defaultConfig { + minSdk project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 23 + targetSdk project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 35 + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = '17' + } +} + +repositories { + google() + mavenCentral() +} + +dependencies { + implementation project(':capacitor-android') +} diff --git a/packages/skatespots-nfc/android/src/main/AndroidManifest.xml b/packages/skatespots-nfc/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c498b92 --- /dev/null +++ b/packages/skatespots-nfc/android/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/skatespots-nfc/android/src/main/java/org/skatespots/nfc/SkateSpotsNfcPlugin.kt b/packages/skatespots-nfc/android/src/main/java/org/skatespots/nfc/SkateSpotsNfcPlugin.kt new file mode 100644 index 0000000..6516e70 --- /dev/null +++ b/packages/skatespots-nfc/android/src/main/java/org/skatespots/nfc/SkateSpotsNfcPlugin.kt @@ -0,0 +1,136 @@ +package org.skatespots.nfc + +import android.net.Uri +import android.nfc.NdefMessage +import android.nfc.NdefRecord +import android.nfc.NfcAdapter +import android.nfc.Tag +import android.nfc.tech.Ndef +import com.getcapacitor.JSObject +import com.getcapacitor.Plugin +import com.getcapacitor.PluginCall +import com.getcapacitor.PluginMethod +import com.getcapacitor.annotation.CapacitorPlugin + +/** + * Writes the card/pass VERIFY URL to an NFC tag. Web NFC is not exposed inside + * Capacitor's WebView, so the APK needs this tiny native plugin. It writes only + * a URL record — the same pointer the QR carries; trust still comes from the + * relay signature. + * + * ⚠️ UNVERIFIED on a device (needs `cap sync` + a phone). Structure mirrors the + * skatespots-background plugin. + */ +@CapacitorPlugin(name = "SkateSpotsNfc") +class SkateSpotsNfcPlugin : Plugin() { + + private var pendingCall: PluginCall? = null + private var pendingUrl: String? = null + + @PluginMethod + fun isSupported(call: PluginCall) { + val result = JSObject() + result.put("supported", NfcAdapter.getDefaultAdapter(context) != null) + call.resolve(result) + } + + /** Enable reader mode and write `url` to the first NDEF tag tapped. */ + @PluginMethod + fun writeUrl(call: PluginCall) { + val url = call.getString("url") + if (url.isNullOrBlank()) { + call.reject("url is required") + return + } + val adapter = NfcAdapter.getDefaultAdapter(context) + if (adapter == null) { + call.reject("This device has no NFC") + return + } + val act = activity + if (act == null) { + call.reject("No foreground activity") + return + } + if (pendingCall != null) { + call.reject("Another write is already in progress") + return + } + + pendingCall = call + pendingUrl = url + + act.runOnUiThread { + val flags = NfcAdapter.FLAG_READER_NFC_A or + NfcAdapter.FLAG_READER_NFC_B or + NfcAdapter.FLAG_READER_NFC_F or + NfcAdapter.FLAG_READER_NFC_V or + NfcAdapter.FLAG_READER_NFC_BARCODE + adapter.enableReaderMode(act, { tag: Tag -> onTag(tag) }, flags, null) + } + } + + /** Stop waiting for a tag and reject the pending write. */ + @PluginMethod + fun cancel(call: PluginCall) { + val pending = pendingCall + pendingCall = null + pendingUrl = null + finishReader() + pending?.reject("Cancelled") + call.resolve() + } + + private fun onTag(tag: Tag) { + val url = pendingUrl ?: return + val call = pendingCall ?: return + val ndef = Ndef.get(tag) + if (ndef == null) { + rejectAndReset(call, "This tag doesn't support NDEF") + return + } + try { + ndef.connect() + if (!ndef.isWritable) { + rejectAndReset(call, "This tag is read-only") + return + } + val record = NdefRecord.createUri(Uri.parse(url)) + ndef.writeNdefMessage(NdefMessage(arrayOf(record))) + activity?.runOnUiThread { + finishReader() + pendingCall = null + pendingUrl = null + call.resolve() + } + } catch (e: Exception) { + rejectAndReset(call, e.message ?: "Write failed") + } finally { + try { ndef.close() } catch (_: Exception) { /* ignore */ } + } + } + + private fun rejectAndReset(call: PluginCall, message: String) { + activity?.runOnUiThread { + finishReader() + pendingCall = null + pendingUrl = null + call.reject(message) + } + } + + private fun finishReader() { + val act = activity ?: return + try { NfcAdapter.getDefaultAdapter(context)?.disableReaderMode(act) } catch (_: Exception) { /* ignore */ } + } + + override fun handleOnPause() { + super.handleOnPause() + val call = pendingCall + if (call != null) { + pendingCall = null + pendingUrl = null + call.reject("Write cancelled") + } + } +} diff --git a/packages/skatespots-nfc/package.json b/packages/skatespots-nfc/package.json new file mode 100644 index 0000000..1287397 --- /dev/null +++ b/packages/skatespots-nfc/package.json @@ -0,0 +1,18 @@ +{ + "name": "skatespots-nfc", + "version": "0.0.1", + "private": true, + "description": "SkateSpots native NFC tag writer (Web NFC isn't exposed in the Capacitor WebView). Writes only the verify URL.", + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "capacitor": { + "android": { + "src": "android" + } + }, + "files": ["src", "android"], + "dependencies": { + "@capacitor/core": "^8.4.2" + } +} diff --git a/packages/skatespots-nfc/src/index.ts b/packages/skatespots-nfc/src/index.ts new file mode 100644 index 0000000..e0d94f4 --- /dev/null +++ b/packages/skatespots-nfc/src/index.ts @@ -0,0 +1,22 @@ +/** + * SkateSpots NFC writer — TS↔Kotlin bridge. + * + * Web NFC isn't exposed inside Capacitor's WebView, so the APK writes tags via + * this local plugin. It writes ONLY the verify URL (the same pointer the QR + * carries). On the web, `src/lib/nfc.ts` falls back to Web NFC. + * + * The app registers this plugin BY NAME via `@capacitor/core`; it does not + * import this package into the web bundle. + */ +import { registerPlugin } from '@capacitor/core'; + +export interface SkateSpotsNfcPlugin { + /** Whether the device has an NFC adapter. */ + isSupported(): Promise<{ supported: boolean }>; + /** Enable reader mode and write the URL to the first NDEF tag tapped. */ + writeUrl(options: { url: string }): Promise; + /** Stop waiting for a tag and reject the pending write. */ + cancel(): Promise; +} + +export const SkateSpotsNfc = registerPlugin('SkateSpotsNfc'); diff --git a/src/lib/nfc.ts b/src/lib/nfc.ts index ebf3693..03868db 100644 --- a/src/lib/nfc.ts +++ b/src/lib/nfc.ts @@ -1,12 +1,24 @@ /** - * NFC tag writing via Web NFC (Chrome on Android over HTTPS). + * NFC tag writing. * - * We write ONLY the verify URL to the tag — the same pointer the QR carries. - * A tag is passive and can be cloned, exactly like copying a QR; the trust still - * comes from the signature on the relay. iOS can't write tags (reading works - * everywhere by tapping), and Capacitor's WebView doesn't expose Web NFC, so the - * button only appears where it works. + * Two paths, one purpose — write ONLY the verify URL (the same pointer the QR + * carries). A tag is passive and clonable, exactly like copying a QR; the trust + * still comes from the signature on the relay. + * + * • Native (Capacitor APK): the local `SkateSpotsNfc` Android plugin, registered + * by name — Web NFC isn't exposed in the WebView. + * • Web (Chrome on Android over HTTPS): Web NFC (`NDEFReader`). + * + * iOS can't write tags either way; reading works everywhere by tapping. */ +import { Capacitor, registerPlugin } from '@capacitor/core'; + +interface NativeNfc { + isSupported(): Promise<{ supported: boolean }>; + writeUrl(options: { url: string }): Promise; + cancel(): Promise; +} +const NativeNfc = registerPlugin('SkateSpotsNfc'); interface NdefWriter { write(message: { records: Array<{ recordType: string; data: string }> }): Promise; @@ -18,12 +30,18 @@ function ndefCtor(): NdefCtor | undefined { return (window as unknown as { NDEFReader?: NdefCtor }).NDEFReader; } +/** Whether we can attempt a write here. On native the plugin validates the adapter. */ export function isNfcSupported(): boolean { + if (Capacitor.isNativePlatform()) return true; return !!ndefCtor(); } /** Write a URL record to an NFC tag. Rejects if unsupported or the write fails. */ export async function writeNfcUrl(url: string): Promise { + if (Capacitor.isNativePlatform()) { + await NativeNfc.writeUrl({ url }); + return; + } const Ctor = ndefCtor(); if (!Ctor) throw new Error('NFC writing is not supported on this device/browser.'); const writer = new Ctor();