Perché l'NFC è ancora rilevante

Biglietti d'ingresso, badge aziendali, etichette smart su prodotti, check-in in palestra, tracciabilità in magazzino: l'NFC (Near Field Communication) resta una tecnologia economica e immediata per collegare il mondo fisico a un'app mobile. Un tag NFC costa pochi centesimi, non ha batteria e può essere letto in meno di un secondo semplicemente avvicinando il telefono.

In Flutter il pacchetto di riferimento è nfc_manager, che espone sia l'API Android (android.nfc) sia quella iOS (Core NFC) con una superficie comune. In questo articolo vediamo come leggere e scrivere tag NDEF, come gestire correttamente il ciclo di vita della sessione e quali differenze di piattaforma non si possono ignorare.

NDEF in due minuti

Quasi tutti i tag "consumer" (NTAG213/215/216, MIFARE Ultralight, ICODE SLIX) supportano il formato NDEF (NFC Data Exchange Format). Un messaggio NDEF è una lista di record, ognuno composto da:

  • un TNF (Type Name Format), che indica come interpretare il tipo;
  • un type (ad esempio T per testo, U per URI);
  • un payload di byte.

I casi più comuni sono il record di testo, il record URI e il record MIME. Lavorare a livello NDEF è la scelta giusta nel 90% dei progetti: è portabile fra Android e iOS e non richiede di dialogare con i comandi proprietari del chip.

Installazione e configurazione

Aggiungi la dipendenza:

dependencies:
  nfc_manager: ^3.5.0

Nota: la major 4.x del pacchetto ha introdotto API separate per piattaforma (NfcManagerAndroid, NfcManagerIos, NdefAndroid, NdefIos). Gli esempi di questo articolo usano la 3.x, ancora la più diffusa; se adotti la 4.x la logica resta la stessa ma cambiano i nomi delle classi.

Android

In android/app/src/main/AndroidManifest.xml:

<uses-permission android:name="android.permission.NFC" />
<!-- required="false" evita di escludere dal Play Store i device senza NFC -->
<uses-feature android:name="android.hardware.nfc" android:required="false" />

Nessun permesso a runtime: su Android l'NFC non passa dal sistema dei permessi, basta che sia attivo nelle impostazioni.

iOS

Su iOS servono tre passaggi:

  1. Abilitare la capability Near Field Communication Tag Reading nel target Runner (Xcode → Signing & Capabilities). Questo aggiunge l'entitlement:
<key>com.apple.developer.nfc.readersession.formats</key>
<array>
  <string>NDEF</string>
  <string>TAG</string>
</array>
  1. Aggiungere la descrizione d'uso in Info.plist:
<key>NFCReaderUsageDescription</key>
<string>L'app usa l'NFC per leggere i tag dei prodotti.</string>
  1. Verificare che l'App ID sul portale Apple Developer abbia la capability NFC attiva, altrimenti la firma fallisce.

L'NFC su iOS richiede iPhone 7 o superiore e non funziona in simulatore: serve un device fisico.

Verificare la disponibilità

Prima di qualunque operazione, controlla se l'hardware c'è ed è attivo:

import 'package:nfc_manager/nfc_manager.dart';

Future<bool> get isNfcReady => NfcManager.instance.isAvailable();

Su Android isAvailable() restituisce false anche quando il chip è presente ma disattivato: in quel caso ha senso mostrare un messaggio che inviti l'utente ad attivarlo dalle impostazioni di sistema.

Leggere un tag

La lettura avviene aprendo una sessione. Su Android la sessione attiva il reader mode in foreground; su iOS il sistema mostra automaticamente un foglio modale con il messaggio che hai configurato.

Future<void> readTag() async {
  if (!await NfcManager.instance.isAvailable()) {
    throw StateError('NFC non disponibile o disattivato');
  }

  await NfcManager.instance.startSession(
    pollingOptions: {
      NfcPollingOption.iso14443, // NTAG, MIFARE, carte
      NfcPollingOption.iso15693, // tag a lunga distanza
    },
    alertMessage: 'Avvicina il tag alla parte alta del telefono',
    onDiscovered: (NfcTag tag) async {
      final ndef = Ndef.from(tag);

      if (ndef == null) {
        await NfcManager.instance.stopSession(
          errorMessage: 'Questo tag non è compatibile',
        );
        return;
      }

      final message = ndef.cachedMessage;
      final testi = (message?.records ?? [])
          .map(decodeTextRecord)
          .whereType<String>()
          .toList();

      debugPrint('Record letti: $testi');

      await NfcManager.instance.stopSession(alertMessage: 'Lettura completata');
    },
  );
}

