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:
parent
2a165c7aad
commit
7d51fc60a5
9 changed files with 288 additions and 6 deletions
11
package-lock.json
generated
11
package-lock.json
generated
|
|
@ -100,6 +100,7 @@
|
||||||
"react-router-dom": "^6.26.2",
|
"react-router-dom": "^6.26.2",
|
||||||
"recharts": "^2.12.7",
|
"recharts": "^2.12.7",
|
||||||
"skatespots-background": "file:packages/skatespots-background",
|
"skatespots-background": "file:packages/skatespots-background",
|
||||||
|
"skatespots-nfc": "file:packages/skatespots-nfc",
|
||||||
"tailwind-merge": "^2.5.2",
|
"tailwind-merge": "^2.5.2",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"three": "^0.185.1",
|
"three": "^0.185.1",
|
||||||
|
|
@ -11300,6 +11301,10 @@
|
||||||
"resolved": "packages/skatespots-background",
|
"resolved": "packages/skatespots-background",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/skatespots-nfc": {
|
||||||
|
"resolved": "packages/skatespots-nfc",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/slice-ansi": {
|
"node_modules/slice-ansi": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz",
|
||||||
|
|
@ -13629,6 +13634,12 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor/core": "^8.4.2"
|
"@capacitor/core": "^8.4.2"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"packages/skatespots-nfc": {
|
||||||
|
"version": "0.0.1",
|
||||||
|
"dependencies": {
|
||||||
|
"@capacitor/core": "^8.4.2"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,7 @@
|
||||||
"react-router-dom": "^6.26.2",
|
"react-router-dom": "^6.26.2",
|
||||||
"recharts": "^2.12.7",
|
"recharts": "^2.12.7",
|
||||||
"skatespots-background": "file:packages/skatespots-background",
|
"skatespots-background": "file:packages/skatespots-background",
|
||||||
|
"skatespots-nfc": "file:packages/skatespots-nfc",
|
||||||
"tailwind-merge": "^2.5.2",
|
"tailwind-merge": "^2.5.2",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"three": "^0.185.1",
|
"three": "^0.185.1",
|
||||||
|
|
|
||||||
26
packages/skatespots-nfc/README.md
Normal file
26
packages/skatespots-nfc/README.md
Normal 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.
|
||||||
45
packages/skatespots-nfc/android/build.gradle
Normal file
45
packages/skatespots-nfc/android/build.gradle
Normal 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')
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
packages/skatespots-nfc/package.json
Normal file
18
packages/skatespots-nfc/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
22
packages/skatespots-nfc/src/index.ts
Normal file
22
packages/skatespots-nfc/src/index.ts
Normal 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');
|
||||||
|
|
@ -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.
|
* Two paths, one purpose — write ONLY the verify URL (the same pointer the QR
|
||||||
* A tag is passive and can be cloned, exactly like copying a QR; the trust still
|
* carries). A tag is passive and clonable, exactly like copying a QR; the trust
|
||||||
* comes from the signature on the relay. iOS can't write tags (reading works
|
* still comes from the signature on the relay.
|
||||||
* everywhere by tapping), and Capacitor's WebView doesn't expose Web NFC, so the
|
*
|
||||||
* button only appears where it works.
|
* • 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 {
|
interface NdefWriter {
|
||||||
write(message: { records: Array<{ recordType: string; data: string }> }): Promise<void>;
|
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;
|
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 {
|
export function isNfcSupported(): boolean {
|
||||||
|
if (Capacitor.isNativePlatform()) return true;
|
||||||
return !!ndefCtor();
|
return !!ndefCtor();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Write a URL record to an NFC tag. Rejects if unsupported or the write fails. */
|
/** Write a URL record to an NFC tag. Rejects if unsupported or the write fails. */
|
||||||
export async function writeNfcUrl(url: string): Promise<void> {
|
export async function writeNfcUrl(url: string): Promise<void> {
|
||||||
|
if (Capacitor.isNativePlatform()) {
|
||||||
|
await NativeNfc.writeUrl({ url });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const Ctor = ndefCtor();
|
const Ctor = ndefCtor();
|
||||||
if (!Ctor) throw new Error('NFC writing is not supported on this device/browser.');
|
if (!Ctor) throw new Error('NFC writing is not supported on this device/browser.');
|
||||||
const writer = new Ctor();
|
const writer = new Ctor();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue