Skip to content

Commit b201cca

Browse files
authored
Merge pull request #2 from visibait/feat/antenna-location
feat: Report NFC antenna location and Secure NFC status
2 parents ab3a5cb + 43a764d commit b201cca

19 files changed

Lines changed: 596 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,34 @@
11
# react-native-nfc-kit
22

3+
## 2.1.0
4+
5+
### Minor Changes
6+
7+
- d1a3d89: Report where the device's NFC antenna is, and whether Secure NFC is on.
8+
9+
`nfc.getAntennaInfo()` answers with the device's dimensions in millimetres and the
10+
position of each NFC antenna within them, which is what turns "hold the card here"
11+
into a hint drawn in the right place instead of in the middle of the screen. Android
12+
14 and later, through `NfcAdapter.getNfcAntennaInfo`. It resolves `null` — never
13+
rejects — on iOS, on the web, below Android 14, and on the many Android 14 devices
14+
whose manufacturer left the numbers empty, so a screen laying out a hint needs no
15+
try/catch and still has to keep its generic fallback.
16+
17+
`nfc.isSecureNfcEnabled()` reports Android's Secure NFC setting, which restricts NFC
18+
to an unlocked screen and is the usual explanation for a background or launch tag
19+
that silently does nothing on one device and works on another. Android 10 and later;
20+
`false` where the setting does not exist. It is a call rather than a capability
21+
because the user can change it while the app is running.
22+
23+
Both are reflected in `nfc.capabilities` as `antennaInfo` and `secureNfc`, and there
24+
is a new page in the docs covering the coordinate system, foldables, and why `null`
25+
is a normal answer.
26+
27+
**This bumps the native contract to version 6, so a development build has to be
28+
rebuilt.** Updating the JavaScript alone leaves the installed binary reporting
29+
version 5, and every call will fail with `contractMismatch` until
30+
`npx expo run:android`, `npx expo run:ios` or an EAS build has run again.
31+
332
## 2.0.0
433

534
### Major Changes

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,8 @@ runtime, and the types tell you before that.
127127
| Card emulation (HCE) | entitlement, EEA only |||
128128
| Observe mode, polling frames || API 35+ ||
129129
| Apple Wallet passes (VAS) | entitlement |||
130+
| Antenna location || API 34+ ||
131+
| Secure NFC status || API 29+ ||
130132

131133
<br>
132134

android/src/main/java/com/nfckit/NfcKitModule.kt

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import java.util.concurrent.ConcurrentHashMap
3030
import java.util.concurrent.atomic.AtomicInteger
3131

3232
/** Bumped together with `CONTRACT_VERSION` in `src/native/contract.ts`. */
33-
private const val CONTRACT_VERSION = 5
33+
private const val CONTRACT_VERSION = 6
3434

3535
private const val EVENT_TAG_DISCOVERED = "onTagDiscovered"
3636
private const val EVENT_BACKGROUND_TAG = "onBackgroundTag"
@@ -44,6 +44,12 @@ private const val EVENT_POLLING_FRAMES = "onPollingFrames"
4444
/** The API level that added observe mode and polling loop frames. */
4545
private const val OBSERVE_MODE_SDK = 35
4646

47+
/** The API level that added `NfcAdapter.getNfcAntennaInfo`. */
48+
private const val ANTENNA_INFO_SDK = 34
49+
50+
/** The API level that added the secure NFC setting. */
51+
private const val SECURE_NFC_SDK = 29
52+
4753
/** Mirrors `NativePollingLoopFilter`. */
4854
class PollingLoopFilterOptions : Record {
4955
@Field val pattern: String = ""
@@ -177,6 +183,13 @@ class NfcKitModule : Module() {
177183
// Apple Wallet passes are an Apple protocol read through CoreNFC's own VAS
178184
// session. There is no Android equivalent to expose.
179185
"vas" to false,
186+
// Asked of the device rather than inferred from the API level: the
187+
// manufacturer has to have filled the numbers in, and plenty of API 34
188+
// devices answer null.
189+
"antennaInfo" to (antennaInfoPayload() != null),
190+
// Hardware-dependent as well as version-dependent, so the adapter is the
191+
// one to ask.
192+
"secureNfc" to isSecureNfcSupported(),
180193
)
181194
}
182195

