diff --git a/README.md b/README.md index a769807..e246198 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ On Android, missing Bluetooth/location permission may report as `denied` before - `addReaderDiscoveredListener()` — receive Readers during the scan window. - `stopReaderScan()` — end the active scan early. -Continuous scans emit discovered Readers in real time and use `timeoutMs` (5 seconds by default) for each supported Reader model before rotating to the next. Their promise resolves when `stopReaderScan()` or `connectReader()` stops the scan. Call `stopReaderScan()` when leaving the owning screen. +Continuous scans emit discovered Readers in real time. Android uses one filtered, low-latency scan for all supported Reader models; iOS uses the vendor's model-specific scan API. Their promise resolves when `stopReaderScan()` or `connectReader()` stops the scan. Call `stopReaderScan()` when leaving the owning screen. Starting a new `scanReaders()` while one is active supersedes the prior scan: the prior promise resolves with partial results collected so far, and discovery events for that scan stop once it ends. diff --git a/android/build.gradle b/android/build.gradle index 9413f09..42e3a06 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -4,14 +4,14 @@ plugins { } group = 'expo.modules.blenfcreader' -version = '0.2.3' +version = '0.2.4' android { namespace "expo.modules.blenfcreader" defaultConfig { minSdkVersion 23 versionCode 1 - versionName "0.2.3" + versionName "0.2.4" } lintOptions { abortOnError false diff --git a/android/libs/acssmcio-0.6.2.aar b/android/libs/acssmcio-0.6.2.aar index 727b782..79a5050 100644 Binary files a/android/libs/acssmcio-0.6.2.aar and b/android/libs/acssmcio-0.6.2.aar differ diff --git a/android/src/main/java/expo/modules/blenfcreader/ReactNativeBleNfcReaderModule.kt b/android/src/main/java/expo/modules/blenfcreader/ReactNativeBleNfcReaderModule.kt index d86ac8e..fd76c6d 100644 --- a/android/src/main/java/expo/modules/blenfcreader/ReactNativeBleNfcReaderModule.kt +++ b/android/src/main/java/expo/modules/blenfcreader/ReactNativeBleNfcReaderModule.kt @@ -1,14 +1,20 @@ package expo.modules.blenfcreader import android.Manifest +import android.bluetooth.BluetoothManager +import android.bluetooth.le.BluetoothLeScanner +import android.bluetooth.le.ScanCallback +import android.bluetooth.le.ScanFilter +import android.bluetooth.le.ScanResult +import android.bluetooth.le.ScanSettings import android.content.Context import android.content.pm.PackageManager import android.os.Build -import androidx.core.app.ActivityCompat import android.os.Bundle import android.os.Handler import android.os.Looper -import com.acs.smartcardio.BluetoothSmartCard +import android.os.ParcelUuid +import androidx.core.app.ActivityCompat import com.acs.smartcardio.BluetoothTerminalManager import expo.modules.interfaces.permissions.PermissionsStatus import expo.modules.kotlin.Promise @@ -21,6 +27,7 @@ import javax.smartcardio.Card import javax.smartcardio.CardException import javax.smartcardio.CardTerminal import javax.smartcardio.CommandAPDU +import java.util.UUID private const val READER_DISCOVERED_EVENT = "onReaderDiscovered" private const val CARD_PRESENT_EVENT = "onCardPresent" @@ -38,16 +45,24 @@ class ReactNativeBleNfcReaderModule : Module() { private val scanHandler = Handler(Looper.getMainLooper()) private val readerLock = Any() private val terminalIoLock = Any() - private val scanTerminalTypes = intArrayOf( - BluetoothTerminalManager.TERMINAL_TYPE_ACR3901U_S1, - BluetoothTerminalManager.TERMINAL_TYPE_ACR1255U_J1, - BluetoothTerminalManager.TERMINAL_TYPE_AMR220_C, - BluetoothTerminalManager.TERMINAL_TYPE_ACR1255U_J1_V2, - BluetoothTerminalManager.TERMINAL_TYPE_ACR1555U + private val scanTerminalTypes = linkedMapOf( + UUID.fromString("00003970-817C-48DF-8DB2-476A8134EDE0") to + BluetoothTerminalManager.TERMINAL_TYPE_ACR1555U, + UUID.fromString("0000FFF0-0000-1000-8000-00805F9B34FB") to + BluetoothTerminalManager.TERMINAL_TYPE_ACR1255U_J1, + UUID.fromString("3C4AFFF0-4783-3DE5-A983-D348718EF133") to + BluetoothTerminalManager.TERMINAL_TYPE_ACR1255U_J1_V2, + UUID.fromString("AE448001-6E87-DF33-ADB8-51DFD29BE725") to + BluetoothTerminalManager.TERMINAL_TYPE_ACR3901U_S1, + UUID.fromString("01A50001-BA65-489A-B49C-4FF986E262EF") to + BluetoothTerminalManager.TERMINAL_TYPE_AMR220_C ) private val discoveredReaders = LinkedHashMap>() private val knownTerminals = LinkedHashMap() + private val knownTerminalManagers = LinkedHashMap() private var activeScanManager: BluetoothTerminalManager? = null + private var activeBleScanner: BluetoothLeScanner? = null + private var activeBleScanCallback: ScanCallback? = null private var activeReaderId: String? = null private var activeReaderTerminal: CardTerminal? = null private var activeReaderManager: BluetoothTerminalManager? = null @@ -59,9 +74,6 @@ class ReactNativeBleNfcReaderModule : Module() { private var activeCardMonitorOptions: NormalizedCardMonitorOptions? = null private var scanPromise: Promise? = null private var scanStopRunnable: Runnable? = null - private var scanTypeRunnable: Runnable? = null - private var scanTypeIndex = 0 - private var scanTypeDelayMs = 1000L private var readerPermissionsRequested = false override fun definition() = ModuleDefinition { @@ -329,26 +341,69 @@ class ReactNativeBleNfcReaderModule : Module() { val timeoutMs = normalizedTimeoutMs(options) finishScan() - val manager = BluetoothSmartCard.getInstance(context()).manager ?: throw ReaderScanUnavailableException() - activeScanManager = manager + val context = context() scanPromise = promise - scanTypeIndex = 0 - if (options?.continuous == true) { - scanTypeDelayMs = timeoutMs - } else { - scanTypeDelayMs = maxOf(250L, timeoutMs / scanTerminalTypes.size) - } discoveredReaders.clear() synchronized(readerLock) { val activeId = activeReaderId val activeTerminal = activeReaderTerminal + val activeManager = activeReaderManager knownTerminals.clear() - if (activeId != null && activeTerminal != null) { + knownTerminalManagers.clear() + if (activeId != null && activeTerminal != null && activeManager != null) { knownTerminals[activeId] = activeTerminal + knownTerminalManagers[activeId] = activeManager } } - startCurrentScanType() + val bluetoothManager = + context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager + ?: throw ReaderScanUnavailableException() + val scanner = bluetoothManager.adapter?.bluetoothLeScanner + ?: throw ReaderScanUnavailableException() + val manager = BluetoothTerminalManager(context) + val terminalCallback = BluetoothTerminalManager.TerminalScanCallback { terminal -> + scanHandler.post { + addDiscoveredReader(terminal, manager) + } + } + val callback = object : ScanCallback() { + override fun onScanResult(callbackType: Int, result: ScanResult) { + val terminalType = terminalType(result) ?: return + val name = result.scanRecord?.deviceName ?: result.device.name ?: return + + try { + manager.a(terminalCallback, terminalType, result.device, name) + } catch (_: Exception) { + } + } + + override fun onScanFailed(errorCode: Int) { + scanHandler.post { + failScan() + } + } + } + val filters = scanTerminalTypes.keys.map { serviceUuid -> + ScanFilter.Builder() + .setServiceUuid(ParcelUuid(serviceUuid)) + .build() + } + val settings = ScanSettings.Builder() + .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) + .build() + + activeScanManager = manager + activeBleScanner = scanner + activeBleScanCallback = callback + try { + scanner.startScan(filters, settings, callback) + } catch (error: Exception) { + stopActiveScan() + scanPromise = null + discoveredReaders.clear() + throw error + } if (options?.continuous == true) { return @@ -397,29 +452,11 @@ class ReactNativeBleNfcReaderModule : Module() { ) } - private fun startCurrentScanType() { - if (scanPromise == null) { - return - } - - val manager = activeScanManager ?: return - - manager.stopScan() - manager.startScan(scanTerminalTypes[scanTypeIndex]) { terminal -> - scanHandler.post { - addDiscoveredReader(terminal) - } - } - - scanTypeIndex = (scanTypeIndex + 1) % scanTerminalTypes.size - scanTypeRunnable = Runnable { - startCurrentScanType() - } - scanHandler.postDelayed(scanTypeRunnable!!, scanTypeDelayMs) - } - - private fun addDiscoveredReader(terminal: CardTerminal) { - if (activeScanManager == null || scanPromise == null) { + private fun addDiscoveredReader( + terminal: CardTerminal, + manager: BluetoothTerminalManager + ) { + if (scanPromise == null || manager !== activeScanManager) { return } @@ -431,6 +468,7 @@ class ReactNativeBleNfcReaderModule : Module() { synchronized(readerLock) { knownTerminals[terminal.name] = terminal + knownTerminalManagers[terminal.name] = manager } sendEvent(READER_DISCOVERED_EVENT, Bundle().apply { @@ -438,11 +476,26 @@ class ReactNativeBleNfcReaderModule : Module() { }) } + private fun terminalType(result: ScanResult): Int? { + val serviceUuids = result.scanRecord?.serviceUuids ?: return null + + return scanTerminalTypes.entries.firstOrNull { (serviceUuid) -> + serviceUuids.any { it.uuid == serviceUuid } + }?.value + } + + private fun failScan() { + val promise = scanPromise ?: return + scanPromise = null + stopActiveScan() + discoveredReaders.clear() + promise.reject(ReaderScanUnavailableException()) + } + private fun connectReader(readerId: String): Map { - val manager = readerManager() finishScan() - val terminal = synchronized(readerLock) { + val connection = synchronized(readerLock) { val activeId = activeReaderId if (activeId != null && activeId != readerId) { @@ -451,17 +504,20 @@ class ReactNativeBleNfcReaderModule : Module() { if (activeId == readerId) { val activeTerminal = activeReaderTerminal ?: throw ReaderNotConnectedException(readerId) - return@synchronized activeTerminal + val activeManager = activeReaderManager ?: throw ReaderConnectionUnavailableException() + return@synchronized Pair(activeManager, activeTerminal) } val knownTerminal = knownTerminals[readerId] ?: throw ReaderNotFoundException(readerId) + val knownManager = + knownTerminalManagers[readerId] ?: throw ReaderConnectionUnavailableException() activeReaderId = readerId activeReaderTerminal = knownTerminal - activeReaderManager = manager - knownTerminal + activeReaderManager = knownManager + Pair(knownManager, knownTerminal) } - return readerForTerminal(terminal, manager) + return readerForTerminal(connection.second, connection.first) } private fun disconnectReader(readerId: String) { @@ -912,10 +968,6 @@ class ReactNativeBleNfcReaderModule : Module() { } } - private fun readerManager(): BluetoothTerminalManager { - return BluetoothSmartCard.getInstance(context()).manager ?: throw ReaderConnectionUnavailableException() - } - private fun readerForTerminal( terminal: CardTerminal, manager: BluetoothTerminalManager? = null @@ -996,6 +1048,7 @@ class ReactNativeBleNfcReaderModule : Module() { activeReaderTerminal = null activeReaderManager = null knownTerminals.clear() + knownTerminalManagers.clear() if (manager == null || terminal == null) { null @@ -1016,12 +1069,9 @@ class ReactNativeBleNfcReaderModule : Module() { private fun finishScan(): ArrayList> { scanStopRunnable?.let(scanHandler::removeCallbacks) - scanTypeRunnable?.let(scanHandler::removeCallbacks) scanStopRunnable = null - scanTypeRunnable = null - activeScanManager?.stopScan() - activeScanManager = null + stopActiveScan() val readers = ArrayList(discoveredReaders.values) discoveredReaders.clear() @@ -1032,6 +1082,21 @@ class ReactNativeBleNfcReaderModule : Module() { return readers } + private fun stopActiveScan() { + val callback = activeBleScanCallback + + if (callback != null) { + try { + activeBleScanner?.stopScan(callback) + } catch (_: Exception) { + } + } + + activeBleScanCallback = null + activeBleScanner = null + activeScanManager = null + } + private fun readerBundle(reader: Map): Bundle { return Bundle().apply { putString("id", reader["id"] as? String) diff --git a/docs/vendor/acs-evk/README.md b/docs/vendor/acs-evk/README.md index 3ce1ccd..a390dfb 100644 --- a/docs/vendor/acs-evk/README.md +++ b/docs/vendor/acs-evk/README.md @@ -2,6 +2,8 @@ This folder keeps the upstream ACS BLE EVK Android and iOS demo projects as reference material. The package runtime uses the vendored binaries under `android/libs` and `ios/Frameworks`; this folder is not part of the build. +`android/libs/acssmcio-0.6.2.aar` is patched to replace the vendor's hardcoded `SCAN_MODE_LOW_POWER` with `SCAN_MODE_LOW_LATENCY` and expose its terminal factory to the library's single filtered Android scan. Run `node internal/patches/patch-acs-android-scan-mode.mjs --check` to verify the binary patch. + Demo capabilities not currently exposed by the library: - Reader admin settings: set/reset master key and get/set terminal timeouts. diff --git a/example/app/index.tsx b/example/app/index.tsx index 5328b9c..b716996 100644 --- a/example/app/index.tsx +++ b/example/app/index.tsx @@ -1,4 +1,5 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; +import { useFocusEffect } from 'expo-router'; import { Alert, ScrollView, StyleSheet, Text, View } from 'react-native'; import { addCardPresentListener, @@ -88,7 +89,7 @@ export default function ReaderTestScreen() { setConnectedReader(null); setCardMonitorRunning(false); setCardPresence('unknown'); - setReaders(await scanReaders({ timeoutMs: 5000 })); + await scanReaders({ continuous: true, timeoutMs: 5000 }); setMessage(''); } catch (error) { setMessage(formatError(error)); @@ -110,6 +111,11 @@ export default function ReaderTestScreen() { async function connectToReader(readerId: string) { try { + if (scanning) { + setReaders(await stopReaderScan()); + setScanning(false); + } + setConnectedReader(await connectReader(readerId)); setCardMonitorRunning(false); setCardPresence('unknown'); @@ -302,15 +308,18 @@ export default function ReaderTestScreen() { void refreshPermissionStatus(); }, []); - useEffect(() => { - const subscription = addReaderDiscoveredListener((event) => { - setReaders((currentReaders) => addReader(currentReaders, event.reader)); - }); + useFocusEffect( + useCallback(() => { + const subscription = addReaderDiscoveredListener((event) => { + setReaders((currentReaders) => addReader(currentReaders, event.reader)); + }); - return () => { - subscription.remove(); - }; - }, []); + return () => { + subscription.remove(); + void stopReaderScan().catch(() => undefined); + }; + }, []) + ); useEffect(() => { const presentSubscription = addCardPresentListener((event) => { @@ -358,6 +367,11 @@ export default function ReaderTestScreen() { + 0 ? 'good' : 'default'} + /> @@ -383,12 +397,12 @@ export default function ReaderTestScreen() {
+ detail="Readers appear as they are discovered. Connecting, stopping, or leaving this screen ends the continuous scan."> @@ -411,7 +425,7 @@ export default function ReaderTestScreen() { connectToReader(reader.id)} title={connected ? 'Connected' : 'Connect'} /> diff --git a/internal/patches/patch-acs-android-scan-mode.mjs b/internal/patches/patch-acs-android-scan-mode.mjs new file mode 100644 index 0000000..157ea45 --- /dev/null +++ b/internal/patches/patch-acs-android-scan-mode.mjs @@ -0,0 +1,196 @@ +import { execFileSync } from 'node:child_process'; +import { + copyFileSync, + mkdirSync, + mkdtempSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('../..', import.meta.url)); +const aarPath = join(root, 'android/libs/acssmcio-0.6.2.aar'); +const classEntry = 'com/acs/smartcardio/BluetoothTerminalManager.class'; +const lowPowerInstruction = Buffer.from([0x03, 0xb6, 0x01, 0x55]); +const lowLatencyInstruction = Buffer.from([0x05, 0xb6, 0x01, 0x55]); +const terminalFactoryDescriptor = + '(Lcom/acs/smartcardio/BluetoothTerminalManager$TerminalScanCallback;ILandroid/bluetooth/BluetoothDevice;Ljava/lang/String;)V'; + +function findUnique(buffer, instruction) { + const offset = buffer.indexOf(instruction); + + if (offset < 0 || buffer.indexOf(instruction, offset + 1) >= 0) { + return -1; + } + + return offset; +} + +function terminalFactoryAccessOffset(buffer) { + const utf8 = new Map(); + const constantPoolCount = buffer.readUInt16BE(8); + let offset = 10; + + for (let index = 1; index < constantPoolCount; index += 1) { + const tag = buffer[offset]; + offset += 1; + + if (tag === 1) { + const length = buffer.readUInt16BE(offset); + offset += 2; + utf8.set(index, buffer.toString('utf8', offset, offset + length)); + offset += length; + continue; + } + + if (tag === 3 || tag === 4) { + offset += 4; + continue; + } + + if (tag === 5 || tag === 6) { + offset += 8; + index += 1; + continue; + } + + if ([7, 8, 16, 19, 20].includes(tag)) { + offset += 2; + continue; + } + + if ([9, 10, 11, 12, 17, 18].includes(tag)) { + offset += 4; + continue; + } + + if (tag === 15) { + offset += 3; + continue; + } + + throw new Error(`Unsupported class constant tag: ${tag}`); + } + + offset += 6; + const interfaceCount = buffer.readUInt16BE(offset); + offset += 2 + interfaceCount * 2; + + const fieldCount = buffer.readUInt16BE(offset); + offset += 2; + offset = skipMembers(buffer, offset, fieldCount); + + const methodCount = buffer.readUInt16BE(offset); + offset += 2; + + for (let index = 0; index < methodCount; index += 1) { + const accessOffset = offset; + const name = utf8.get(buffer.readUInt16BE(offset + 2)); + const descriptor = utf8.get(buffer.readUInt16BE(offset + 4)); + const attributeCount = buffer.readUInt16BE(offset + 6); + offset = skipAttributes(buffer, offset + 8, attributeCount); + + if (name === 'a' && descriptor === terminalFactoryDescriptor) { + return accessOffset; + } + } + + return -1; +} + +function skipMembers(buffer, initialOffset, count) { + let offset = initialOffset; + + for (let index = 0; index < count; index += 1) { + const attributeCount = buffer.readUInt16BE(offset + 6); + offset = skipAttributes(buffer, offset + 8, attributeCount); + } + + return offset; +} + +function skipAttributes(buffer, initialOffset, count) { + let offset = initialOffset; + + for (let index = 0; index < count; index += 1) { + const length = buffer.readUInt32BE(offset + 2); + offset += 6 + length; + } + + return offset; +} + +function inspectOrPatch() { + const workingDirectory = mkdtempSync(join(tmpdir(), 'acs-android-scan-mode-')); + const aarDirectory = join(workingDirectory, 'aar'); + const classDirectory = join(workingDirectory, 'classes'); + + try { + mkdirSync(aarDirectory); + mkdirSync(classDirectory); + execFileSync('unzip', ['-q', aarPath, '-d', aarDirectory]); + + const classesJar = join(aarDirectory, 'classes.jar'); + execFileSync('jar', ['xf', classesJar], { cwd: classDirectory }); + + const classPath = join(classDirectory, classEntry); + const classBytes = readFileSync(classPath); + const lowPowerOffset = findUnique(classBytes, lowPowerInstruction); + const lowLatencyOffset = findUnique(classBytes, lowLatencyInstruction); + const factoryAccessOffset = terminalFactoryAccessOffset(classBytes); + + if (factoryAccessOffset < 0) { + throw new Error('Expected ACS terminal factory method was not found'); + } + + const factoryAccess = classBytes.readUInt16BE(factoryAccessOffset); + const factoryIsPublic = (factoryAccess & 0x0001) !== 0; + + if (process.argv.includes('--check')) { + if (lowPowerOffset >= 0 || lowLatencyOffset < 0 || !factoryIsPublic) { + throw new Error('ACS Android SDK scan optimizations are not applied'); + } + + console.log('ACS Android SDK scan optimizations are applied'); + return; + } + + if (lowPowerOffset < 0 && lowLatencyOffset < 0) { + throw new Error('Expected ACS scan-mode instruction was not found exactly once'); + } + + let changed = false; + + if (lowPowerOffset >= 0) { + classBytes[lowPowerOffset] = 0x05; + changed = true; + } + + if (!factoryIsPublic) { + classBytes.writeUInt16BE((factoryAccess & ~0x0002) | 0x0001, factoryAccessOffset); + changed = true; + } + + if (!changed) { + console.log('ACS Android SDK scan optimizations are already applied'); + return; + } + + writeFileSync(classPath, classBytes); + execFileSync('jar', ['uf', classesJar, '-C', classDirectory, classEntry]); + + const patchedAar = join(workingDirectory, 'acssmcio-0.6.2.aar'); + copyFileSync(aarPath, patchedAar); + execFileSync('jar', ['uf', patchedAar, '-C', dirname(classesJar), 'classes.jar']); + renameSync(patchedAar, aarPath); + console.log('Patched ACS Android SDK scan optimizations'); + } finally { + rmSync(workingDirectory, { recursive: true, force: true }); + } +} + +inspectOrPatch(); diff --git a/ios/ReactNativeBleNfcReader.podspec b/ios/ReactNativeBleNfcReader.podspec index 518f6ed..25984a3 100644 --- a/ios/ReactNativeBleNfcReader.podspec +++ b/ios/ReactNativeBleNfcReader.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'ReactNativeBleNfcReader' - s.version = '0.2.3' + s.version = '0.2.4' s.summary = 'React Native BLE NFC reader' s.description = 'Expo native module for ACS BLE NFC readers' s.author = 'countertek' diff --git a/package.json b/package.json index 845524f..f26b253 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@countertek/react-native-ble-nfc-reader", - "version": "0.2.3", + "version": "0.2.4", "description": "Expo native module for ACS BLE NFC Readers", "main": "build/index.js", "types": "build/index.d.ts", diff --git a/src/android-native-contract.test.ts b/src/android-native-contract.test.ts index d802a00..2f87841 100644 --- a/src/android-native-contract.test.ts +++ b/src/android-native-contract.test.ts @@ -12,18 +12,25 @@ const androidModule = readFileSync( const scanReaders = androidModule.match( /private fun scanReaders[\s\S]*?(?= private fun normalizedTimeoutMs)/ )?.[0]; +const stopActiveScan = androidModule.match( + /private fun stopActiveScan[\s\S]*?(?= private fun readerBundle)/ +)?.[0]; describe('Android native contracts', () => { - it('keeps scanning with a full timeout per Reader model when requested', () => { - expect(scanReaders).toMatch( - /if \(options\?\.continuous == true\) \{\s*scanTypeDelayMs = timeoutMs/ - ); - expect(scanReaders).toContain( - 'scanTypeDelayMs = maxOf(250L, timeoutMs / scanTerminalTypes.size)' + it('scans all supported Reader models with one low-latency registration', () => { + expect(androidModule).toMatch( + /scanTerminalTypes = linkedMapOf\([\s\S]*TERMINAL_TYPE_ACR1555U,[\s\S]*TERMINAL_TYPE_ACR1255U_J1,[\s\S]*TERMINAL_TYPE_ACR1255U_J1_V2/ ); expect(scanReaders).toMatch( - /startCurrentScanType\(\)\s*if \(options\?\.continuous == true\) \{\s*return\s*\}\s*scanStopRunnable/ + /ScanSettings\.Builder\(\)[\s\S]*SCAN_MODE_LOW_LATENCY[\s\S]*scanner\.startScan\(filters, settings, callback\)/ ); + expect(scanReaders?.match(/\.startScan\(/g)).toHaveLength(1); + expect(scanReaders).not.toContain('manager.startScan'); + expect(androidModule).toMatch(/override fun onScanFailed[\s\S]*failScan\(\)/); + expect(stopActiveScan).toContain('stopScan(callback)'); + expect(stopActiveScan).toContain('activeBleScanner = null'); + expect(stopActiveScan).toContain('activeBleScanCallback = null'); + expect(androidModule).toMatch(/private fun finishScan[\s\S]*stopActiveScan\(\)/); expect(androidModule).toMatch(/private fun connectReader[\s\S]*?finishScan\(\)/); }); diff --git a/src/example-app-contract.test.ts b/src/example-app-contract.test.ts new file mode 100644 index 0000000..b53a214 --- /dev/null +++ b/src/example-app-contract.test.ts @@ -0,0 +1,16 @@ +const { readFileSync } = require('fs'); +const { join } = require('path'); + +const exampleScreen = readFileSync(join(__dirname, '..', 'example/app/index.tsx'), 'utf8'); + +describe('example app continuous scan contract', () => { + it('streams until stopped by the user, navigation, or connection', () => { + expect(exampleScreen).toContain('await scanReaders({ continuous: true, timeoutMs: 5000 })'); + expect(exampleScreen).toMatch( + /useFocusEffect[\s\S]*addReaderDiscoveredListener[\s\S]*stopReaderScan/ + ); + expect(exampleScreen).toMatch( + /if \(scanning\) \{[\s\S]*stopReaderScan\(\)[\s\S]*connectReader\(readerId\)/ + ); + }); +});