【Android】別アプリのAARを回避して、起動中のアプリでNFCのインテントを処理する方法

# はじめに

この記事は、Android端末がNFCを読み取った際にAndroid Application Record、通称AARが記録されていれば、その値に基づいて紐づくアプリを起動またはGoogle Play Storeに移動することを防ぐための方法をまとめたものです。

経緯として、NFCの読み取りと書き込みを行うアプリを開発していたところから話は始まります。
ビルドバリアントごとにパッケージ名を変更するよう**build.gradle**で設定していたため、ビルドバリアントを変更した際に、別のパッケージ名でAARを書き込んだNFCを読み取るとそのアプリをインストールするためにGoogle Play Storeを開いてしまうという問題に直面していました。

LogCatを確認すると、次のようなインテントが発行されていました。

```
ActivityTaskManager system_server I  START u0 {act=android.intent.action.VIEW dat=market://details?id=com.takagimeow.exampleapp cmp=com.android.vending/com.google.android.finsky.activities.MarketDeepLinkHandlerActivity} from uid 1027
ActivityTaskManager system_server I  START u0 {act=android.intent.action.VIEW dat=http://market.android.com/... cmp=com.android.vending/com.google.android.finsky.activities.MainActivity (has extras)} from uid 10172
```

AndroidManifestに次のIntent Filterを設定することで、Google Play Storeが強制的に開かれてしまうのを防ごうとしました。

```xml
<intent-filter>
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <data
        android:scheme="market"
        />
</intent-filter>
```

もちろん、アプリの起動中にNFCを読み取ると、どのアプリで開くかのダイアログが表示されるようになりました。
しかし、僕が求めていたのはこのような処理ではありません。

NFC ToolsやSuica Readerなどのアプリでは、NDEFレコードの末端にAARが保存されていたとしても、紐づく動作をするのではなくNFCに記録されているレコードの読み取りと書き込みをスムーズに行うことができます。

https://play.google.com/store/apps/details?id=com.wakdev.wdnfc&hl=ja&gl=US

https://play.google.com/store/apps/details?id=yanzm.products.suicareader&hl=ja&gl=US

同じような動作を実現するためにはどのようにすればよいか、調べ尽くしましたが良い回答を得ることはできませんでした。
むしろAARは非常に強力な機能なため、回避することを考えることすらおかしいとまで思うようになってしまいました。

https://stuff.mit.edu/afs/sipb/project/android/docs/guide/topics/connectivity/nfc/nfc.html#ext-type

# アクティビティを使うというひとつの仮説

実は、僕が開発していたアプリではJetpack Composeを採用していました。
なので、できるだけアクティビティ依存の実装は避け、Jetpack Composeで提供されている機能を積極的に採用することでAndroidアプリのモダンな実装を目指していました。

そのため、NFCを読み取る際には、アクティビティの`onResume()`をオーバーライドすることはせず、`LifecycleEventObserver()`にコールバックを登録することで、ライフサイクルの監視を行いNFCへの読み書きを行う実装をしていました。

詳しい実装方法については、以前投稿したこちらの記事をご覧ください。

https://qiita.com/takagimeow/items/48b37c55ad8d73d5da88

しかし、上記の実装方法のとおり、`addOnNewIntentListener()`を使って実装したリスナーでは、`onNewIntent()`が呼び出される前に`android:scheme="market"`のインテントが発行されてしまうためにGoogle Play Storeが開かれてしまうという問題が生じていることがわかりました。

そこで、僕はあるひとつの仮説を立てました。NFCを読み取った際の処理方法を参考にしていた上記の２つのアプリはJetpack Composeが浸透する前からリリースされているアプリでした。

おそらくこの二つのアプリでは、`addOnNewIntentListener()`などは使わずにActivityにて`enableForegroundDispatch()`を呼び出し、`onNewIntent()`をオーバーライドすることでGoogle Play Storeが開かれるよりも前に起動中のアプリでNFCへの操作を行って至るのではないかと考えました。

# rememberLauncherForActivityResult()を使ってNFC専用のアクティビティを起動させる

そこで、NFC読み取り専用のコンポーザブルを呼び出すのをやめて、代わりに`rememberLauncherForActivityResult()`を使ってNFC専用のアクティビティを起動して目的の処理をそこで行うことに決めました。

まず、コンポーザブル側で`rememberLauncherForActivityResult()`を呼び出します。何かのハンドラの結果として`launch()`を呼び出します。

```kotlin

@Composable
ExampleRoute() {
    val context = LocalContext.current
    
    val launcher = rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) { result ->
        when (result.resultCode) {
            Activity.RESULT_OK -> {
                val intent = result.data
                val message = intent?.getStringExtra("message")
                Log.d(TAG, "message : $message")
            }
        }
    }
    
    ...
    
    ExampleScreen (
        onNavigaToNfcClick = {
           launcher.launch(Intent(context, NfcActivity::class.java))
        },
    )
}
```

