StrictModeを利用した「より安全なインテント」を判定するテストコードを作成する


## はじめに

Android15（taragetSdk=35)の対応をすることがありました。
[動作の変更点](https://developer.android.com/about/versions/15/behavior-changes-15?hl=ja#safer-intents)の中で、「より安全なインテント」の項目の確認をする際にStrictModeを利用して安全なインテントか確認することがあったので今後のためにやった内容を備忘録としてまとめておきます。

ちなみにInstrumentationTestを利用しますが、この記事では特にそれについて詳しく記載はしません。

## より安全なインテントとは

そもそもより安全なインテントとはなんでしょうか？
Android15における改善点として２点挙げられています。
- ターゲットのインテント フィルタに一致させる
- インテントにはアクションが必要

##### [公式](https://developer.android.com/about/versions/15/behavior-changes-15?hl=ja#safer-intents)から引用
- ターゲットのインテント フィルタに一致させる
> 特定のコンポーネントをターゲットとするインテントは、ターゲットのインテント フィルタの仕様に正確に一致する必要があります。別のアプリのアクティビティを起動するためのインテントを送信する場合、ターゲット インテント コンポーネントは、受信側のアクティビティで宣言されたインテント フィルタと一致している必要があります。

- インテントにはアクションが必要
> インテントにはアクションが必要: アクションのないインテントは、インテント フィルタと一致しなくなります。つまり、アクティビティやサービスを起動するために使用するインテントには、明確に定義されたアクションが必要です。

要は
・```<intent-filter>```で指定しているactionやcategoryが呼び出し時のIntentと一致しているか
・Intent作成時にactionを明示的に設定しているかの観点でみていくことになります。

actionの方は初期化時に設定されているかどうかの観点で警告が出るため、
```kotlin
val intent = Intent()
intent.action = Intent.ACTION_VIEW

startActivity(intent)
```
のような宣言後にプロパティへ代入するのも良くないようです。
また、setResult()や明示的に別アプリ名を指定しているようなカスタムインテントはactionを設定する必要はないようです。（認識間違ってたら教えてください...）


## 確認方法
いよいよ実際の確認方法ですが、
タイトルにもあるとおり、今回はAndroidのAPIで提供されている[StrictMode](https://developer.android.com/reference/android/os/StrictMode)とInstrumentation Testを利用して確認をしていきます。

- **インテントにはアクションが必要**
こちらに関してはIntent()などで検索して設定されていなければ基本的には修正対象です。
setResult()やカスタムインテント(起動させるアプリ名を明示的に指定したりするもの)は付けてなくても問題ありません。
<br>

**ターゲットのインテント フィルタに一致させる、安全なインテントか**
という観点でテストコードを利用しています


[StrictMode](https://developer.android.com/reference/android/os/StrictMode)は元々開発者向けのデバッグ機能でパフォーマンス改善を目的としています。
違反を検知してログに出したりアプリを停止させたりすることができます。

本来はApplicationの継承クラスのonCreate()などアプリ全体に対する設定として設定しますが今回は
テストの事前設定で

```kotlin
StrictMode.setVmPolicy(
            StrictMode.VmPolicy.Builder()
                // 安全ではないインテント起動を検知
                .detectUnsafeIntentLaunch()
                // 警告のある場合ログとして出す
                .penaltyLog()
                // 警告がある場合アプリケーションを終了する
                .penaltyDeath()
                .build()
        )
```

を追加します。

本来はKotestを利用したかったのですが、InstrumentationTestがJUnit+AndroidXでしかできませんでした。。

その他全体像としては以下になります。

```kotlin

// JUnit4を利用しています
@RunWith(AndroidJUnit4::class)
// 名前は適当です
class AllIntentsSafetyInstrumentedTest {

    private lateinit var context: Context
    private lateinit var hogeScenario: ActivityScenario<HogeActivity>

    private val testUrl = "https://example.com"
    private val schemes = listOf(
        "hogehoge",
        "piyopiyo",
    )

    @Before
    fun setup() {
        context = ApplicationProvider.getApplicationContext()
        // ログ出力、安全ではないインテントの場合はアプリを落とす
        StrictMode.setVmPolicy(
            StrictMode.VmPolicy.Builder()
                .detectUnsafeIntentLaunch()
                .penaltyLog()
                .penaltyDeath()
                .build()
        )
        hogeScenario = ActivityScenario.launch(HogeActivity::class.java)
    }

    @After
    fun tearDown() {
        StrictMode.setVmPolicy(StrictMode.VmPolicy.Builder().build())
        hogeScenario.close()
    }

    @Test
    fun testCreateIntentOnActivity() {
        // 明示的にアクションを指定していない、安全ではないインテント。エラー確認用
        // val errorIntent = Intent()
        val intentForPeke = PekePekeActivity.createIntent(context)
        intentForPeke.putExtra("keyName", true)
        val intents = listOf(
            // errorIntent,
            intentForPeke,
            XXX.createIntent(context),
            // 以下省略
        )

        // intentsの数だけ確認する
        intents.forEach { intent ->
            IntentTestHelper.testIntentWithStartActivity(context, intent)
        }
    }


    @Test
    fun testHogeActivityCreateIntent() {
        launchActivity<XxxActivity>(Intent().apply {
            data = Uri.parse(testUrl)
        }).onActivity { activity ->
            val intent = HogeActivity.Companion.createIntent(activity, testUrl)
            intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
            activity.startActivity(intent)
        }
    }

    
    @Test
    fun testValidSchemes() {
        schemes.forEach { scheme ->
            val intent = IntentTestHelper.createTestIntent(scheme)
            IntentTestHelper.assertIntentMatches(context, intent, true)
        }
    }

    @Test
    fun testHttpsSchemes() {
        // App Linksのテスト
        val testCases = listOf(
            Triple(
                "https",
                context.getString("domain_key_name"),
                context.getString(.R.string.app_link_path_prefix)
            )
        )

        testCases.forEach { (scheme, host, pathPrefix) ->
            val intent = Intent().apply {
                action = Intent.ACTION_VIEW
                data = Uri.parse("$scheme://$host$pathPrefix")
                addCategory(Intent.CATEGORY_DEFAULT)
                addCategory(Intent.CATEGORY_BROWSABLE)
            }

            val resolveInfo = context.packageManager.resolveActivity(intent, 0)
            assertNotNull(
                "URL '$scheme://$host$pathPrefix' を処理できるActivityが見つかりませんでした",
                resolveInfo
            )
            assertEquals(
                "${context.packageName}.ui.main.SchemeActivity",
                resolveInfo?.activityInfo?.name
            )
        }
    }
}

class IntentTestHelper {
    companion object {
        fun testIntentWithStartActivity(context: Context, intent: Intent) {
            try {
                intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
                context.startActivity(intent)
            } catch (e: Exception) {
                Assert.fail("Unsafe Intent detected in ${intent.component?.className}: ${e.message}")
            }
        }

        fun createTestIntent(scheme: String): Intent {
            return Intent().apply {
                action = Intent.ACTION_VIEW
                data = Uri.parse("$scheme://example")
                addCategory(Intent.CATEGORY_DEFAULT)
                addCategory(Intent.CATEGORY_BROWSABLE)
            }
        }

        fun assertIntentMatches(
            context: Context,
            intent: Intent,
            shouldMatch: Boolean
        ) {
            try {
                val resolveInfo = context.packageManager.resolveActivity(intent, 0)

                if (shouldMatch) {
                    assertNotNull(
                        "インテントを処理できるActivityが見つかりませんでした: $intent",
                        resolveInfo
                    )
                    assertEquals(
                        SchemeActivity::class.java.name,
                        resolveInfo?.activityInfo?.name
                    )
                } else {
                    assertNull(
                        "インテントが予期せずマッチしました: $intent",
                        resolveInfo
                    )
                }
            } catch (e: Exception) {
                fail("StrictMode violation: ${e.message}")
            }
        }
    }
}


```

基本的な動きとしてはインテントのリストを作成して、startAcitvityで警告なく起動できるかのテストになります。
引数の関係で合ったり、インテントを利用している処理をそのまま呼んだ方が良い場合はScenarioを利用して実施などをしてください。

スキーマに関してはインテントフィルターに設定している設定で起動したときに問題なく呼ばれているかの確認をしています。
設定した値と違うもので起動してエラーになるかの確認も必要な場合は行なってください。

## 最後に

本来は最初からDebug時にStrictModeの設定をONにしておいて、実装時に確認するというのが良いです。
しかし、なかなかそこまでやっていないアプリの方が多いと思います。

確認方法としても実際にインテント処理を動かしてみたり、その処理をテスト側から呼び出す方が正しいですが画面を出す際の設定が大変であるとか（モックを作成しなくてはいけない、プライベートな関数である、データの外的要因が多いなど）
全くの同じ設定でなくても警告の確認はしておきたいなどの場合は今回のようなテストをやってみてはいかがでしょうか。