@@ -202,6 +215,12 @@ class NfcKitModule : Module() {
202215
activity.startActivity(Intent(Settings.ACTION_NFC_SETTINGS))
203216
}
204217

218+
AsyncFunction("getAntennaInfo") { antennaInfoPayload() }
219+
220+
AsyncFunction("isSecureNfcEnabled") {
221+
Build.VERSION.SDK_INT >= SECURE_NFC_SDK && nfcAdapter()?.isSecureNfcEnabled == true
222+
}
223+
205224
/* -- Session lifecycle ----------------------------------------------- */
206225

207226
AsyncFunction("startSession") { sessionId: String, options: SessionOptions ->
@@ -550,6 +569,38 @@ class NfcKitModule : Module() {
550569
private fun isObserveModeSupported(): Boolean =
551570
Build.VERSION.SDK_INT >= OBSERVE_MODE_SDK && nfcAdapter()?.isObserveModeSupported == true
552571

572+
private fun isSecureNfcSupported(): Boolean =
573+
Build.VERSION.SDK_INT >= SECURE_NFC_SDK && nfcAdapter()?.isSecureNfcSupported == true
574+
575+
/**
576+
* This device's antenna layout, in the shape `NativeNfcAntennaInfo` describes,
577+
* or null.
578+
*
579+
* Null covers three situations deliberately: below API 34 the call does not
580+
* exist, there may be no adapter at all, and a manufacturer on API 34 may
581+
* simply not have filled the numbers in. None of them is an error, and a caller
582+
* that handles the third one has already handled the other two.
583+
*
584+
* The platform type never appears in a signature here, only inside the guarded
585+
* branch, so nothing in this class references an API 34 class on a device that
586+
* does not have one.
587+
*/
588+
private fun antennaInfoPayload(): Map<String, Any?>? {
589+
if (Build.VERSION.SDK_INT < ANTENNA_INFO_SDK) {
590+
return null
591+
}
592+
val info = nfcAdapter()?.nfcAntennaInfo ?: return null
593+
594+
return mapOf(
595+
"deviceWidth" to info.deviceWidth,
596+
"deviceHeight" to info.deviceHeight,
597+
"deviceFoldable" to info.isDeviceFoldable,
598+
"antennas" to info.availableNfcAntennas.map { antenna ->
599+
mapOf("locationX" to antenna.locationX, "locationY" to antenna.locationY)
600+
},
601+
)
602+
}
603+
553604
/**
554605
* Asks the platform to route taps to this app's service while it is in front.
555606
*

docs/antenna-location.mdx

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
---
2+
title: 'Where the antenna is, and what the settings allow'
3+
sidebarTitle: 'Antenna and secure NFC'
4+
description: 'Two things only the device can answer: where its NFC antenna physically sits, and whether NFC is restricted to an unlocked screen.'
5+
---
6+
7+
Both of these describe the hardware in the user's hand rather than anything your app
8+
does with it. They exist because the two most common complaints about an NFC screen —
9+
"it never reads" and "nothing happens on the lock screen" — usually have nothing to do
10+
with the tag.
11+
12+
## Where to tell the user to tap
13+
14+
Android 14 added an API that reports where the NFC antenna physically sits on the
15+
device. It is the difference between a card illustration in the middle of the screen
16+
and one over the antenna.
17+
18+
```ts
19+
import { nfc } from 'react-native-nfc-kit';
20+
21+
const info = await nfc.getAntennaInfo();
22+
23+
if (info !== null) {
24+
const [antenna] = info.antennas;
25+
// Millimetres from the bottom-left corner of the device.
26+
console.log(antenna.locationX, antenna.locationY);
27+
}
28+
```
29+
30+
<Info>
31+
The coordinates are millimetres from the **bottom-left corner of the device** — the corner of the
32+
hardware, bezels included. They are not screen coordinates and not pixels, so they mean nothing on
33+
their own. That is why `deviceWidth` and `deviceHeight` come back in the same object: it is the
34+
ratio of the two that becomes a position you can lay out against.
35+
</Info>
36+
37+
```ts
38+
import { nfc } from 'react-native-nfc-kit';
39+
40+
const info = await nfc.getAntennaInfo();
41+
const antenna = info?.antennas[0];
42+
43+
const hint =
44+
info === null || antenna === undefined
45+
? null
46+
: {
47+
left: `${(antenna.locationX / info.deviceWidth) * 100}%`,
48+
// Measured up from the bottom, which is the opposite of a CSS `top`.
49+
bottom: `${(antenna.locationY / info.deviceHeight) * 100}%`,
50+
};
51+
```
52+
53+
A phone can report more than one antenna, and `antennas` is ordered as the platform
54+
gives it, with no promise about which is the "main" one. Most devices report exactly
55+
one.
56+
57+
### When it is null
58+
59+
`null` is a normal answer, not a failure, and it covers four different situations on
60+
purpose:
61+
62+
| Situation | Why |
63+
| -------------------------------------- | ----------------------------------------------------------- |
64+
| iOS | CoreNFC never reports antenna geometry, at any iOS version. |
65+
| Web | A page is handed records, never anything about the radio. |
66+
| Android 13 and earlier | The API arrived in Android 14 (API 34). |
67+
| Android 14 where the OEM left it empty | Common. The numbers are the manufacturer's to fill in. |
68+
69+
The last row is the one worth designing around: it means the `null` branch is not "the
70+
iOS branch" by another name, and a device on the right Android version can still
71+
decline to answer. Keep the generic illustration as the fallback.
72+
73+
Because of that, `getAntennaInfo()` resolves `null` rather than rejecting — a screen
74+
laying out a hint needs no `try`/`catch`. If you want the answer without the round
75+
trip, `nfc.capabilities?.antennaInfo` is the same fact as a boolean.
76+
77+
<Note>
78+
On a foldable, `deviceFoldable` is `true` and every number describes the device **unfolded**. A
79+
folded phone needs its own arithmetic before any of this reaches a layout, and the platform gives
80+
you nothing to do it with — so on a foldable, showing the generic hint is usually the better
81+
answer.
82+
</Note>
83+
84+
## Secure NFC
85+
86+
Android 10 added a setting called **Secure NFC**: when it is on, the device reads tags
87+
only while the screen is unlocked. It is off by default on most devices and on by
88+
default on a few, and a user can turn it on without connecting it to anything.
89+
90+
```ts
91+
import { nfc } from 'react-native-nfc-kit';
92+
93+
if (await nfc.isSecureNfcEnabled()) {
94+
// A tap on the lock screen will do nothing at all. Say so, rather than
95+
// letting the scan sit there looking broken.
96+
}
97+
```
98+
99+
This is a call rather than a capability because the user can change it from the
100+
settings while your app is running. Whether the device has the setting at all is
101+
`nfc.capabilities?.secureNfc`, which is `false` below Android 10, on iOS and on the
102+
web — and `isSecureNfcEnabled()` is `false` there too.
103+
104+
It matters most for [background and launch tags](/setup/background-reading): a tap
105+
against a locked phone is exactly what that feature is for, and Secure NFC is the
106+
reason it silently does nothing on some devices. A reading session started from a
107+
screen the user is looking at is unaffected, because the screen is unlocked by
108+
definition.
109+
110+
## What each platform reports
111+
112+
| | iOS | Android | Web (Chrome) |
113+
| ---------------------- | :-----: | :-----------------------------------------: | :----------: |
114+
| `getAntennaInfo()` | `null` | API 34+, and only when the OEM filled it in | `null` |
115+
| `isSecureNfcEnabled()` | `false` | API 29+ | `false` |
116+
117+
Neither call needs a permission, an entitlement, or anything in the manifest. Both are
118+
safe to call on every platform on startup.

docs/device-matrix.mdx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,22 @@ Procedure:
139139
A soak result is worth reporting whether it holds or not — it is the one piece of
140140
evidence CI cannot produce.
141141

142+
## Device information, Android only
143+
144+
The two rows that need no tag at all. They describe the device, so a wrong answer is
145+
visible the moment you look at the phone.
146+
147+
| # | Claim | Setup | Passed when | Status |
148+
| --- | ------------------------------------------------- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ------ |
149+
| 46 | The antenna location is reported, and plausible | API 34+ device | `nfc.getAntennaInfo()` gives dimensions within a few mm of the real phone, and a location that falls inside them ||
150+
| 47 | Secure NFC is reported as the system setting says | API 29+ device, setting toggled both ways | `nfc.isSecureNfcEnabled()` follows the setting without restarting the app ||
151+
152+
<Note>
153+
A `null` from row 46 on an API 34 device is a **pass**, not a failure: filling those numbers in is
154+
the manufacturer's choice and many have not. What would be a failure is a throw, or numbers that
155+
do not match the hardware in your hand. Worth reporting either way, with the exact model.
156+
</Note>
157+
142158
## The rows most worth covering
143159

144160
Not every row is equally informative. If you are only going to run some:
@@ -153,6 +169,9 @@ Not every row is equally informative. If you are only going to run some:
153169
inference it has not been able to check. If it turns out to be wrong, the fix is a
154170
one-line default and the docs already say it is provisional.
155171
- **Row 45 on both platforms.**
172+
- **Row 46 on any Android 14 phone.** It costs one call and no tag, and how many
173+
devices actually fill the numbers in is not something the documentation anywhere
174+
answers.
156175

157176
Rows 39 and 40 need an entitlement Apple grants case by case, and rows 30 to 33 need a
158177
terminal, so those stay thin until somebody who has one reports back. They are

docs/docs.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@
6464
{
6565
"group": "Reference",
6666
"icon": "list",
67-
"pages": ["errors", "device-matrix"]
67+
"pages": ["errors", "device-matrix", "antenna-location"]
6868
},
6969
{
7070
"group": "Coming from elsewhere",

docs/index.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@ runtime, and the types tell you before that.
9595
| Card emulation (HCE) | entitlement, EEA only |||
9696
| Observe mode, polling frames || API 35+ ||
9797
| Apple Wallet passes (VAS) | entitlement |||
98+
| Antenna location || API 34+ ||
99+
| Secure NFC status || API 29+ ||
98100

99101
<Warning>
100102
**NFC needs a development build.** It is native code, so Expo Go cannot load it. `npx expo

example/App.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
isTextRecord,
1616
nfc,
1717
toHex,
18+
type NfcAntennaInfo,
1819
type NfcAvailability,
1920
type Tag,
2021
} from 'react-native-nfc-kit';
@@ -31,6 +32,8 @@ export default function App() {
3132
const [status, setStatus] = useState('Idle');
3233
const [result, setResult] = useState<ScanResult | null>(null);
3334
const [busy, setBusy] = useState(false);
35+
const [antenna, setAntenna] = useState<NfcAntennaInfo | null>(null);
36+
const [secureNfc, setSecureNfc] = useState<boolean | null>(null);
3437

3538
useEffect(() => {
3639
void nfc.getAvailability().then(setAvailability);
@@ -39,6 +42,13 @@ export default function App() {
3942
return () => subscription.remove();
4043
}, []);
4144

45+
useEffect(() => {
46+
// Hardware facts, so they are read once. Both answer on every platform
47+
// rather than throwing, which is why neither needs a catch.
48+
void nfc.getAntennaInfo().then(setAntenna);
49+
void nfc.isSecureNfcEnabled().then(setSecureNfc);
50+
}, []);
51+
4252
/** Reads whatever is on the tag, and reports it. */
4353
const read = useCallback(async () => {
4454
setBusy(true);
@@ -141,6 +151,8 @@ export default function App() {
141151
label="Observe mode"
142152
value={String(availability?.capabilities?.observeMode ?? '…')}
143153
/>
154+
<Row label="Antenna" value={describeAntenna(antenna)} />
155+
<Row label="Secure NFC" value={secureNfc === null ? '…' : String(secureNfc)} />
144156
</Section>
145157

146158
<View style={styles.buttons}>
@@ -180,6 +192,25 @@ function describe(error: unknown): string {
180192
return error instanceof Error ? error.message : String(error);
181193
}
182194

195+
/**
196+
* The antenna layout as one line.
197+
*
198+
* Null is the common answer even on Android 14 -- the numbers are the
199+
* manufacturer's to fill in -- so it says which of the two it is rather than
200+
* leaving a blank that reads like a bug.
201+
*/
202+
function describeAntenna(info: NfcAntennaInfo | null): string {
203+
if (info === null) {
204+
return 'not reported by this device';
205+
}
206+
207+
const positions = info.antennas
208+
.map((antenna) => `${antenna.locationX}, ${antenna.locationY}`)
209+
.join(' · ');
210+
211+
return `${positions || 'none listed'} mm of ${info.deviceWidth}×${info.deviceHeight} mm`;
212+
}
213+
183214
function Section({ title, children }: { title: string; children: React.ReactNode }) {
184215
return (
185216
<View style={styles.section}>

0 commit comments

Comments
 (0)