Punti chiave:

  • cachedMessage contiene il messaggio già letto durante la scoperta del tag: evita una read() aggiuntiva ed è più veloce.
  • stopSession su iOS chiude il foglio modale mostrando un segno di spunta (alertMessage) o un errore (errorMessage). Chiamarlo sempre: una sessione lasciata aperta scade dopo circa 60 secondi con un errore poco elegante.
  • onDiscovered può essere invocata più volte se l'utente appoggia più tag: progetta la callback per essere idempotente.

Decodificare un record di testo

Il pacchetto fornisce i costruttori per scrivere, ma la decodifica è a carico nostro. Il payload di un record di testo ha una struttura precisa: un byte di stato, il codice lingua, poi il testo.

import 'dart:convert';

String? decodeTextRecord(NdefRecord record) {
  final isWellKnown =
      record.typeNameFormat == NdefTypeNameFormat.nfcWellknown;
  final isText = record.type.length == 1 && record.type.first == 0x54; // 'T'
  if (!isWellKnown || !isText) return null;

  final payload = record.payload;
  if (payload.isEmpty) return null;

  final status = payload.first;
  final isUtf16 = (status & 0x80) != 0;
  final langLength = status & 0x3F;
  final bytes = payload.sublist(1 + langLength);

  return isUtf16
      ? String.fromCharCodes(bytes) // semplificazione: UTF-16 è raro
      : utf8.decode(bytes, allowMalformed: true);
}

