Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,18 @@
<uses-feature android:name="android.hardware.nfc" android:required="false" tools:replace="android:required" />
<uses-feature android:name="android.hardware.nfc.hce" android:required="false" tools:replace="android:required" />

<!-- BLE "tap to send" ecash: advertise (peripheral) + scan/connect (central),
no bonding. neverForLocation avoids pulling in location on Android 12+. -->
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" android:usesPermissionFlags="neverForLocation" tools:targetApi="s" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" tools:targetApi="s" />
<!-- Legacy Bluetooth permissions for Android 11 and below. -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<!-- Pre-31 BLE scanning requires location; scoped out on 31+ via neverForLocation above. -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />

<application
android:label="Ecash App"
android:name="${applicationName}"
Expand Down
761 changes: 761 additions & 0 deletions android/app/src/main/kotlin/app/ecash/BleTapController.kt

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions android/app/src/main/kotlin/app/ecash/EcashHceService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@ class EcashHceService : HostApduService() {
@Volatile
var ndefMessage: ByteArray? = null

/**
* Invoked when a reader has actually read the NDEF payload, i.e. a tap
* just happened. MainActivity forwards it to Dart so the receiver can
* start scanning for the sender's advertisement. Fires once per read, so
* the Dart side must be idempotent.
*/
@Volatile
var onTagRead: (() -> Unit)? = null

private val SW_OK = byteArrayOf(0x90.toByte(), 0x00.toByte())
private val SW_NOT_FOUND = byteArrayOf(0x6A.toByte(), 0x82.toByte())

Expand Down Expand Up @@ -124,6 +133,8 @@ class EcashHceService : HostApduService() {
Selected.CC -> CC_CONTENT
Selected.NDEF -> {
val msg = ndefMessage ?: return reply("READ no NDEF message", SW_NOT_FOUND)
// A reader is pulling the rendezvous: the tap is happening now.
if (offset >= 2) onTagRead?.invoke()
// The NDEF file is NLEN (2 bytes, big-endian) followed by the message.
val nlen = msg.size
byteArrayOf(((nlen ushr 8) and 0xFF).toByte(), (nlen and 0xFF).toByte()) + msg
Expand Down
179 changes: 172 additions & 7 deletions android/app/src/main/kotlin/app/ecash/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@ import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.nfc.NfcAdapter
import android.nfc.Tag
import android.nfc.cardemulation.CardEmulation
import android.nfc.tech.Ndef
import android.util.Log
import android.view.WindowManager
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel

class MainActivity : FlutterActivity() {
Expand All @@ -36,6 +39,13 @@ class MainActivity : FlutterActivity() {
private var nfcPendingIntent: PendingIntent? = null
private var nfcIntentFilters: Array<IntentFilter>? = null

private var bleController: BleTapController? = null
private var bleEventSink: EventChannel.EventSink? = null

private var nfcTapEventSink: EventChannel.EventSink? = null
private var hceEventSink: EventChannel.EventSink? = null
private var readerModeActive = false

override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)

Expand Down Expand Up @@ -65,6 +75,23 @@ class MainActivity : FlutterActivity() {
},
)

// The receiver has to know the moment its rendezvous was read over NFC:
// under the sender-is-peripheral design it must start scanning then, and
// scanning continuously would be a battery problem.
EventChannel(flutterEngine.dartExecutor.binaryMessenger, "ecashapp/nfc_hce/events")
.setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
hceEventSink = events
}

override fun onCancel(arguments: Any?) {
hceEventSink = null
}
})
EcashHceService.onTagRead = {
runOnUiThread { hceEventSink?.success(mapOf("event" to "tagRead")) }
}

MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "ecashapp/nfc_hce")
.setMethodCallHandler { call, result ->
when (call.method) {
Expand Down Expand Up @@ -101,6 +128,98 @@ class MainActivity : FlutterActivity() {
else -> result.notImplemented()
}
}

// BLE "tap to send" transport (Phase 2). Events stream to Dart over an
// EventChannel; the controller posts them on the main thread already.
EventChannel(flutterEngine.dartExecutor.binaryMessenger, "ecashapp/ble_tap/events")
.setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
bleEventSink = events
}

override fun onCancel(arguments: Any?) {
bleEventSink = null
}
})

val ble = BleTapController(applicationContext) { event -> bleEventSink?.success(event) }
bleController = ble
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "ecashapp/ble_tap")
.setMethodCallHandler { call, result ->
when (call.method) {
"isAvailable" -> result.success(ble.isAvailable())
"startReceiving" -> {
val uuid = call.argument<String>("uuid")
if (uuid == null) {
result.error("missing_uuid", "uuid required", null)
} else {
ble.startReceiving(uuid)
result.success(null)
}
}
"startSending" -> {
val uuid = call.argument<String>("uuid")
val blob = call.argument<ByteArray>("blob")
if (uuid == null || blob == null) {
result.error("missing_args", "uuid and blob required", null)
} else {
ble.startSending(uuid, blob)
result.success(null)
}
}
"stop" -> {
ble.stop()
result.success(null)
}
else -> result.notImplemented()
}
}

// NFC reader mode for the "tap to send" handshake (Phase 3). The sender
// reads the receiver's rendezvous (ephemeral pubkey + BLE service UUID)
// off an emulated NDEF tag. Reader mode and foreground dispatch are
// mutually exclusive, so `readerModeActive` gates onResume/onPause.
EventChannel(flutterEngine.dartExecutor.binaryMessenger, "ecashapp/nfc_tap/events")
.setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
nfcTapEventSink = events
}

