@utapza/expo-mifare-scanner 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,177 @@
1
+ # @utapza/expo-mifare-scanner
2
+
3
+ Expo module for scanning MIFARE Classic NFC cards on Android.
4
+
5
+ ## Features
6
+
7
+ - ✅ Scan MIFARE Classic cards
8
+ - ✅ Read NDEF records
9
+ - ✅ Read raw data from multiple sectors
10
+ - ✅ Automatic JSON extraction
11
+ - ✅ Simple async API
12
+ - ✅ Automatic start/stop handling
13
+ - ✅ Timeout support
14
+ - ✅ Comprehensive error handling
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ npm install @utapza/expo-mifare-scanner
20
+ ```
21
+
22
+ **Note**: This module requires a development build. It does not work in Expo Go.
23
+
24
+ ## Usage
25
+
26
+ ### Simple API (Recommended)
27
+
28
+ ```javascript
29
+ import { readNfcTag } from '@utapza/expo-mifare-scanner';
30
+
31
+ try {
32
+ // Start scanning, wait for tag, automatically stop
33
+ const cardData = await readNfcTag({ timeout: 10000 });
34
+
35
+ console.log('Card UID:', cardData.uid);
36
+ console.log('Card Data:', cardData.data); // JSON string
37
+ console.log('Raw Data:', cardData.rawData); // Hex string
38
+ console.log('Timestamp:', cardData.timestamp);
39
+ } catch (error) {
40
+ console.error('Scan failed:', error.message);
41
+ // Possible errors:
42
+ // - "NFC is not enabled on this device"
43
+ // - "NFC scan timed out after 10000ms"
44
+ // - "Failed to start NFC scanning"
45
+ }
46
+ ```
47
+
48
+ ### Advanced API (Event-based)
49
+
50
+ If you need more control, you can use the event-based API:
51
+
52
+ ```javascript
53
+ import {
54
+ startScanning,
55
+ stopScanning,
56
+ addCardScannedListener,
57
+ removeCardScannedListener,
58
+ isNfcEnabled
59
+ } from '@utapza/expo-mifare-scanner';
60
+
61
+ // Check if NFC is enabled
62
+ const enabled = await isNfcEnabled();
63
+
64
+ if (enabled) {
65
+ // Set up listener
66
+ const subscription = addCardScannedListener((event) => {
67
+ console.log('Card scanned:', event);
68
+ // Stop scanning when done
69
+ stopScanning();
70
+ });
71
+
72
+ // Start scanning
73
+ await startScanning();
74
+
75
+ // Later, clean up
76
+ removeCardScannedListener(subscription);
77
+ await stopScanning();
78
+ }
79
+ ```
80
+
81
+ ## API Reference
82
+
83
+ ### `readNfcTag(options?)`
84
+
85
+ Simplified async function that handles the entire scan lifecycle.
86
+
87
+ **Parameters:**
88
+ - `options.timeout` (number, optional): Timeout in milliseconds. Default: 30000 (30 seconds)
89
+
90
+ **Returns:** `Promise<CardData>`
91
+
92
+ **CardData:**
93
+ ```typescript
94
+ {
95
+ uid: string; // Card UID (hex string)
96
+ data: string; // Card data (JSON string or text)
97
+ rawData: string; // Raw hex data
98
+ timestamp: number; // Unix timestamp
99
+ }
100
+ ```
101
+
102
+ **Throws:**
103
+ - `Error` if NFC is not enabled
104
+ - `Error` if scan times out
105
+ - `Error` if scanning fails to start
106
+ - `Error` if module is not available (Expo Go)
107
+
108
+ ### `isNfcEnabled()`
109
+
110
+ Check if NFC is enabled on the device.
111
+
112
+ **Returns:** `Promise<boolean>`
113
+
114
+ ### `startScanning()`
115
+
116
+ Start NFC scanning (for advanced use cases).
117
+
118
+ **Throws:** `Error` if module is not available
119
+
120
+ ### `stopScanning()`
121
+
122
+ Stop NFC scanning (for advanced use cases).
123
+
124
+ ### `addCardScannedListener(listener)`
125
+
126
+ Add event listener for card scans (for advanced use cases).
127
+
128
+ **Parameters:**
129
+ - `listener` (function): Callback that receives `{ uid, data, rawData, timestamp }`
130
+
131
+ **Returns:** Subscription object with `remove()` method
132
+
133
+ ### `removeCardScannedListener(subscription)`
134
+
135
+ Remove event listener (for advanced use cases).
136
+
137
+ ## Requirements
138
+
139
+ - Android device with NFC support
140
+ - Development build (does not work in Expo Go)
141
+ - NFC enabled on device
142
+ - Expo SDK 54+
143
+
144
+ ## Development Build
145
+
146
+ Since this uses custom native code, you need a development build:
147
+
148
+ ```bash
149
+ # Install dependencies
150
+ npm install
151
+
152
+ # Generate native code
153
+ npx expo prebuild --platform android
154
+
155
+ # Build and run
156
+ npx expo run:android
157
+ ```
158
+
159
+ ## How It Works
160
+
161
+ 1. **NDEF Reading**: First attempts to read NDEF records (standard NFC format)
162
+ 2. **MIFARE Reading**: If NDEF fails, reads multiple sectors from MIFARE Classic card
163
+ 3. **Data Extraction**: Automatically extracts JSON from the data
164
+ 4. **Auto-cleanup**: Automatically stops scanning after tag is discovered
165
+
166
+ ## Error Handling
167
+
168
+ The module throws descriptive errors:
169
+
170
+ - `"NFC is not enabled on this device"` - User needs to enable NFC
171
+ - `"NFC scan timed out after Xms"` - No tag detected within timeout
172
+ - `"Failed to start NFC scanning"` - System error starting scan
173
+ - `"ExpoMifareScanner requires a development build"` - Running in Expo Go
174
+
175
+ ## License
176
+
177
+ MIT
@@ -0,0 +1,41 @@
1
+ apply plugin: 'com.android.library'
2
+ apply plugin: 'org.jetbrains.kotlin.android'
3
+
4
+ group = 'expo.modules'
5
+ version = '1.0.0'
6
+
7
+ buildscript {
8
+ def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir, "ExpoModulesCorePlugin.gradle")
9
+ if (expoModulesCorePlugin.exists()) {
10
+ apply from: expoModulesCorePlugin
11
+ applyKotlinExpoModulesCorePlugin()
12
+ }
13
+ }
14
+
15
+ android {
16
+ namespace "expo.modules.mifarescanner"
17
+ compileSdkVersion rootProject.ext.compileSdkVersion
18
+
19
+ defaultConfig {
20
+ minSdkVersion rootProject.ext.minSdkVersion
21
+ targetSdkVersion rootProject.ext.targetSdkVersion
22
+ }
23
+
24
+ lintOptions {
25
+ abortOnError false
26
+ }
27
+
28
+ compileOptions {
29
+ sourceCompatibility JavaVersion.VERSION_17
30
+ targetCompatibility JavaVersion.VERSION_17
31
+ }
32
+
33
+ kotlinOptions {
34
+ jvmTarget = JavaVersion.VERSION_17.majorVersion
35
+ }
36
+ }
37
+
38
+ dependencies {
39
+ implementation project(':expo-modules-core')
40
+ implementation 'com.facebook.react:react-native'
41
+ }
@@ -0,0 +1,404 @@
1
+ package expo.modules.mifarescanner;
2
+
3
+ import android.app.Activity;
4
+ import android.nfc.NfcAdapter;
5
+ import android.nfc.NdefMessage;
6
+ import android.nfc.NdefRecord;
7
+ import android.nfc.Tag;
8
+ import android.nfc.tech.MifareClassic;
9
+ import android.nfc.tech.Ndef;
10
+ import android.util.Log;
11
+ import java.io.IOException;
12
+ import java.nio.charset.StandardCharsets;
13
+ import java.util.ArrayList;
14
+ import java.util.List;
15
+
16
+ public class MifareScanner {
17
+ private static final String TAG = "MifareScanner";
18
+ private NfcAdapter nfcAdapter;
19
+ private Activity currentActivity;
20
+ private boolean isScanning = false;
21
+ private NfcAdapter.ReaderCallback readerCallback;
22
+ private OnCardScannedListener listener;
23
+
24
+ public interface OnCardScannedListener {
25
+ void onCardScanned(String uid, String data, String rawData, long timestamp);
26
+ }
27
+
28
+ public MifareScanner(NfcAdapter adapter, Activity activity) {
29
+ this.nfcAdapter = adapter;
30
+ this.currentActivity = activity;
31
+ Log.d(TAG, "MifareScanner initialized");
32
+ }
33
+
34
+ public void setOnCardScannedListener(OnCardScannedListener listener) {
35
+ this.listener = listener;
36
+ Log.d(TAG, "Card scanned listener set");
37
+ }
38
+
39
+ public boolean isNfcEnabled() {
40
+ boolean enabled = nfcAdapter != null && nfcAdapter.isEnabled();
41
+ Log.d(TAG, "NFC enabled check: " + enabled);
42
+ return enabled;
43
+ }
44
+
45
+ public void startScanning() throws Exception {
46
+ if (isScanning) {
47
+ String errorMsg = "NFC scanning is already in progress";
48
+ Log.e(TAG, errorMsg);
49
+ throw new IllegalStateException(errorMsg);
50
+ }
51
+
52
+ if (nfcAdapter == null) {
53
+ String errorMsg = "NFC adapter is not available on this device";
54
+ Log.e(TAG, errorMsg);
55
+ throw new IllegalStateException(errorMsg);
56
+ }
57
+
58
+ if (currentActivity == null) {
59
+ String errorMsg = "Activity is not available. Make sure the app is in the foreground.";
60
+ Log.e(TAG, errorMsg);
61
+ throw new IllegalStateException(errorMsg);
62
+ }
63
+
64
+ if (!nfcAdapter.isEnabled()) {
65
+ String errorMsg = "NFC is not enabled on this device. Please enable NFC in settings.";
66
+ Log.e(TAG, errorMsg);
67
+ throw new IllegalStateException(errorMsg);
68
+ }
69
+
70
+ isScanning = true;
71
+ Log.d(TAG, "Starting MIFARE scanning - Discovery");
72
+
73
+ readerCallback = new NfcAdapter.ReaderCallback() {
74
+ @Override
75
+ public void onTagDiscovered(Tag tag) {
76
+ Log.d(TAG, "Tag discovered - Discovery");
77
+ handleTag(tag);
78
+ }
79
+ };
80
+
81
+ int flags = NfcAdapter.FLAG_READER_NFC_A |
82
+ NfcAdapter.FLAG_READER_NFC_B;
83
+
84
+ try {
85
+ nfcAdapter.enableReaderMode(currentActivity, readerCallback, flags, null);
86
+ Log.d(TAG, "Reader mode enabled successfully - Discovery");
87
+ } catch (Exception e) {
88
+ isScanning = false;
89
+ String errorMsg = "Failed to enable NFC reader mode: " + e.getMessage();
90
+ Log.e(TAG, errorMsg, e);
91
+ throw new RuntimeException(errorMsg, e);
92
+ }
93
+ }
94
+
95
+ public void stopScanning() {
96
+ if (!isScanning) {
97
+ Log.d(TAG, "Not scanning, ignoring stop request");
98
+ return;
99
+ }
100
+
101
+ if (nfcAdapter != null && currentActivity != null) {
102
+ try {
103
+ nfcAdapter.disableReaderMode(currentActivity);
104
+ Log.d(TAG, "Reader mode disabled");
105
+ } catch (Exception e) {
106
+ Log.e(TAG, "Error disabling reader mode: " + e.getMessage(), e);
107
+ }
108
+ }
109
+
110
+ isScanning = false;
111
+ readerCallback = null;
112
+ }
113
+
114
+ private void handleTag(Tag tag) {
115
+ // Get UID from tag ID (this is the actual UID, not block 0)
116
+ String uid = bytesToHex(tag.getId());
117
+ Log.d(TAG, "Processing tag with UID: " + uid + " - Discovery");
118
+
119
+ String data = "";
120
+ String rawData = "";
121
+
122
+ // First, try to read NDEF records (if card supports NDEF)
123
+ Ndef ndef = Ndef.get(tag);
124
+ if (ndef != null) {
125
+ try {
126
+ ndef.connect();
127
+ Log.d(TAG, "NDEF detected, reading NDEF records - Reading");
128
+
129
+ NdefMessage ndefMessage = ndef.getNdefMessage();
130
+ if (ndefMessage != null) {
131
+ NdefRecord[] records = ndefMessage.getRecords();
132
+ Log.d(TAG, "Found " + records.length + " NDEF records - Reading");
133
+
134
+ List<String> textRecords = new ArrayList<>();
135
+ StringBuilder allData = new StringBuilder();
136
+
137
+ for (NdefRecord record : records) {
138
+ if (record.getTnf() == NdefRecord.TNF_WELL_KNOWN) {
139
+ byte[] type = record.getType();
140
+ if (java.util.Arrays.equals(type, NdefRecord.RTD_TEXT)) {
141
+ String text = parseTextRecord(record);
142
+ if (text != null && !text.isEmpty()) {
143
+ textRecords.add(text);
144
+ allData.append(text);
145
+ Log.d(TAG, "Found text record: " + text.substring(0, Math.min(50, text.length())) + "... - Reading");
146
+ }
147
+ }
148
+ }
149
+
150
+ // Also try to read raw payload as string (might contain JSON)
151
+ byte[] payload = record.getPayload();
152
+ if (payload != null && payload.length > 0) {
153
+ try {
154
+ String payloadStr = new String(payload, StandardCharsets.UTF_8);
155
+ if (payloadStr.trim().length() > 0) {
156
+ allData.append(payloadStr);
157
+ Log.d(TAG, "Found payload data: " + payloadStr.substring(0, Math.min(50, payloadStr.length())) + "... - Reading");
158
+ }
159
+ } catch (Exception e) {
160
+ // Ignore encoding errors
161
+ }
162
+ }
163
+ }
164
+
165
+ if (allData.length() > 0) {
166
+ data = allData.toString();
167
+ rawData = bytesToHex(ndefMessage.toByteArray());
168
+ Log.d(TAG, "NDEF data extracted: " + data.substring(0, Math.min(100, data.length())) + "... - Success");
169
+ }
170
+
171
+ ndef.close();
172
+ } else {
173
+ Log.d(TAG, "No NDEF message found - Reading");
174
+ ndef.close();
175
+ }
176
+ } catch (Exception e) {
177
+ Log.e(TAG, "Error reading NDEF: " + e.getMessage(), e);
178
+ try {
179
+ if (ndef.isConnected()) {
180
+ ndef.close();
181
+ }
182
+ } catch (IOException closeException) {
183
+ Log.e(TAG, "Error closing NDEF: " + closeException.getMessage());
184
+ }
185
+ }
186
+ }
187
+
188
+ // If NDEF didn't work or returned empty, try MIFARE Classic direct reading
189
+ if (data.isEmpty()) {
190
+ MifareClassic mifare = null;
191
+ try {
192
+ mifare = MifareClassic.get(tag);
193
+ if (mifare != null) {
194
+ Log.d(TAG, "MIFARE Classic detected, type: " + mifare.getType() + " - Discovery");
195
+
196
+ try {
197
+ mifare.connect();
198
+ Log.d(TAG, "Connected to MIFARE card - Discovery");
199
+
200
+ int sectorCount = mifare.getSectorCount();
201
+ Log.d(TAG, "Card has " + sectorCount + " sectors - Reading");
202
+
203
+ StringBuilder allBlocksData = new StringBuilder();
204
+ List<Byte> allBytes = new ArrayList<>();
205
+
206
+ // Try to read multiple sectors
207
+ for (int sectorIndex = 0; sectorIndex < Math.min(sectorCount, 16); sectorIndex++) {
208
+ try {
209
+ Log.d(TAG, "Attempting authentication on sector " + sectorIndex + " - Authentication");
210
+
211
+ // Try default key first
212
+ boolean authenticated = mifare.authenticateSectorWithKeyA(
213
+ sectorIndex,
214
+ MifareClassic.KEY_DEFAULT
215
+ );
216
+
217
+ if (!authenticated) {
218
+ // Try key B
219
+ authenticated = mifare.authenticateSectorWithKeyB(
220
+ sectorIndex,
221
+ MifareClassic.KEY_DEFAULT
222
+ );
223
+ }
224
+
225
+ if (!authenticated) {
226
+ Log.d(TAG, "Failed to authenticate sector " + sectorIndex + " - Authentication failed");
227
+ continue;
228
+ }
229
+
230
+ Log.d(TAG, "Successfully authenticated sector " + sectorIndex + " - Authentication");
231
+
232
+ int firstBlock = mifare.sectorToBlock(sectorIndex);
233
+ int blockCount = mifare.getBlockCountInSector(sectorIndex);
234
+ int lastBlock = firstBlock + blockCount - 1; // Sector trailer
235
+
236
+ Log.d(TAG, "Sector " + sectorIndex + ": blocks " + firstBlock + " to " + lastBlock + " (trailer at " + lastBlock + ") - Reading");
237
+
238
+ // Read all blocks in this sector (except the last one which is the sector trailer)
239
+ for (int blockOffset = 0; blockOffset < blockCount - 1; blockOffset++) {
240
+ int blockNumber = firstBlock + blockOffset;
241
+ try {
242
+ byte[] blockData = mifare.readBlock(blockNumber);
243
+ String blockHex = bytesToHex(blockData);
244
+
245
+ Log.d(TAG, "Block " + blockNumber + " hex: " + blockHex.substring(0, Math.min(32, blockHex.length())) + "... - Reading");
246
+
247
+ // Check if block is all zeros or all same byte (likely empty)
248
+ boolean isEmpty = true;
249
+ byte firstByte = blockData[0];
250
+ for (byte b : blockData) {
251
+ if (b != 0 && b != firstByte) {
252
+ isEmpty = false;
253
+ break;
254
+ }
255
+ }
256
+
257
+ if (isEmpty) {
258
+ Log.d(TAG, "Block " + blockNumber + " appears empty, skipping - Reading");
259
+ continue;
260
+ }
261
+
262
+ // Collect bytes
263
+ for (byte b : blockData) {
264
+ allBytes.add(b);
265
+ }
266
+
267
+ // Try to read as string, but be more careful
268
+ String blockStr = new String(blockData, StandardCharsets.UTF_8);
269
+ // Remove null characters and control characters
270
+ blockStr = blockStr.replaceAll("[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F]", "");
271
+
272
+ // Check if it looks like readable text (has printable ASCII or JSON-like characters)
273
+ boolean hasReadableText = false;
274
+ int printableCount = 0;
275
+ for (char c : blockStr.toCharArray()) {
276
+ if (c >= 32 && c < 127) { // Printable ASCII
277
+ printableCount++;
278
+ }
279
+ }
280
+
281
+ // If at least 50% is printable, consider it readable
282
+ if (blockStr.length() > 0 && printableCount > blockStr.length() / 2) {
283
+ hasReadableText = true;
284
+ }
285
+
286
+ if (hasReadableText) {
287
+ allBlocksData.append(blockStr);
288
+ Log.d(TAG, "Block " + blockNumber + " readable text: " + blockStr.substring(0, Math.min(50, blockStr.length())) + "... - Reading");
289
+ } else {
290
+ Log.d(TAG, "Block " + blockNumber + " doesn't contain readable text (printable: " + printableCount + "/" + blockStr.length() + ") - Reading");
291
+ }
292
+
293
+ } catch (IOException e) {
294
+ Log.e(TAG, "Error reading block " + blockNumber + ": " + e.getMessage());
295
+ }
296
+ }
297
+
298
+ } catch (Exception e) {
299
+ Log.d(TAG, "Error processing sector " + sectorIndex + ": " + e.getMessage());
300
+ }
301
+ }
302
+
303
+ mifare.close();
304
+ Log.d(TAG, "MIFARE connection closed");
305
+
306
+ // Convert collected bytes to hex
307
+ byte[] allBytesArray = new byte[allBytes.size()];
308
+ for (int i = 0; i < allBytes.size(); i++) {
309
+ allBytesArray[i] = allBytes.get(i);
310
+ }
311
+ rawData = bytesToHex(allBytesArray);
312
+
313
+ // Use the string data if we found any
314
+ if (allBlocksData.length() > 0) {
315
+ data = allBlocksData.toString().trim();
316
+ // Try to find JSON in the data
317
+ int jsonStart = data.indexOf("{");
318
+ int jsonEnd = data.lastIndexOf("}");
319
+ if (jsonStart >= 0 && jsonEnd > jsonStart) {
320
+ data = data.substring(jsonStart, jsonEnd + 1);
321
+ Log.d(TAG, "Found JSON in data: " + data.substring(0, Math.min(100, data.length())) + "... - Success");
322
+ }
323
+ } else if (allBytesArray.length > 0) {
324
+ // Try to parse as JSON or text
325
+ String fullData = new String(allBytesArray, StandardCharsets.UTF_8);
326
+ fullData = fullData.replaceAll("[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F]", "");
327
+
328
+ // Look for JSON
329
+ int jsonStart = fullData.indexOf("{");
330
+ int jsonEnd = fullData.lastIndexOf("}");
331
+ if (jsonStart >= 0 && jsonEnd > jsonStart) {
332
+ data = fullData.substring(jsonStart, jsonEnd + 1);
333
+ Log.d(TAG, "Found JSON in raw bytes: " + data.substring(0, Math.min(100, data.length())) + "... - Success");
334
+ } else {
335
+ // Just use the cleaned string
336
+ data = fullData.trim();
337
+ }
338
+ }
339
+
340
+ Log.d(TAG, "MIFARE data extracted, length: " + data.length() + ", rawData length: " + rawData.length() + " - Success");
341
+
342
+ } catch (IOException e) {
343
+ Log.e(TAG, "Error reading MIFARE card: " + e.getMessage(), e);
344
+ Log.d(TAG, "Tag handling failed - Failure");
345
+ try {
346
+ if (mifare != null && mifare.isConnected()) {
347
+ mifare.close();
348
+ }
349
+ } catch (IOException closeException) {
350
+ Log.e(TAG, "Error closing MIFARE connection: " + closeException.getMessage());
351
+ }
352
+ }
353
+ }
354
+ } catch (Exception e) {
355
+ Log.e(TAG, "Error handling tag: " + e.getMessage(), e);
356
+ Log.d(TAG, "Tag handling failed - Failure");
357
+ }
358
+ }
359
+
360
+ // Send event to JavaScript
361
+ long timestamp = System.currentTimeMillis();
362
+ Log.d(TAG, "Sending onCardScanned event - Success");
363
+ if (listener != null) {
364
+ listener.onCardScanned(uid, data, rawData, timestamp);
365
+ }
366
+ }
367
+
368
+ private String parseTextRecord(NdefRecord record) {
369
+ try {
370
+ byte[] payload = record.getPayload();
371
+ if (payload == null || payload.length == 0) {
372
+ return null;
373
+ }
374
+
375
+ // First byte contains language code length and encoding
376
+ boolean isUtf16 = (payload[0] & 0x80) != 0;
377
+ int languageCodeLength = payload[0] & 0x3F;
378
+ java.nio.charset.Charset textEncoding = isUtf16 ? StandardCharsets.UTF_16 : StandardCharsets.UTF_8;
379
+
380
+ // Extract text
381
+ if (payload.length > languageCodeLength + 1) {
382
+ byte[] textBytes = new byte[payload.length - languageCodeLength - 1];
383
+ System.arraycopy(payload, languageCodeLength + 1, textBytes, 0, textBytes.length);
384
+ return new String(textBytes, textEncoding);
385
+ }
386
+ } catch (Exception e) {
387
+ Log.e(TAG, "Error parsing text record: " + e.getMessage(), e);
388
+ }
389
+ return null;
390
+ }
391
+
392
+ private String bytesToHex(byte[] bytes) {
393
+ StringBuilder result = new StringBuilder();
394
+ for (byte b : bytes) {
395
+ result.append(String.format("%02x", b));
396
+ }
397
+ return result.toString();
398
+ }
399
+
400
+ public void updateActivity(Activity activity) {
401
+ this.currentActivity = activity;
402
+ Log.d(TAG, "Activity updated");
403
+ }
404
+ }
@@ -0,0 +1,71 @@
1
+ package expo.modules.mifarescanner
2
+
3
+ import android.app.Activity
4
+ import android.nfc.NfcAdapter
5
+ import expo.modules.kotlin.modules.Module
6
+ import expo.modules.kotlin.modules.ModuleDefinition
7
+ import expo.modules.kotlin.events.EventsDefinition
8
+
9
+ class MifareScannerModule : Module() {
10
+ private val TAG = "MifareScannerModule"
11
+ private var nfcAdapter: NfcAdapter? = null
12
+ private var currentActivity: Activity? = null
13
+ private var mifareScanner: MifareScanner? = null
14
+
15
+ override fun definition() = ModuleDefinition {
16
+ Name("ExpoMifareScanner")
17
+
18
+ Events("onCardScanned")
19
+
20
+ OnCreate {
21
+ currentActivity = appContext.currentActivity
22
+ nfcAdapter = NfcAdapter.getDefaultAdapter(appContext.reactContext)
23
+
24
+ if (nfcAdapter != null && currentActivity != null) {
25
+ mifareScanner = MifareScanner(nfcAdapter!!, currentActivity!!)
26
+ mifareScanner?.setOnCardScannedListener { uid, data, rawData, timestamp ->
27
+ sendEvent("onCardScanned", mapOf(
28
+ "uid" to uid,
29
+ "data" to data,
30
+ "rawData" to rawData,
31
+ "timestamp" to timestamp
32
+ ))
33
+ }
34
+ }
35
+ }
36
+
37
+ OnActivityEntersForeground {
38
+ currentActivity = appContext.currentActivity
39
+ mifareScanner?.updateActivity(currentActivity)
40
+ }
41
+
42
+ Function("startScanning") {
43
+ try {
44
+ if (mifareScanner == null) {
45
+ throw Exception("MIFARE scanner is not initialized. NFC adapter may not be available.")
46
+ }
47
+ mifareScanner.startScanning()
48
+ } catch (e: Exception) {
49
+ throw Exception("Failed to start NFC scanning: ${e.message}", e)
50
+ }
51
+ }
52
+
53
+ Function("stopScanning") {
54
+ try {
55
+ mifareScanner?.stopScanning()
56
+ } catch (e: Exception) {
57
+ // Log but don't throw - stopping is best effort
58
+ android.util.Log.e(TAG, "Error stopping scan: ${e.message}", e)
59
+ }
60
+ }
61
+
62
+ Function("isNfcEnabled") {
63
+ try {
64
+ mifareScanner?.isNfcEnabled() ?: false
65
+ } catch (e: Exception) {
66
+ android.util.Log.e(TAG, "Error checking NFC status: ${e.message}", e)
67
+ false
68
+ }
69
+ }
70
+ }
71
+ }
package/app.plugin.js ADDED
@@ -0,0 +1,44 @@
1
+ const { withAndroidManifest } = require('@expo/config-plugins');
2
+
3
+ /**
4
+ * Expo config plugin for ExpoMifareScanner
5
+ * Adds NFC permissions and features to AndroidManifest.xml
6
+ */
7
+ const withMifareScanner = (config) => {
8
+ return withAndroidManifest(config, async (config) => {
9
+ const androidManifest = config.modResults;
10
+ const { manifest } = androidManifest;
11
+
12
+ // Ensure permissions array exists
13
+ if (!manifest['uses-permission']) {
14
+ manifest['uses-permission'] = [];
15
+ }
16
+
17
+ // Add NFC permission if not already present
18
+ const hasNfcPermission = manifest['uses-permission'].some(
19
+ (perm) => perm.$['android:name'] === 'android.permission.NFC'
20
+ );
21
+ if (!hasNfcPermission) {
22
+ manifest['uses-permission'].push({
23
+ $: { 'android:name': 'android.permission.NFC' },
24
+ });
25
+ }
26
+
27
+ // Add NFC feature if not already present
28
+ if (!manifest['uses-feature']) {
29
+ manifest['uses-feature'] = [];
30
+ }
31
+ const hasNfcFeature = manifest['uses-feature'].some(
32
+ (feat) => feat.$['android:name'] === 'android.hardware.nfc'
33
+ );
34
+ if (!hasNfcFeature) {
35
+ manifest['uses-feature'].push({
36
+ $: { 'android:name': 'android.hardware.nfc', 'android:required': 'false' },
37
+ });
38
+ }
39
+
40
+ return config;
41
+ });
42
+ };
43
+
44
+ module.exports = withMifareScanner;
@@ -0,0 +1,6 @@
1
+ {
2
+ "platforms": ["android"],
3
+ "android": {
4
+ "modules": ["expo.modules.mifarescanner.MifareScannerModule"]
5
+ }
6
+ }
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@utapza/expo-mifare-scanner",
3
+ "version": "1.0.0",
4
+ "description": "Expo module for scanning MIFARE Classic cards",
5
+ "main": "src/index.js",
6
+ "keywords": [
7
+ "react-native",
8
+ "expo",
9
+ "mifare",
10
+ "nfc",
11
+ "expo-module"
12
+ ],
13
+ "author": "Your Name",
14
+ "license": "MIT",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/utapza/expo-mifare-scanner.git"
18
+ },
19
+ "peerDependencies": {
20
+ "expo": "*",
21
+ "expo-modules-core": "*"
22
+ },
23
+ "files": [
24
+ "src",
25
+ "android/src",
26
+ "android/build.gradle",
27
+ "expo-module.config.json",
28
+ "app.plugin.js",
29
+ "README.md"
30
+ ],
31
+ "publishConfig": {
32
+ "access": "public"
33
+ }
34
+ }
package/src/index.js ADDED
@@ -0,0 +1,167 @@
1
+ import { requireNativeModule, EventEmitter } from 'expo-modules-core';
2
+
3
+ // Import the native module
4
+ let ExpoMifareScanner;
5
+ let isExpoGo = false;
6
+ try {
7
+ ExpoMifareScanner = requireNativeModule('ExpoMifareScanner');
8
+ } catch (e) {
9
+ ExpoMifareScanner = null;
10
+ // Check if we're running in Expo Go (which doesn't support custom native modules)
11
+ try {
12
+ const { Constants } = require('expo-constants');
13
+ isExpoGo = Constants.executionEnvironment === 'storeClient';
14
+ } catch (constantsError) {
15
+ // If expo-constants is not available, we can't definitively detect Expo Go
16
+ // But the error message will still be helpful
17
+ isExpoGo = false;
18
+ }
19
+
20
+ // Log a warning (not an error) - this is expected in Expo Go
21
+ if (isExpoGo) {
22
+ console.warn('ExpoMifareScanner: Custom native modules are not supported in Expo Go. Please use a development build.');
23
+ } else {
24
+ console.warn('ExpoMifareScanner: Native module not found. This feature requires a development build.');
25
+ }
26
+ }
27
+
28
+ const emitter = new EventEmitter(ExpoMifareScanner || {});
29
+
30
+ export function addCardScannedListener(listener) {
31
+ return emitter.addListener('onCardScanned', listener);
32
+ }
33
+
34
+ export function removeCardScannedListener(subscription) {
35
+ if (subscription && subscription.remove) {
36
+ subscription.remove();
37
+ }
38
+ }
39
+
40
+ export async function startScanning() {
41
+ if (!ExpoMifareScanner) {
42
+ const errorMessage = isExpoGo
43
+ ? 'ExpoMifareScanner requires a development build. Custom native modules are not supported in Expo Go.'
44
+ : 'ExpoMifareScanner native module not available. Please rebuild the app with a development build.';
45
+ throw new Error(errorMessage);
46
+ }
47
+ return await ExpoMifareScanner.startScanning();
48
+ }
49
+
50
+ export async function stopScanning() {
51
+ if (!ExpoMifareScanner) {
52
+ // Silently fail if module is not available (already stopped)
53
+ return;
54
+ }
55
+ return await ExpoMifareScanner.stopScanning();
56
+ }
57
+
58
+ export async function isNfcEnabled() {
59
+ if (!ExpoMifareScanner) {
60
+ return false;
61
+ }
62
+ return await ExpoMifareScanner.isNfcEnabled();
63
+ }
64
+
65
+ /**
66
+ * Read NFC tag - simplified async API
67
+ * Starts scanning, waits for tag discovery, automatically stops, and returns data
68
+ *
69
+ * @param {Object} options - Optional configuration
70
+ * @param {number} options.timeout - Timeout in milliseconds (default: 30000 = 30 seconds)
71
+ * @returns {Promise<Object>} Promise that resolves with card data or rejects with error
72
+ *
73
+ * @example
74
+ * try {
75
+ * const cardData = await readNfcTag({ timeout: 10000 });
76
+ * console.log('Card UID:', cardData.uid);
77
+ * console.log('Card Data:', cardData.data);
78
+ * } catch (error) {
79
+ * console.error('Scan failed:', error.message);
80
+ * }
81
+ */
82
+ export async function readNfcTag(options = {}) {
83
+ const { timeout = 30000 } = options;
84
+
85
+ if (!ExpoMifareScanner) {
86
+ const errorMessage = isExpoGo
87
+ ? 'ExpoMifareScanner requires a development build. Custom native modules are not supported in Expo Go.'
88
+ : 'ExpoMifareScanner native module not available. Please rebuild the app with a development build.';
89
+ throw new Error(errorMessage);
90
+ }
91
+
92
+ // Check if NFC is enabled
93
+ const nfcEnabled = await isNfcEnabled();
94
+ if (!nfcEnabled) {
95
+ throw new Error('NFC is not enabled on this device. Please enable NFC in settings.');
96
+ }
97
+
98
+ return new Promise((resolve, reject) => {
99
+ let scanSubscription = null;
100
+ let timeoutId = null;
101
+ let isResolved = false;
102
+
103
+ // Cleanup function
104
+ const cleanup = () => {
105
+ if (timeoutId) {
106
+ clearTimeout(timeoutId);
107
+ timeoutId = null;
108
+ }
109
+ if (scanSubscription) {
110
+ removeCardScannedListener(scanSubscription);
111
+ scanSubscription = null;
112
+ }
113
+ // Stop scanning (best effort, don't wait for it)
114
+ if (ExpoMifareScanner) {
115
+ ExpoMifareScanner.stopScanning().catch(() => {
116
+ // Ignore errors when stopping
117
+ });
118
+ }
119
+ };
120
+
121
+ // Set up timeout
122
+ timeoutId = setTimeout(() => {
123
+ if (!isResolved) {
124
+ isResolved = true;
125
+ cleanup();
126
+ reject(new Error(`NFC scan timed out after ${timeout}ms. Please try again.`));
127
+ }
128
+ }, timeout);
129
+
130
+ // Set up event listener
131
+ try {
132
+ scanSubscription = addCardScannedListener((event) => {
133
+ if (isResolved) {
134
+ return; // Already resolved/rejected
135
+ }
136
+
137
+ isResolved = true;
138
+ cleanup();
139
+
140
+ // Format the response
141
+ const cardData = {
142
+ uid: event.uid,
143
+ data: event.data,
144
+ rawData: event.rawData,
145
+ timestamp: event.timestamp,
146
+ };
147
+
148
+ resolve(cardData);
149
+ });
150
+
151
+ // Start scanning
152
+ startScanning().catch((error) => {
153
+ if (!isResolved) {
154
+ isResolved = true;
155
+ cleanup();
156
+ reject(new Error(`Failed to start NFC scanning: ${error.message}`));
157
+ }
158
+ });
159
+ } catch (error) {
160
+ if (!isResolved) {
161
+ isResolved = true;
162
+ cleanup();
163
+ reject(new Error(`Failed to set up NFC scan: ${error.message}`));
164
+ }
165
+ }
166
+ });
167
+ }