Per i record URI (type uguale a 0x55) il primo byte del payload è un prefisso abbreviato (0x01 = http://www., 0x03 = http://, 0x04 = https://, …) seguito dal resto dell'URL in UTF-8.

Scrivere su un tag

La scrittura segue lo stesso schema, con due controlli obbligatori: che il tag sia scrivibile e che il messaggio ci stia.

Future<void> writeTag({required String testo, required Uri url}) async {
  final message = NdefMessage([
    NdefRecord.createText(testo),
    NdefRecord.createUri(url),
  ]);

  await NfcManager.instance.startSession(
    pollingOptions: {NfcPollingOption.iso14443},
    alertMessage: 'Avvicina il tag da programmare',
    onDiscovered: (tag) async {
      final ndef = Ndef.from(tag);

      if (ndef == null || !ndef.isWritable) {
        await NfcManager.instance.stopSession(
          errorMessage: 'Tag non scrivibile',
        );
        return;
      }

      if (message.byteLength > ndef.maxSize) {
        await NfcManager.instance.stopSession(
          errorMessage: 'Messaggio troppo grande '
              '(${message.byteLength}/${ndef.maxSize} byte)',
        );
        return;
      }

      try {
        await ndef.write(message);
        await NfcManager.instance.stopSession(alertMessage: 'Tag scritto');
      } catch (e) {
        await NfcManager.instance.stopSession(
          errorMessage: 'Scrittura fallita: $e',
        );
      }
    },
  );
}

Se vuoi rendere il contenuto permanente esiste ndef.writeLock(): attenzione, l'operazione è irreversibile e trasforma il tag in sola lettura per sempre. Usala solo in produzione, mai durante i test.

Incapsulare tutto in un servizio

Lavorare direttamente con NfcManager dentro i widget porta rapidamente a codice fragile. Conviene isolare la logica in un servizio che espone Future e gestisce l'annullamento.

class NfcService {
  bool _sessionActive = false;

  Future<T> scan<T>(
    Future<T> Function(NfcTag tag) handler, {
    String alertMessage = 'Avvicina il tag',
    Duration timeout = const Duration(seconds: 30),
  }) async {
    if (_sessionActive) throw StateError('Sessione NFC già attiva');
    if (!await NfcManager.instance.isAvailable()) {
      throw const NfcUnavailableException();
    }

    final completer = Completer<T>();
    _sessionActive = true;

    await NfcManager.instance.startSession(
      pollingOptions: {NfcPollingOption.iso14443, NfcPollingOption.iso15693},
      alertMessage: alertMessage,
      onDiscovered: (tag) async {
        if (completer.isCompleted) return;
        try {
          final result = await handler(tag);
          completer.complete(result);
          await NfcManager.instance.stopSession(alertMessage: 'Fatto');
        } catch (e, s) {
          completer.completeError(e, s);
          await NfcManager.instance.stopSession(errorMessage: '$e');
        }
      },
    );

    try {
      return await completer.future.timeout(timeout);
    } finally {
      _sessionActive = false;
      await cancel();
    }
  }

  Future<void> cancel() async {
    try {
      await NfcManager.instance.stopSession();
    } catch (_) {
      // la sessione potrebbe essere già chiusa
    }
  }
}

class NfcUnavailableException implements Exception {
  const NfcUnavailableException();
  @override
  String toString() => 'NFC non disponibile';
}

Il servizio diventa facilmente testabile sostituendo NfcManager con un'astrazione, e nella UI il codice si riduce a:

final badge = await nfcService.scan((tag) async {
  final ndef = Ndef.from(tag);
  final records = ndef?.cachedMessage?.records ?? const [];
  return records.map(decodeTextRecord).whereType<String>().firstOrNull;
});

Collegare la sessione al ciclo di vita del widget

Una sessione aperta mentre l'utente abbandona la schermata è una fonte classica di bug, soprattutto su iOS dove il foglio modale resta visibile. Chiudila nel dispose e quando l'app va in background:

class ScanPage extends StatefulWidget {
  const ScanPage({super.key});
  @override
  State<ScanPage> createState() => _ScanPageState();
}

class _ScanPageState extends State<ScanPage> {
  final _nfc = NfcService();
  late final AppLifecycleListener _listener;

  @override
  void initState() {
    super.initState();
    _listener = AppLifecycleListener(
      onPause: () => _nfc.cancel(),
    );
  }

  @override
  void dispose() {
    _nfc.cancel();
    _listener.dispose();
    super.dispose();
  }

  // build...
}

Differenze di piattaforma da conoscere

Aspetto Android iOS
UI della scansione a carico dell'app foglio di sistema non personalizzabile (solo testo)
Durata sessione finché non la chiudi ~60 secondi, poi timeout
Lettura in background sì, via intent filter nel manifest solo tag NDEF con URL universal link, su device recenti
Emulazione carta (HCE) supportata non disponibile alle app di terze parti
Comandi proprietari ampio accesso (MIFARE, NfcA, IsoDep) limitato ai formati dichiarati nell'entitlement

Su Android puoi far aprire l'app quando l'utente appoggia un tag anche a app chiusa, dichiarando un intent filter:

<intent-filter>
  <action android:name="android.nfc.action.NDEF_DISCOVERED" />
  <category android:name="android.intent.category.DEFAULT" />
  <data android:scheme="https" android:host="esempio.it" android:pathPrefix="/tag" />
</intent-filter>

In questo caso il tag contiene un URL e il flusso si sovrappone a quello del deep linking: l'app riceve l'intent come un normale link e puoi gestirlo con il tuo router.

Oltre NDEF: comandi a basso livello

Quando devi dialogare con carte proprietarie (MIFARE Classic, DESFire, carte ISO 7816) NDEF non basta. nfc_manager espone classi specifiche come MifareUltralight.from(tag), NfcA.from(tag), IsoDep.from(tag) su Android e MiFare.from(tag) / Iso7816.from(tag) su iOS, con metodi per inviare APDU o comandi raw:

final isoDep = IsoDep.from(tag);
if (isoDep != null) {
  final response = await isoDep.transceive(
    data: Uint8List.fromList([0x00, 0xA4, 0x04, 0x00]),
  );
}

In questo scenario il codice diventa inevitabilmente specifico per piattaforma: conviene isolarlo dietro un'interfaccia e testarlo su hardware reale. Ricorda inoltre di aggiungere gli com.apple.developer.nfc.readersession.iso7816.select-identifiers nell'Info.plist per gli AID che vuoi selezionare su iOS.

Sicurezza: un tag non è una credenziale

Il contenuto NDEF di un tag è in chiaro e clonabile con qualunque smartphone. Non usare mai l'UID di un tag come unico fattore di autenticazione: gli UID possono essere emulati con tag "magic" o con dispositivi dedicati.

Linee guida pratiche:

  • fai firmare il payload lato server (HMAC) e verifica la firma nell'app o, meglio ancora, sul backend;
  • includi un contatore anti-replay (molti NTAG supportano la funzione SUN/mirror con contatore e CMAC);
  • tratta l'NFC come un identificatore, non come una prova di identità;
  • per scenari ad alta sicurezza usa carte con autenticazione crittografica (DESFire EV2/EV3).

Checklist finale

  • Verifica sempre isAvailable() e mostra un fallback (per esempio uno scanner QR) sui device senza NFC.
  • Chiudi la sessione in ogni percorso di codice, compresi gli errori e il dispose.
  • Imposta messaggi chiari in alertMessage: su iOS sono l'unica UI che l'utente vede.
  • Controlla isWritable e maxSize prima di scrivere; un NTAG213 offre solo ~137 byte utili.
  • Testa su hardware reale: emulatori e simulatori non supportano l'NFC.
  • Gestisci il caso "tag rimosso troppo presto": è l'errore più frequente in produzione, meglio invitare l'utente a tenere fermo il telefono.

Con poche decine di righe e un servizio ben incapsulato, l'NFC diventa una funzionalità solida e piacevole da usare, capace di eliminare digitazioni, codici e attriti dai flussi della tua app Flutter.