override fun onCancel(arguments: Any?) {
nfcTapEventSink = null
}
})

MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "ecashapp/nfc_tap")
.setMethodCallHandler { call, result ->
when (call.method) {
"startReader" -> {
val adapter = nfcAdapter
if (adapter == null) {
result.error("no_nfc", "NFC unavailable", null)
} else {
readerModeActive = true
adapter.disableForegroundDispatch(this)
enableReaderModeInternal(adapter)
result.success(null)
}
}
"stopReader" -> {
readerModeActive = false
nfcAdapter?.disableReaderMode(this)
nfcAdapter?.enableForegroundDispatch(
this, nfcPendingIntent, nfcIntentFilters, null,
)
result.success(null)
}
else -> result.notImplemented()
}
}
}

override fun onDestroy() {
bleController?.stop()
super.onDestroy()
}

/**
Expand Down Expand Up @@ -137,17 +256,63 @@ class MainActivity : FlutterActivity() {

override fun onResume() {
super.onResume()
nfcAdapter?.enableForegroundDispatch(
this,
nfcPendingIntent,
nfcIntentFilters,
null,
)
val adapter = nfcAdapter ?: return
if (readerModeActive) {
enableReaderModeInternal(adapter)
} else {
adapter.enableForegroundDispatch(this, nfcPendingIntent, nfcIntentFilters, null)
}
}

override fun onPause() {
super.onPause()
nfcAdapter?.disableForegroundDispatch(this)
val adapter = nfcAdapter ?: return
if (readerModeActive) {
adapter.disableReaderMode(this)
} else {
adapter.disableForegroundDispatch(this)
}
}

private fun enableReaderModeInternal(adapter: NfcAdapter) {
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
adapter.enableReaderMode(this, { tag -> if (tag != null) onTagRead(tag) }, flags, null)
}

/**
* Read the receiver's rendezvous URI (`ecashtap:<base64>`) off an emulated
* NDEF tag and forward it to Dart, which decodes the pubkey + BLE UUID.
*/
private fun onTagRead(tag: Tag) {
val ndef = Ndef.get(tag)
if (ndef == null) {
emitNfcTap(mapOf("event" to "error", "message" to "tag is not NDEF"))
return
}
try {
ndef.connect()
val uri = ndef.ndefMessage?.records?.firstOrNull()?.toUri()?.toString()
if (uri != null) {
emitNfcTap(mapOf("event" to "read", "uri" to uri))
} else {
emitNfcTap(mapOf("event" to "error", "message" to "no URI record on tag"))
}
} catch (e: Exception) {
emitNfcTap(mapOf("event" to "error", "message" to (e.message ?: "NFC read failed")))
} finally {
try {
ndef.close()
} catch (e: Exception) {
Log.w("NfcTap", "ndef close: ${e.message}")
}
}
}

private fun emitNfcTap(event: Map<String, Any?>) {
runOnUiThread { nfcTapEventSink?.success(event) }
}

/**
Expand Down
32 changes: 31 additions & 1 deletion lib/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import 'package:ecashapp/providers/preferences_provider.dart';
import 'package:ecashapp/scan.dart';
import 'package:ecashapp/setttings.dart';
import 'package:ecashapp/sidebar.dart';
import 'package:ecashapp/tap_transfer/tap_receive.dart';
import 'package:ecashapp/theme.dart';
import 'package:ecashapp/toast.dart';
import 'package:ecashapp/utils.dart';
Expand All @@ -43,7 +44,7 @@ class MyApp extends StatefulWidget {
State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
late List<(FederationSelector, bool)> _feds;
int _refreshTrigger = 0;
FederationSelector? _selectedFederation;
Expand Down Expand Up @@ -74,6 +75,12 @@ class _MyAppState extends State<MyApp> {
super.initState();
_feds = widget.initialFederations;

// Passive "tap to receive" — armed while the app is foregrounded and BLE
// permissions are already granted (never prompts). See tap_receive.dart.
WidgetsBinding.instance.addObserver(this);
TapReceive.instance.onEcash = _handleReceivedTapEcash;
TapReceive.instance.arm();

if (_feds.isNotEmpty) {
_selectedFederation = _feds.first.$1;
_isRecovering = _feds.first.$2;
Expand Down Expand Up @@ -341,6 +348,9 @@ class _MyAppState extends State<MyApp> {

@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
TapReceive.instance.onEcash = null;
TapReceive.instance.disarm();
_subscription.cancel();
_deepLinkSubscription?.cancel();
_peerStatusSubscription?.cancel();
Expand All @@ -349,6 +359,26 @@ class _MyAppState extends State<MyApp> {
super.dispose();
}

@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
TapReceive.instance.arm();
} else {
TapReceive.instance.disarm();
}
}

/// Passive receiver got a tapped ecash token: present the same redeem UI the
/// QR scanner uses (no silent auto-reissue). Suppress the funds-received toast
/// while the redeem sheet is up, matching the scan flow.
Future<void> _handleReceivedTapEcash(String ecash) async {
final ctx = _navigatorKey.currentContext;
if (ctx == null) return;
invoicePaidToastVisible.value = false;
await presentReceivedEcash(ctx, ecash);
invoicePaidToastVisible.value = true;
}

void _checkPendingDeepLink() {
final pendingDeepLink = DeepLinkHandler().pendingDeepLink;
if (pendingDeepLink != null) {
Expand Down
Loading
Loading