`context.startActivity(Intent(context, NfcActivity::class.java))`を使っても同じようにアクティビティを起動させることは可能ですが、呼び出されたアクティビティ側から呼び出し元にデータを返してみたかったので、今回は`rememberLauncherForActivityResult()`を使用しました。

続いて、アクティビティを実装します。

```kotlin
class NfcActivity : ComponentActivity() {
    private lateinit var pendingIntent: PendingIntent
    private lateinit var nfcAdapter: NfcAdapter

    private lateinit var viewModel: ExampleViewModel

    @OptIn(ExperimentalMaterial3Api::class)
    @RequiresApi(Build.VERSION_CODES.P)
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        nfcAdapter = NfcAdapter.getDefaultAdapter(this);

        pendingIntent = PendingIntent.getActivity(
            this, 0,
            Intent(this, javaClass)
                .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
            PendingIntent.FLAG_MUTABLE
        );

        setContent {
            viewModel = hiltViewModel()
            val uiState by viewModel.uiState.collectAsState()

            ExampleAppTheme {
                ExampleAppBackground {
                    ExampleNfcScreen(
                        loading = uiState.loading,
                        completed = uiState.completed,
                        showError = uiState.showError,
                        onUpPress = {
                            finish()
                        },
                        onComplete = {
                            val intent = Intent()
                            intent.putExtra("message", "done")
                            setResult(Activity.RESULT_OK, intent)
                            finish()
                        },
                        onDismissErrorDialog = {
                            viewModel.resetShowError()
                        }
                    )
                }
            }
        }
    }

    override fun onResume() {
        super.onResume()
        val ndef = IntentFilter(NfcAdapter.ACTION_NDEF_DISCOVERED)
        val tech = IntentFilter(NfcAdapter.ACTION_TECH_DISCOVERED)
        val tag = IntentFilter(NfcAdapter.ACTION_TAG_DISCOVERED)
        try {
            ndef.addDataType("text/plain")
        } catch (ex: Exception) {
            ex.printStackTrace()
        }
        val intentFilters = arrayOf(ndef, tech, tag)
        val techListsArray = arrayOf(
            arrayOf(Ndef::class.java.name),
            arrayOf(NdefFormatable::class.java.name),
            arrayOf(NfcA::class.java.name),
            arrayOf(NfcF::class.java.name)
        )

        nfcAdapter.enableForegroundDispatch(
            this,
            pendingIntent,
            intentFilters,
            techListsArray
        )
    }

    override fun onNewIntent(intent: Intent?) {
        super.onNewIntent(intent)
        setIntent(intent)
        if (intent != null && viewModel != null) {
            viewModel.processIntent(intent)
        }
    }

    override fun onPause() {
        super.onPause()
        nfcAdapter?.disableForegroundDispatch(this)
    }
}
```

UIを実装するためにJetpack Composeを引き続き使用しています。
コンポーザブルに`onComplete()`を実装することで、操作の結果によって元のアクティビティに戻る場合に`putExtra()`を使ってIntentにデータを設定して`finish()`を呼び出せるようになるため、元のアクティビティ側でデータを受け取ることが可能となります。ここでは、正しくアクティビティが終了したことを`message`キーへの値として設定しています。

**AndroidManifest.xml**にも`<activity />`を追加しておきましょう。
```xml
<activity
    android:name=".ui.NfcActivity"
    android:exported="true"
    android:label="@string/app_name"
    android:theme="@style/Theme.ExampleAppTheme"
    android:launchMode="singleTask"
>
</activity>
```

`enableForegroundDispatch()`を呼び出しているため、次のようなインテントフィルターは設定していません。

```xml
<intent-filter>
    <action android:name="android.nfc.action.NDEF_DISCOVERED"/>
    <category android:name="android.intent.category.DEFAULT"/>
</intent-filter>
<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" />
```

# アクティビティでの実装に切り替えた結果

そして、僕の仮説通り、コンポーザブルではなくアクティビティにて必要な設定を行うことで、例えAARがNDEFメッセージに含まれていたとしても問題なく起動中のアプリで処理することができるようになりました。

# AppCompatActivityではなく、ComponentActivityを使う理由

`Activity`の定義では、`AppCompatActivity`のサブクラスではなく`CompatActivity`のサブクラスとしてアクティビティのクラスを定義しています。

理由としては、アプリ開発の参考にしていたJetNewsにて`CompatActivity`を使っていたからという理由もあるのですが、具体的にいうと次のエラーが発生してアプリが強制終了してしまうという理由が一番大きいです。

```
java.lang.RuntimeException: Unable to start activity ComponentInfo{com.takagimeow.exampleapp.debug/com.takagimeow.exampleapp.ui.NfcActivity}: java.lang.IllegalStateException: You need to use a Theme.AppCompat theme (or descendant) with this activity.
```

この`java.lang.NullPointerException: getDefaultAdapter(this) must not be null`エラーを回避するためには、`ComponentActivity`のサブクラスとしてクラスを定義します。

ぜひ同様の問題が発生した場合は、ご参考までにクラス定義をご確認ください。

https://github.com/android/compose-samples/tree/master/JetNews

# シミュレーターではご注意を

シミュレーターを使用しているときに、このアクティビティを開くと次のエラーが発生する可能性があります。

```
java.lang.RuntimeException: Unable to start activity ComponentInfo{com.takagimeow.exampleapp/com.takagimeow.exampleapp.ui.NfcActivity}: java.lang.NullPointerException: getDefaultAdapter(this) must not be null 
```

原因は`getDefaultAdapter(this)`です。これを回避するために、必ず実機を繋いでからデバッグを行うことをおすすめします。

# まとめ

この問題を解決するために、実は6時間以上もかかってしまいました。
問題解決に取り組んでいる最中、ここまで時間がかかるくらいならいい加減諦めて別の手段を考えようとも思いました。
しかし結果的にアクティビティについても多少詳しくなり、NFCに関する知見も深めることができました。

あのとき諦めずにこの問題に取り組み続けて良かったと思います。

もし僕と同じような問題に悩まされていて、この記事にたどり着いた方の数時間を節約することができたなら、あの時間は無駄ではなかったと胸を張ることができるでしょう。


# 参考にした記事

https://stackoverflow.com/questions/46171741/possible-to-prevent-app-launch-by-nfc-intent-from-within-an-app

https://shunxnegi.hateblo.jp/entry/2014/11/09/030734

https://stuff.mit.edu/afs/sipb/project/android/docs/guide/topics/connectivity/nfc/nfc.html#ext-type

https://ja.stackoverflow.com/questions/36995/android-nfc%E8%AA%AD%E3%81%BF%E5%8F%96%E3%82%8A%E5%BE%8C%E3%81%AB%E3%82%A2%E3%83%97%E3%83%AA%E8%B5%B7%E5%8B%95-ble%E6%8E%A5%E7%B6%9A%E3%81%99%E3%82%8B%E6%96%B9%E6%B3%95

http://y-anz-m.blogspot.com/2013/10/

http://y-anz-m.blogspot.com/2013/10/aarandroid-application-record.html

https://developer.android.com/guide/topics/connectivity/nfc/nfc?hl=ja#tag-disc

https://developer.android.com/guide/topics/connectivity/nfc/advanced-nfc

https://android.stackexchange.com/questions/91592/avoid-opening-play-store-after-nfc-scan-for-unlocking-with-smart-lock

https://www.talentica.com/blogs/nfc-in-android-an-introduction/

https://sites.google.com/site/hiro99ma/nfc/experiment/android-nfc/create_project1

https://stackoverflow.com/questions/25504418/get-nfc-tag-with-ndef-android-application-record-aar

http://enjoy-rfid.blogspot.com/2013/07/aar.html

https://stackoverflow.com/questions/26395894/how-can-i-get-the-multiple-nfc-record-after-launching-the-app-by-aar

https://stackoverflow.com/questions/11406804/2-ndef-messages-records-on-one-nfc-tag-android

https://stackoverflow.com/questions/61500910/enforce-nfc-dispatch-event-disable-and-re-enable-nfc-system-capabilities

https://qiita.com/YasuakiNakazawa/items/a509acee1f65272bc933

https://github.com/y-yagi/NFCSample/blob/master/app/src/main/java/com/example/yaginuma/nfcsample/MyActivity.java

https://stackoverflow.com/questions/64920307/how-to-write-ndef-records-to-nfc-tag/64921434#64921434

https://stackoverflow.com/questions/65023008/is-there-any-way-to-stop-my-android-app-from-scanning-nfc-tags-in-the-background

https://stackoverflow.com/questions/31996230/android-nfc-aars-without-google-play-store

https://github.com/BlueFletch/android-nfc-demo/blob/master/app/src/main/java/com/bluefletch/nfcdemo/MainActivity.java

https://www.andreasjakl.com/nfc-tags-ndef-and-android-with-kotlin/

https://stackoverflow.com/questions/18743246/nfc-tag-why-use-aar-and-launchapp-rather-than-url-intent-with-android

https://kanetai.hatenablog.com/entry/20131122/1385119482

https://stackoverflow.com/questions/28837963/android-nfc-intent-filter-for-all-type

https://stackoverflow.com/questions/25814376/trying-to-ignore-all-nfc-intents-while-in-foreground-with-enableforegrounddispat

---

https://developer.android.com/training/basics/intents/result

https://akira-watson.com/android/activity-2.html#4

https://android.roof-balcony.com/intent/putextra/

https://stackoverflow.com/questions/65088035/how-to-navigate-from-a-composable-to-an-activity-or-a-fragment-in-jetpack-compos

https://buchi.hatenablog.com/entry/2014/11/25/183142