この記事を書こうと思ったきっかけ
最初、NFCタグにデータを書き込んだり読み取ったりしてみたいと思った時、何から手をつけて良いのかわかりませんでした。
公式ドキュメントを読んでも、NDEFやらタグ・ディスパッチシステムやらと用語が全く理解できず、「もう無理〜」という感想しか抱くことができませんでした。
そして、色々記事を探したり、サンプルコードを読み込んでも、ほとんどがActivityやFragmentを基準としたコードになっていて、Jetpack Composeで実装する場合はどのようにしたらよいのかを参考にできるソースを見つけることができませんでした。
そこで今回は、NFCタグに関する処理をJetpack Composeを通して行なってみたいけど、NFCもよくわからないし、JetpackComposeでやるとなると尚更よくわからないという方向けに、超絶やさしくわかりやすく、記事としてまとめておこうと思いたち、筆を取りました。
※NFCに関する規格などの詳細については解説を省きますのでご了承ください。あくまでJetpack ComposeでNFCタグにデータを読み書きする際に理解しておくべきことに絞って解説を行なっております。あらかじめご理解いただけると幸いです。
NFCの解説でよく出てくるNDEFデータって何?
NDEFとはNFC Data Exchange Formatの略で、NFCタグへの読み書きに使用される基本的なフォーマットのことです。そのフォーマットに従って作成されたデータのことをNDEFデータと呼んだりNDEFメッセージと呼んだりします。基本的にはNDEFメッセージと呼ぶことがほとんどです。
NDEFメッセージって中身はどうなってるの?
このNDEFメッセージは、ひとつ以上のNDEFレコードが連結してまとまっているものになっています。
NDEFレコードにはどんな種類のデータを格納できるの?
NDEFレコードに設定されるTNFとTYPEによって、NDEFレコードが持つペイロード(内容の本体)の意味が決定されます。
TNFとしては、次の値を設定することができます。
NdefRecord.TNF_ABSOLUTE_URI
NdefRecord.TNF_EMPTY
NdefRecord.TNF_EXTERNAL_TYPE
NdefRecord.TNF_MIME_MEDIA
NdefRecord.TNF_UNCHANGED
NdefRecord.TNF_UNKNOWN
NdefRecord.TNF_WELL_KNOWN
このうち、NdefRecord.TNF_MIME_MEDIA
を指定した場合はTYPEとして、お馴染みのMIMEタイプを指定します。
ここで、重要なのがNdefRecord.TNF_WELL_KNOWN
です。このNdefRecord.TNF_WELL_KNOWN
はNFC Forum well-known typeを表していて、TYPEに指定できる値としてNFC Forumで規定されたものしか使用することができません。
このNFC Forumで規定されたTYPEの値をRTD(Record Type Definition)と呼びます。
このRTDとは具体的に次の値のことを言います。
NdefRecord.RTD_ALTERNATIVE_CARRIER
NdefRecord.RTD_HANDOVER_CARRIER
NdefRecord.RTD_HANDOVER_REQUEST
NdefRecord.RTD_HANDOVER_SELECT
NdefRecord.RTD_SMART_POSTER
NdefRecord.RTD_TEXT
NdefRecord.RTD_URI
テキストレコードを作成したい場合は、NdefRecord.TNF_WELL_KNOWN
とNdefRecord.RTD_TEXT
を組み合わせて使用することがほとんどです。
NFC Forumって何?
ちなみにNFC Forumとは、2004年にソニーやフィリップス、ノキアによって設立されたNFCに関する標準化団体です。
NFCタグの種類とは?
このNFC ForumによってNFCタグの種類(仕様)が、1から最新の5まで規定されています。日本でお馴染みのFelicaはNFC Forum Type 3 Tagに属しています。Amazonなどで購入できる安価なNFCタグは主にNFC Forum Type 2 Tagに属しており、これはNXPセミコンダクターズ社のMIFARE Ultralightという商品がベースになって規定されています。
【例】NDEFレコードとしてテキストレコードを作成してみる
例えば、テキストレコードと呼ばれるテキストデータを持つNDEFレコードを作成したい場合は、TNFにNdefRecord.TNF_WELL_KNOWN
を、TYPEに、NdefRecord.RTD_TEXT
を設定します。
ちなみに、NdefRecord.RTD_TEXT
はtext/plain
MIMEタイプを表しています。
Kotlinでヘルパーメソッドを使って作成する場合は次のとおりです。
val textRecord = NdefRecord.createTextRecord(
"ja",
"こんにちわ、世界"
)
第一引数に言語コードを、第二引数にテキスト本体を設定します。
言語コードについては、次のサイトから確認することができます。
これをヘルパーメソッドを使わずに、手動でテキストレコードを作成しようとすると次のようになります。
fun createTextRecord(payload: String, locale: Locale, encodeInUtf8: Boolean): NdefRecord {
// 言語コード
val langBytes = locale.language.toByteArray(Charset.forName("US-ASCII"))
// テキスト
val utfEncoding = if (encodeInUtf8) Charset.forName("UTF-8") else Charset.forName("UTF-16")
val textBytes = payload.toByteArray(utfEncoding)
// UTF-8かUTF-16を表現するためのフラグ
val utfBit: Int = if (encodeInUtf8) 0 else 1 shl 7
// フラグと言語コードの長さを組み合わせ1バイトの情報
val status = (utfBit + langBytes.size).toChar()
// ペイロード全体を格納するためのバイト配列の準備
val data = ByteArray(1 + langBytes.size + textBytes.size)
// 先頭1バイトに、フラグと言語コードの長さを組み合わせた情報を格納
data[0] = status.toByte()
// 言語コードを格納
System.arraycopy(langBytes, 0, data, 1, langBytes.size)
// テキストを格納
System.arraycopy(textBytes, 0, data, 1 + langBytes.size, textBytes.size)
// NTFとRTDとペイロードをまとめたNDEFレコードを作成
return NdefRecord(NdefRecord.TNF_WELL_KNOWN, NdefRecord.RTD_TEXT, ByteArray(0), data)
}
テキストレコードの場合、次の順番でペイロードにデータが格納されています。
- UTF-8かUTF-16を表すビットのフラグと言語コードの長さを表す1バイトのデータ
- IANA言語コードを表す任意のバイト数のデータ
- テキスト本体を表す任意のバイト数のデータ
まず、ペイロードの先頭1バイトに、UTF-8かUTF-16を表すビットのフラグと指定される言語コードの長さが格納されます。
続いて、言語コードが格納されて、最後にテキストが格納されます。
このように手動でテキストレコードを作成しようとすると、テキストレコード自体がとても複雑なためバグを生み出しかねない実装を行なってしまうかもしれません。
そこで、公式でも推奨されているヘルパーメソッドを使うことで、とても簡単にテキストレコードを作成することができるようになっています。
【例】Media-typeをTNFに指定してでテキストレコードぽいものを作成する方法
NdefRecord.TNF_MIME_MEDIA
をTNFに設定して、TYPEとして"text/plain"
を指定することでテキストレコードぽいものを作ることもできます。
val mimeRecord = NdefRecord.createMime(
"text/plain",
"こんにちわ、世界".toByteArray(Charset.forName("UTF-8"))
)
NdefRecord.TNF_MIME_MEDIA
で書き込まれたペイロードを読み取る場合は、次のようになります。
intent.getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES)?.also { rawMsgs ->
(rawMsgs[0] as NdefMessage).apply {
// ByteなのでUTF-8エンコードの文字列に変換してあげる必要があります。
Log.d(TAG, "0: ${String(records[0].payload)}")
}
}
書き込む際にtoByTeArray()
をつかってByte
に変換してから書き込みを行なっているため、読み取る際はUTF-8エンコードの文字列に復元してあげる必要があります。
テキストレコードを作る場合はどっちのTNFとTYPEの組み合わせが良いの?
NdefRecord.TNF_WELL_KNOWN
とNdefRecord.RTD_TEXT
の組み合わせで表されるテキストレコードの場合は、言語コードを設定することができるため、ユーザーが選択した言語設定によって表示したり使用したりするテキストを切り替えられるといったメリットがあります。
一方、NdefRecord.TNF_MIME_MEDIA
と"text/plain"
でNDEFレコードを作成する場合には、言語コードを格納せずに書き込みたいテキストデータをバイト配列に変換したものを格納することが可能となります。
純粋なテキストレコードとは違い、単純な文字列データのみをByte
としてペイロードに格納することになるため、格納されたテキストの言語コードが必要な場合などにはNdefRecord.TNF_WELL_KNWON
とNdefRecord.RTD_TEXT
の組み合わせで作成したNDEFレコードを使用することをおすすめします。
AndroidManifestでNFCの使用を宣言する
NFCに関するAPIをアプリから使用するためには、次のパーミッションの宣言をAndroidManifest.xmlに追加する必要があります。
<uses-permission android:name="android.permission.NFC" />
そしてNFC機能を有している端末にのみ、アプリをインストールできるように制限をかけるには、次の宣言を追加します。
<uses-feature android:name="android.hardware.nfc" android:required="true" />
アプリがバックグラウンドの状態やフォアグラウンドの状態に関わらず、NFCタグを検知してインテントを受信する方法
アプリでNFCタグ由来のインテントを受信する設定を実装する方法として、AndroidManifest.xmlに<intent-filter />
を設定する方法とNfcAdapter
のインスタンスに対してenableForegroundDispatch()
を呼び出して設定する方法の2種類が存在します。
アプリがフォアグラウンド以外の状態の時にNFCタグを検知して、設定したインテントフィルターに引っかかったインテントを受け取ってデータを読み取ったり書き込んだりできるようにしたい場合には、AndroidManifest.xmlに<intent-filter />
を記述する方法を選択します。
タグ・ディスパッチシステム
NFCタグを検知した時に、応答可能なアクティビティを見つけるためにAndroidではタグ・ディスパッチシステムが使われています。
NFCタグがスキャンされると、タグ・ディスパッチシステムを使ってインテントが発行されます。この時、応答するアプリケーションが見つかるまで、優先度順に次の3つのインテントを発行します。
ACTION_NDEF_DISCOVERED
ACTION_TECH_DISCOVERED
ACTION_TAG_DISCOVERED
NDEF_DISCOVERED
NDEF_DISCOVERED
で発行されたインテントはとても優先順位が高いインテントで、NFCタグ検出時にNDEFレコードが見つかった場合に1番最初に発行されます。
この場合のインテントフィルターは次のように記述します。
<intent-filter>
<action android:name="android.nfc.action.NDEF_DISCOVERED"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="text/plain" />
</intent-filter>
この<intent-filter />
に設定されている内容は、NDEFフォーマットのNFCタグが検知され、NDEF_DISCOVERED
のインテントが発行され、そのNDEFメッセージ内にテキストレコードが含まれているインテントだった場合に応答するといったものになっています。
ACTION_TECH_DISCOVERED
上記で設定したインテントフィルターはテキストレコードを含むNFCタグだった場合に有効なのですが、テキストレコードではないNDEFレコードで構成されたNDEFメッセージだった場合や、何も書き込まれていないNFCタグだった場合などには反応しないフィルターになってしまいます。
その場合には、android.nfc.action.TECH_DISCOVERED
を指定してフィルタリングを行うことができます。
<intent-filter>
<action android:name="android.nfc.action.TECH_DISCOVERED" />
</intent-filter>
<meta-data android:name="android.nfc.action.TECH_DISCOVERED"
android:resource="@xml/nfc_tech_list" />
TECH_DISCOVERED
を指定した場合には、スキャンされるタグがサポートしているテクノロジーを<tech-list />
を使ってまとめたXMLリソースファイルを<meta-data />
を使って追記する必要があります。
このXMLリソースファイルは、プロジェクトルート/res/xml
ディレクトリに作成します。ファイル名に制限はないため自由に指定することができます。ここではnfc_tech_list.xmlを使用しています。
記述方法としては、次の注意が必要です。
検知されたNFCタグがNfcA
とNfcB
とNfcF
の全てのテクノロジーをサポートしている場合にインテントを受け取りたい時にはひとつの<tect-list />
内に全ての<tech />
を記述します。
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<tech-list>
<tech>android.nfc.tech.NfcA</tech>
<tech>android.nfc.tech.NfcB</tech>
<tech>android.nfc.tech.NfcF</tech>
</tech-list>
</resources>
もし、NfcA
、NfcB
、NfcF
のいずれかのテクノロジーを1つでもサポートしている場合にインテントを受け取りたい時には、複数の<tech-list />
を用意して、それぞれに<tech />
を記述します。
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<tech-list>
<tech>android.nfc.tech.NfcA</tech>
</tech-list>
<tech-list>
<tech>android.nfc.tech.NfcB</tech>
</tech-list>
<tech-list>
<tech>android.nfc.tech.NfcF</tech>
</tech-list>
</resources>
ACTION_TAG_DISCOVERED
上記で設定したTECH_DISCOVERED
のインテントフィルターにも引っかからない場合でも、フォールバックとしてインテントを受け取りたい場合には、ACTION_TAG_DISCOVERED
を使用します。
<intent-filter>
<action android:name="android.nfc.action.TAG_DISCOVERED"/>
</intent-filter>
【フォアグラウンド・ディスパッチシステム】特定のコンポーザブルが前面に出ている状態の時のみ、インテントフィルターを設定する
NFCタグへの書き込みのように、アプリがフォアグラウンドの状態で、かつ書き込み用のスクリーンを担うコンポーザブルが表示されている間のみNFCタグ用のインテントフィルターを動的に有効化して、NFCタグを検知できるようにしたい場合は、NfcAdapterインスタンスのenableForegroundDispatch()
を呼び出してフォアグラウンド・ディスパッチシステムを有効化します。
この時に設定するインテントフィルターは、上記のAndroidManifest.xmlの内容をKotlinコードで表現したものになります。
フォアグラウンド・ディスパッチシステムから発行されるインテントの優先順位はタグ・ディスパッチシステムのそれと変わりません。
この場合のようにアプリがバックグラウンドの状態に時にNFCタグに対して特定の処理を行いたくない場合は、AndroidManifest.xmlにてインテントフィルターの設定を行う必要はありません。コンポーザブル内のコードによって動的にインテントフィルターを設定するだけで大丈夫です。
【例】ACTION_TECH_DISCOVEREDのインテントで、かつ検知したNFCタグがNFC-Aをサポートしている時のみインテントフィルターを設定したい場合
NFCタグに書き込みを行いたい場合などは、最初からNDEFレコードが書き込まれていない新規のNFCタグの場合などもあるため、次のようにNFC-Aをサポートしているかどうかでインテントフィルターを設定すれば良いと思います。
※詳しい実装につきましては、プロジェクトの要件やセキュリティ条件に合わせて適宜修正を行なってください。
val context = LocalContext.current
val nfcAdapter by lazy {
NfcAdapter.getDefaultAdapter(context)
}
...
val tag = IntentFilter(NfcAdapter.ACTION_TECH_DISCOVERED)
val pendingIntent = PendingIntent.getActivity(
context,
0,
Intent(context, context.javaClass).addFlags(
Intent.FLAG_ACTIVITY_SINGLE_TOP
),
PendingIntent.FLAG_IMMUTABLE
)
val techLists = arrayOf(
arrayOf(NfcA::class.java.name)
)
nfcAdapter?.enableForegroundDispatch(
context,
pendingIntent,
arrayOf(tag),
techLists,
)
【例】ACTION_NDEF_DISCOVEREDのインテントで、かつ検知したNFCタグにテキストレコードが含まれている時のみインテントフィルターを設定したい場合
NFCタグをの読み込みを行いたい場合などは、NFCタグにテキストレコードが書き込まれている必要があるため、それを前提条件としてインテントフィルターを設定しても良いと思います。
※詳しい実装につきましては、プロジェクトの要件やセキュリティ条件に合わせて適宜修正を行なってください。
val context = LocalContext.current
val nfcAdapter by lazy {
NfcAdapter.getDefaultAdapter(context)
}
...
val ndef = IntentFilter(NfcAdapter.ACTION_NDEF_DISCOVERED)
val pendingIntent = PendingIntent.getActivity(
context,
0,
Intent(context, context.javaClass).addFlags(
Intent.FLAG_ACTIVITY_SINGLE_TOP
),
PendingIntent.FLAG_MUTABLE
)
try {
ndef.addDataType("text/plain")
} catch (ex: Exception) {
ex.printStackTrace()
}
nfcAdapter?.enableForegroundDispatch(
context,
pendingIntent,
arrayOf(ndef),
null
)
ACTION_NDEF_DISCOVERED
を検知したいので、enableForegroundDispatch()
の第四引数として、nullを指定しています。ACTION_TECH_DISCOVERED
の場合はここにtechLists
が指定されます。
NFCタグを検知するたびに何度もアクティビティを起動させない方法
アプリがフォアグラウンドの状態でNFCタグをかざすと、何度もMainActivityが起動してしまいます。これを防ぎたい場合は、android:launchMode
にsingleTask
を設定することをおすすめします。
<activity
android:name=".ui.MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.NFCApp"
android:launchMode="singleTask"
>
...
</avtivity>
【実践】Jetpack ComposeでテキストレコードをNFCタグに書き込む
Androidの公式ドキュメントに掲載されているサンプルコードを見ると、Activity
内でonResume()
をオーバーライドし、アクティビティがNFCタグのスキャンによって起動されたかを確認した上でNFCタグの読み取りを行う処理になっています。
このようにNFCタグの検知というのは、アクティビティのライフサイクルにとても依存してしまいます。
ただし今回は、Jetpack Composeを使ってNFCタグに関する処理の全てを完結させたいので、Activity内にライフサイクルに関する処理の記述はonCreate()
以外行わないという前提で話を進めます。
そこで、コンポーザブル内でアクティビティのライフサイクルに沿って処理を実行するために、LifecycleEventObserver()
を使用します。
最終的なコードは次のようになります。
@Composable
fun ReadNFCRoute(
modifier: Modifier = Modifier,
viewModel: ReadNFCViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsState()
val lifecycleOwner = LocalLifecycleOwner.current
val context = LocalContext.current
val activity = (context as ComponentActivity)
val nfcAdapter by lazy {
NfcAdapter.getDefaultAdapter(context)
}
DisposableEffect(lifecycleOwner) {
val onNewIntentListener = Consumer<Intent> { intent ->
when(intent.action) {
NfcAdapter.ACTION_NDEF_DISCOVERED -> {
viewModel.processIntent(intent)
}
NfcAdapter.ACTION_TAG_DISCOVERED -> {
viewModel.processIntent(intent)
}
NfcAdapter.ACTION_TECH_DISCOVERED -> {
viewModel.processIntent(intent)
}
}
}
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_CREATE -> {
activity.addOnNewIntentListener(onNewIntentListener)
}
Lifecycle.Event.ON_PAUSE -> {
nfcAdapter?.disableForegroundDispatch(context)
}
Lifecycle.Event.ON_RESUME -> {
val ndef = IntentFilter(NfcAdapter.ACTION_NDEF_DISCOVERED)
val pendingIntent = PendingIntent.getActivity(
context,
0,
Intent(context, context.javaClass).addFlags(
Intent.FLAG_ACTIVITY_SINGLE_TOP
),
PendingIntent.FLAG_MUTABLE
)
try {
ndef.addDataType("text/plain")
} catch (ex: Exception) {
ex.printStackTrace()
}
nfcAdapter?.enableForegroundDispatch(
context,
pendingIntent,
arrayOf(ndef),
null
)
}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
activity.removeOnNewIntentListener(onNewIntentListener)
}
}
ReadNFCScreen(
modifier = modifier,
readText = uiState.text
)
}
LifecycleEventObserver()
はDisposableEffect()
内にて呼び出します。コンポーザブルがコンポジションから外れるときに作成したobserver
を解除するために、onDispose()
内でlifecycleOwner.lifecycle.removeObserver(observer)
を呼び出しています。
NFCAdapterは、端末のNFCモジュールに接続して、NFCに関する処理を行うためのクラスです。コンポーザブルでインスタンスを取得したいときは、次のようにします。
val context = LocalContext.current
val nfcAdapter by lazy {
NfcAdapter.getDefaultAdapter(context)
}
NFCAdapterクラスのインスタンスを取得している理由は、フォアグラウンド・ディスパッチシステムをコードにて有効化させるためです。
そして、取得したnfcAdapter
を使って、このコンポーザブルが動いているアクティビティにて、NFCタグの検知を行えるようにするために、Lifecycle.Event.ON_RESUME
の時にnfcAdapter?.enableForegroundDispatch()
を呼び出しています。
アプリがバックグラウンドに入った時に、フォアグラウンド・ディスパッチを無効化させるために、Lifecycle.Event.ON_PAUSE
の時にnfcAdapter?.disableForegroundDispatch()
を呼び出しています。
ここで、一番重要なのが、Lifecycle.Event.ON_CREATE
の時に呼び出しているactivity.addOnNewIntentListener()
です。これは、本来ならアクティビティにてオーバーライドされるonNewIntent()
をコンポーザブルにて設定するために(context as ComponentActivity)
に対してaddOnNewIntentListener()
を呼び出しています。
そして、onDispose()
で、removeOnNewIntentListener()
を呼び出してリスナーの登録を解除しています。
ここではonNewIntentListener()
をDispoableEffect()
のラムダ内で定義している点に注目してください。最初、ViewModelにリスナーを実装して次のように記述していました。
activity.addOnNewIntentListener(viewModel::onNewIntentListener)
ところがこれだと、次のように解除しようとしても解除されず、再度呼び出されるたびに何度もリスナーが登録されてしまうという問題が発生した。
activity.removeOnNewIntentListener(viewModel::onNewIntentListener)
なので、この問題を回避するためにリスナーのみをラムダで定義して、処理の本体はViewModelに定義する方法を採用しています。
実際にNFCタグへの書き込みを担うViewModelの実装は次のようになります。
@HiltViewModel
class WriteTextViewModel @Inject constructor() : ViewModel() {
fun processIntent(intent: Intent) {
try {
val tag: Tag? = intent.getParcelableExtra(NfcAdapter.EXTRA_TAG)
val ndef = Ndef.get(tag)
if (ndef.isWritable) {
val textRecord = NdefRecord.createTextRecord(
"ja",
"こんにちわ、世界。"
)
val message = NdefMessage(
arrayOf(
textRecord,
NdefRecord.createApplicationRecord("com.takagimeow.nfcapp")
)
)
// この時カードと端末が離れてしまうと例外が発生してアプリがクラッシュするので注意が必要です
ndef.connect()
ndef.writeNdefMessage(message)
ndef.close()
}
} catch (ex: Exception) {
ex.printStackTrace()
}
}
}
intent.getParcelableExtra(NfcAdapter.EXTRA_TAG)
を呼び出すことで、書き込むNFCタグのインスタンスを取得しています。そして、取得したインスタンスをNdef.get()
に渡して、Ndefのインスタンスを取得しています。
実際に、NDEFメッセージを書き込む際は、connect()
を呼び出してNFCタグに接続します。そして接続が完了した後にwriteNdefMessage()
を呼び出して書き込みを行い、最後にclose()
を呼び出して接続を閉じます。
AARをNDEFメッセージの最後に追加して、NFCタグを検知してアプリを起動するように設定する
Ndefメッセージを作成する際に、最後の要素としてAndroid Appliction Recordのインスタンスを追加していることに注目してください。
val message = NdefMessage(
arrayOf(
textRecord,
NdefRecord.createApplicationRecord("com.takagimeow.nfcapp")
)
)
AARレコードを使うことで、NFCタグの検知時にAARレコードに設定したパッケージ名のアプリがインストールされている場合にアプリを起動させたり、Google Play Storeのアプリのページを表示させたりすることができます。
具体的に言うと、次のような順序で処理が行われます。
- NDEFレコードとAARレコードで構成されたNDEFメッセージが書き込まれたNFCタグを検知する
- 検知したNDEFレコードを拾うことができるインテントフィルターが設定されたActivityがAndroidManifest.xmlで設定されていれば、アプリを起動後にそのアクティビティを開く
- このNDEFレコードを取り扱えるインテントフィルターが設定されたActivityが存在しない場合は、このアプリのMainActivityを開く
注意点として、フォアグラウンド・ディスパッチシステムが有効状態で、AARを含んだNFCタグを検知した場合は、タグ・ディスパッチシステムではなく、フォアグラウンド・ディスパッチシステムが優先されて処理のフローが実行される点に注意してください。つまりこの場合のAndroidManifest.xmlでのインテントフィルターの設定は、無視される可能性があります。
【実践】Jetpack ComposeでNFCタグからテキストレコードを読み取る
コンポーザブルが実行されている間のみ、NFCタグを検知してインテントを処理できるようにするため、AndroidManifest.xmlではインテントフィルターの設定を行わず、enableForegroundDispatch()
を呼び出してフォアグラウンド・ディスパッチシステムを有効化させることでインテントを処理できるようにしようと思います。
基本的な実装方法は、上記で説明した内容とほとんど変化はありません。具体的な実装は次のようになります。
@Composable
fun ReadNFCRoute(
modifier: Modifier = Modifier,
viewModel: ReadNFCViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsState()
val lifecycleOwner = LocalLifecycleOwner.current
val context = LocalContext.current
val activity = (context as ComponentActivity)
val nfcAdapter by lazy {
NfcAdapter.getDefaultAdapter(context)
}
DisposableEffect(lifecycleOwner) {
val onNewIntentListener = Consumer<Intent> { intent ->
when(intent.action) {
NfcAdapter.ACTION_NDEF_DISCOVERED -> {
viewModel.processIntent(intent)
}
NfcAdapter.ACTION_TAG_DISCOVERED -> {
viewModel.processIntent(intent)
}
NfcAdapter.ACTION_TECH_DISCOVERED -> {
viewModel.processIntent(intent)
}
}
}
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_CREATE -> {
activity.addOnNewIntentListener(onNewIntentListener)
}
Lifecycle.Event.ON_PAUSE -> {
nfcAdapter?.disableForegroundDispatch(context)
}
Lifecycle.Event.ON_RESUME -> {
val ndef = IntentFilter(NfcAdapter.ACTION_NDEF_DISCOVERED)
val pendingIntent = PendingIntent.getActivity(
context,
0,
Intent(context, context.javaClass).addFlags(
Intent.FLAG_ACTIVITY_SINGLE_TOP
),
PendingIntent.FLAG_MUTABLE
)
try {
ndef.addDataType("text/plain")
} catch (ex: Exception) {
ex.printStackTrace()
}
nfcAdapter?.enableForegroundDispatch(
context,
pendingIntent,
arrayOf(ndef),
null
)
}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
activity.removeOnNewIntentListener(onNewIntentListener)
}
}
ReadNFCScreen(
modifier = modifier,
readText = uiState.text
)
}
大きな違いとしては、インテントフィルターの設定部分です。
val ndef = IntentFilter(NfcAdapter.ACTION_NDEF_DISCOVERED)
...
try {
ndef.addDataType("text/plain")
} catch (ex: Exception) {
ex.printStackTrace()
}
NFCタグのNDEFデータを読み取りたい場合は、NFCタグにNDEFフォーマットのテキストレコードが保存されていることを前提にしたいため、NfcAdapter.ACTION_NDEF_DISCOVERED
を設定し、addDataType("text/plain")
を呼び出してテキストレコードのNDEFレコードをフィルタリングしたい旨を指定しています。
実際にNFCタグの読み取りを担うViewModelの実装は次のようになります。
data class ReadNFCUiState(
val text: String = ""
)
@HiltViewModel
class ReadNFCViewModel @Inject constructor(
) : ViewModel() {
private val _uiState = MutableStateFlow(
ReadNFCUiState()
)
val uiState: StateFlow<ReadNFCUiState> = _uiState.asStateFlow()
fun processIntent(intent: Intent) {
intent.getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES)?.also { rawMsgs ->
(rawMsgs[0] as NdefMessage).apply {
viewModelScope.launch {
_uiState.update {
it.copy(
text = String(records[0].payload).drop(3)
)
}
}
}
}
}
}
NDEFフォーマットのタグを検知したことによって発行されたインテントには、EXTRA_NDEF_MESSAGES
というエクストラが含まれていて、このエクストラからNDEFメッセージを取得することができます。
intent.getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES)
このメソッドを呼び出すことで、NDEFメッセージの配列を取得することができます。NDEFメッセージを1つだけしか書き込んでいない場合は(rawMsgs[0] as NdefMessage)
を通してNDEFメッセージを取得することができます。
上記で説明した内容の通りにNDEFメッセージを書き込んだ場合、records[0].payload
にテキストレコードが含まれていて、records[1].payload
にAARが含まれています。
drop(3)
を呼び出して最初の3バイトを除いた値を取得している理由は、テキストの本体部分をペイロードから取り出すためです。
PendingIntentのインスタンスを取得するときに、flagsとしてPendingIntent.FLAG_MUTABLEを指定する
フォアグラウンド・ディスパッチシステムを有効化する際に、enableForegroundDispatch()
を呼び出しますが、このメソッドの第二引数にはPendingIntent
のインスタンスを指定します。
この記事では、インスタンスを取得する際にPendingIntent.getActivity()
を呼び出していますが、このメソッドの第四引数にはflags
を指定する必要があります。
この時、flags
としてPendingIntent.FLAG_IMMUTABLE
を指定してしまうと、onNewIntentListener()
が呼び出されない可能性があります。
必ずPendingIntent.FLAG_MUTABLE
を指定して登録されたリスナーが呼び出されるようにしてください。
val context = LocalContext.current
...
val pendingIntent = PendingIntent.getActivity(
context,
0,
Intent(context, context.javaClass).addFlags(
Intent.FLAG_ACTIVITY_SINGLE_TOP
),
PendingIntent.FLAG_MUTABLE
)
まとめ
一見すると難しそうと思われがちなNFCですが、ちょっと理解をし始めれば普通のエンジニアの方ならすぐにでも取り扱いができるようになるくらい、Android側のライブラリや機能が充実しています。
ただ、最近ではNFCタグを使ったアプリの開発などの勢いはAndroid4.0でで登場したAndroid Beamを境に、勢いがやや落ち着いた印象があります。代わりに二次元コードを利用したアプリの開発は近年活発になっているイメージがあるのではないでしょうか?。
NFCタグはAmazonなどでも安価で手に入れることができますし、使い方によっては無数の可能性を秘めたアプリを開発することも夢ではないと信じています。
AndroidのNFCに関する書籍としては、名著であるHFC HACKS プロが教えるテクニック&ツールも出版が2013年なので、もうすぐ10年を迎えようとしています。しかし、この書籍で使用されているNFCに関するAPIやライブラリは、今でも色褪せず、上記で説明したようにJetpack Composeと組み合わせて使用することができます。
かの有名な横井軍平が残した言葉である「枯れた技術の水平思考」ではないですが、このNFCを使った魅力的なAndroidアプリがこの2020年代にも多数登場することを心から願っています。
この記事を読んで、NFCタグとAndroidを組み合わせたアプリの開発に少しでも興味をいだいてくれたら嬉しいです。
参考にした記事