feat(nfc): native Android tag-writer plugin (APK) + Web NFC on web

Web NFC isn't exposed in Capacitor's WebView, so the APK needs a native writer.
Adds packages/skatespots-nfc — a local Capacitor Android plugin (Kotlin,
enableReaderMode + NDEF URL record), registered BY NAME (not bundled). lib/nfc.ts
now uses the plugin on native and Web NFC on the web. iOS can't write tags.
⚠️ UNVERIFIED (needs cap sync + a device). README documents it.
This commit is contained in:
SkateSpots 2026-10-02 14:14:31 +07:00
parent 2a165c7aad
commit 7d51fc60a5
9 changed files with 288 additions and 6 deletions

11
package-lock.json generated
View file

@ -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"
}
}
}
}

View file

@ -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",

View file

@ -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/<naddr>`.
## 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.

View file

@ -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')
}

View file

@ -0,0 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.NFC" />
<!-- Optional hardware: the app installs on non-NFC phones too. -->
<uses-feature android:name="android.hardware.nfc" android:required="false" />
</manifest>

View file

@ -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")
}
}
}

View file

@ -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"
}
}

View file

@ -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<void>;
/** Stop waiting for a tag and reject the pending write. */
cancel(): Promise<void>;
}
export const SkateSpotsNfc = registerPlugin<SkateSpotsNfcPlugin>('SkateSpotsNfc');

View file

@ -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<void>;
cancel(): Promise<void>;
}
const NativeNfc = registerPlugin<NativeNfc>('SkateSpotsNfc');
interface NdefWriter {
write(message: { records: Array<{ recordType: string; data: string }> }): Promise<void>;
@ -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<void> {
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();