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",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
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.
|
||||
* 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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue