はじめに
この記事は、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が強制的に開かれてしまうのを防ごうとしました。
<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に記録されているレコードの読み取りと書き込みをスムーズに行うことができます。
同じような動作を実現するためにはどのようにすればよいか、調べ尽くしましたが良い回答を得ることはできませんでした。
むしろAARは非常に強力な機能なため、回避することを考えることすらおかしいとまで思うようになってしまいました。
アクティビティを使うというひとつの仮説
実は、僕が開発していたアプリではJetpack Composeを採用していました。
なので、できるだけアクティビティ依存の実装は避け、Jetpack Composeで提供されている機能を積極的に採用することでAndroidアプリのモダンな実装を目指していました。
そのため、NFCを読み取る際には、アクティビティのonResume()
をオーバーライドすることはせず、LifecycleEventObserver()
にコールバックを登録することで、ライフサイクルの監視を行いNFCへの読み書きを行う実装をしていました。
詳しい実装方法については、以前投稿したこちらの記事をご覧ください。
しかし、上記の実装方法のとおり、addOnNewIntentListener()
を使って実装したリスナーでは、onNewIntent()
が呼び出される前にandroid:scheme="market"
のインテントが発行されてしまうためにGoogle Play Storeが開かれてしまうという問題が生じていることがわかりました。
そこで、僕はあるひとつの仮説を立てました。NFCを読み取った際の処理方法を参考にしていた上記の2つのアプリはJetpack Composeが浸透する前からリリースされているアプリでした。
おそらくこの二つのアプリでは、addOnNewIntentListener()
などは使わずにActivityにてenableForegroundDispatch()
を呼び出し、onNewIntent()
をオーバーライドすることでGoogle Play Storeが開かれるよりも前に起動中のアプリでNFCへの操作を行って至るのではないかと考えました。
rememberLauncherForActivityResult()を使ってNFC専用のアクティビティを起動させる
そこで、NFC読み取り専用のコンポーザブルを呼び出すのをやめて、代わりにrememberLauncherForActivityResult()
を使ってNFC専用のアクティビティを起動して目的の処理をそこで行うことに決めました。
まず、コンポーザブル側でrememberLauncherForActivityResult()
を呼び出します。何かのハンドラの結果としてlaunch()
を呼び出します。
@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()
を使用しました。
続いて、アクティビティを実装します。
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 />
を追加しておきましょう。
<activity
android:name=".ui.NfcActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.ExampleAppTheme"
android:launchMode="singleTask"
>
</activity>
enableForegroundDispatch()
を呼び出しているため、次のようなインテントフィルターは設定していません。
<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
のサブクラスとしてクラスを定義します。
ぜひ同様の問題が発生した場合は、ご参考までにクラス定義をご確認ください。
シミュレーターではご注意を
シミュレーターを使用しているときに、このアクティビティを開くと次のエラーが発生する可能性があります。
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に関する知見も深めることができました。
あのとき諦めずにこの問題に取り組み続けて良かったと思います。
もし僕と同じような問題に悩まされていて、この記事にたどり着いた方の数時間を節約することができたなら、あの時間は無駄ではなかったと胸を張ることができるでしょう。
参考にした記事