たかぎとねこの忘備録

プログラミングに関する忘備録を自分用に残しときます。マサカリ怖い。

通知に関する実行時の権限を要求するダイアログが表示されない時に考えるべきこと

launchPermissionRequest()を呼び出して、通知に関する権限の確認を行いたい場合がある。

val permission = Manifest.permission.POST_NOTIFICATIONS
val permissionState = rememberPermissionState(permission = permission)

when {
    permissionState.status.isGranted -> {
        viewModel.updateIsNotificationEnabled(it)
    }
    else -> {
        permissionState.launchPermissionRequest()
    }
}

本来想定しているのは、次のようにダイアログが表示されている状態。

しかし、これが表示されない場合があり、次のようなエラーが発生する。

None of [android.permission.POST_NOTIFICATIONS] in {}
...
Input channel object '6xxxxx com.google.android.permissioncontroller/com.android.permissioncontroller.permission.ui.GrantPermissionsActivity (client)' was disposed without first being removed with the input manager!

実は、通知に関する実行時の権限はAPIレベル33から導入されたもの。

通知に関する実行時の権限  |  Android デベロッパー  |  Android Developers

なので、APIレベル32以下のAndroid 12などの端末やエミュレーターでいくら権限を要求してもダイアログは表示されない。

そのため、顕現を要求する際は現在の実行環境のSDKバージョンを確認してから行うと良い。

import android.os.Build
...
when {
    Build.VERSION.SDK_INT >= 33 -> {
        when {
            permissionState.status.isGranted -> {
                ...
            }
            else -> {
                permissionState.launchPermissionRequest()
                ...
            }
        }
    }
    else -> {
        ...
    }
}

参考

How can I check the system version of Android? - Stack Overflow

java - Android permission doesn't work even if I have declared it - Stack Overflow

Cloud Runにデプロイを試みた時に発生したPermission 'run.services.get' denied on resourceエラーの解決方法

GitHub Actionsからサービスアカウントを使ってCloud Runにデプロイを行なった時に次のようなエラーが発生した。

ERROR: (gcloud.run.deploy) PERMISSION_DENIED: Permission 'run.services.get' denied on resource

このエラーの内容からgcloud.run.deployの権限が付与されていないためエラーが発生しているように見えるが、このときサービスアカウントにはすでにCloud Run管理者権限が付与されている状態だった。

google cloud run - ERROR: (gcloud.run.services.update) PERMISSION_DENIED: Permission 'run.services.get' denied on resource 'namespaces/*/services/test-repo' - Stack Overflow

この場合の解決方法としては、Cloud Buildのページから設定を開く。

Cloud Run 管理者のステータスを有効に切り替える。

すべてのサービスアカウントにアクセス権を付与をクリックする。

この状態で、GitHub Actionsからもう一度デプロイを試みると解決した。

ワークフローファイルの内容は次の通り。

name: Deploy

on:
  push:
    branches:
      - main
  workflow_dispatch:

jobs:
  deploy:
    runs-on: ubuntu-latest
    timeout-minutes: 60
    needs: [test]
    if: ${{ (github.ref == 'refs/heads/main') }}

    steps:
      - name: Checkout
        uses: actions/checkout@v3

      - name: Set up JDK 11
        uses: actions/setup-java@v3
        with:
          distribution: 'temurin'
          java-version: 11

      - name: Make gradlew executable
        run: chmod +x ./gradlew

      - name: Setup Gradle
        uses: gradle/gradle-build-action@v2

      - id: 'auth'
        uses: 'google-github-actions/auth@v1'
        with:
          credentials_json: '${{ secrets.GCP_CREDENTIALS }}'

      - name: Set up gcloud CLI
        uses: google-github-actions/setup-gcloud@v1

      - name: Configure Docker with Credentials
        run: |
          gcloud auth configure-docker

      - name: Build the Docker Image
        run: |
          gcloud config set project ${{ secrets.GCP_PROJECT_NAME }}
          gcloud builds submit --config ./cloudbuild.yaml

参考

Cloud Build を使用した継続的デプロイの手動設定  |  Cloud Run のドキュメント  |  Google Cloud

ViewModelを初期化しようとして`Module with the Main dispatcher had failed to initialize`というエラーが出た話

テスト時にViewModelを初期化する処理を次のように書いた。

class LabelListViewModelTest {
    private val testScheduler = TestCoroutineScheduler()
    private val dispatcher = StandardTestDispatcher(testScheduler)
    private val repository = mockk<LabelRepository>(relaxed = true)
    private val sut = LabelListViewModel(repository)

    @Before
    fun setup() {
        Dispatchers.setMain(dispatcher)
    }

そしたら、次のようなエラーが発生した。

Exception in thread "Test worker" java.lang.IllegalStateException: Module with the Main dispatcher had failed to initialize. For tests Dispatchers.setMain from kotlinx-coroutines-test module can be used

ViewModelの中身を確認してみる。

...
@HiltViewModel
class LabelListViewModel @Inject constructor(
    private val labelRepository: LabelRepository
) : ViewModel() {
    private val viewModelState = MutableStateFlow(
        LabelListViewModelState(
            isLoading = false,
        )
    )
    val uiState = viewModelState
        .map { it.toUiState() }
        .stateIn(
            viewModelScope,
            SharingStarted.Eagerly,
            viewModelState.value.toUiState()
        )

おそらく、setup()Dispatchers.setMain()を呼び出す前に、.stateIn(viewModelScope, SharingStarted.Eagerly, viewModelState.value.toUiState())が呼び出されてしまっているためModule with the Main dispatcher had failed to initializeというエラーが発生したのだと思う。

なのでlateinitを使ってsetup()の中でsutを初期化するようにしたらエラーが消えた。

...
@HiltViewModel
class LabelListViewModel @Inject constructor(
    private val labelRepository: LabelRepository
) : ViewModel() {
    private val viewModelState = MutableStateFlow(
        LabelListViewModelState(
            isLoading = false,
        )
    )
    val uiState = viewModelState
        .map { it.toUiState() }
        .stateIn(
            viewModelScope,
            SharingStarted.Eagerly,
            viewModelState.value.toUiState()
        )

`builder: Modifier.() -> Modifier`の読み方

DroidKaigi 2022のコードを眺めていると次のような記述にでくわした。

inline fun Modifier.ifTrue(value: Boolean, builder: Modifier.() -> Modifier): Modifier {
    val modifier = Modifier
    return then(if (value) modifier.builder() else modifier)
}

fix potential bugs in ifModifier by tkdgusl94 · Pull Request #903 · DroidKaigi/conference-app-2022 · GitHub

このbuilder: Modifier.() -> Modifierをどう解釈すればよいのかわからなかったので、深夜にツイートしてみた。

すると、@ji_sungbinさんからリプが!

なので、ここでのbuilderModifierの拡張関数であり、Modifierを返り値として返す関数という意味らしい。

たまにこういう記述を見かける時があってその都度思考停止してたから、スッキリした。

追伸

Android界隈はとても優しいエンジニアの方が多い印象があって、本当に楽しいし大好き😊

ワークフローのファイルを作成してリモートリポジトリにプッシュしようとしたら失敗した

.github/workflows/Build.yamlを作成して、リモートリポジトリにプッシュしようとしたらエラーが発生した。

! refs/heads/ci-build-workflow:refs/heads/ci-build-workflow   [remote rejected] (refusing to allow a Personal Access Token to create or update workflow `.github/workflows/Build.yaml` without `workflow` scope)

意訳すると、Workflowのスコープが現在ログインに使用しているトークンに含まれていないということだ。

GitHubトークン作成ページに移動する。

github.com

トークンの作成時に、workflowsスコープにチェックを入れてからトークンを新たに作成する。

作成が完了したら、画面に表示されているトークンをコピーしておく。

IntelliJ IDEAで環境設定を開き、左のメニューからバージョン管理、GitHubの順番にクリックする。 ログイン済みの場合は、既存のアカウントを削除する。

トークンでログインをクリックする。

先ほどコピーしたトークンを貼り付けて、アカウントの追加をクリックする。

自分のアカウントが正しく表示されていれば完了。

あとは、もう一度プッシュしてみて成功するかを確認する。

参考

ようやくAndroid StudioにGitHubのTokenを設定した - 縁側プログラミング

NavigationBarのNavigationBarItemを何度もタップすることにより、アプリが強制終了する場合の対処法

NavigationBarNavigationBarItemを何度もタップし、最後に一番左のNavigationBarItemを連打するとアプリがクラッシュする現象に遭遇した。

これがその時のエラーの抜粋。

java.util.NoSuchElementException: List contains no element matching the predicate.
    at androidx.navigation.compose.NavHostKt$NavHost$4.invoke(NavHost.kt:180)
    at androidx.navigation.compose.NavHostKt$NavHost$4.invoke(NavHost.kt:141)
    at androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.jvm.kt:116)
    at androidx.compose.runtime.internal.ComposableLambdaImpl$invoke$1.invoke(ComposableLambda.jvm.kt:127)
    at androidx.compose.runtime.internal.ComposableLambdaImpl$invoke$1.invoke(ComposableLambda.jvm.kt:127)
    at androidx.compose.runtime.RecomposeScopeImpl.compose(RecomposeScopeImpl.kt:145)
    at androidx.compose.runtime.ComposerImpl.recomposeToGroupEnd(Composer.kt:2375)

例外が発生している箇所は、NavHostlast()メソッドの呼び出し部分だった。

// classes.jar/androidx/navigation/compose/NavHostKt.class

if (backStackEntry != null) {
    // while in the scope of the composable, we provide the navBackStackEntry as the
    // ViewModelStoreOwner and LifecycleOwner
    Crossfade(backStackEntry.id, modifier) {
        val lastEntry = visibleEntries.last { entry ->
            it == entry.id
        }

大元を辿るとnavigate()メソッドを呼び出している箇所によるものだった。

navController.navigate(newRoute) {
    popUpTo(navController.graph.findStartDestination().id) {}
    launchSingleTop = true
}

どうやら、特定の条件下において同じ遷移先に対して何度もnavigate()メソッドを呼び出そうとするとクラッシュするようだ。

なので、これから遷移しようとしている先が現在のルートと被っていないかを確認する処理を挟んであげることで解決した。

fun navigateAndPopUp(
    destination: TnaNavigationDestination,
    route: String? = null,
    from: NavBackStackEntry? = try { navController.currentBackStackEntry } catch(err: Exception) { null }
) {
    if (from != null && from.lifecycleIsResumed()) {
        val newRoute = when (destination) {
            is TopLevelDestination -> {
                destination.destination
            }
            else -> {
                route ?: destination.route
            }
        }
        when(newRoute) {
            from.destination.route -> { /* 何もしない */ }
            else -> {
                navController.navigate(newRoute) {
                    popUpTo(navController.graph.findStartDestination().id) {}
                    launchSingleTop = true
                }
            }
        }
    }
}

参考

Google Issue Tracker

kotlin Exception「Exception in thread “main” java.util.NoSuchElementException: Collection contains no element matching the predicate.」の解決方法 | mebee

android - Collection contains no element matching the predicate - Stack Overflow

Cloud Runで`error: Failed to sign the provided bytes`というエラーが表示された場合の対処法

Cloud RunでKtorアプリを動かしていると、次のエラーログが表示された。

error: Failed to sign the provided bytes

色々調べたらCloud Storageに保管してある画像の署名付きURLの生成時に発生しているようだった。

serverfault.com

stackoverflow.com

なので、サービスアカウントトークン作成者ロールをサービスアカウントに付与してみた。

しかし、結果は変わらずだった。次に、signUrl()の呼び出し時のオプションを変えてみた。

変える前の書き方。

override suspend fun find(blobName: String): FileObject? {
    val bucket = storage.get(bucketName)
    val blob = bucket.get(blobName)
    val url = blob.signUrl(
        15,
        TimeUnit.MINUTES,
        Storage.SignUrlOption.withV4Signature()
    )
    ...
}

変えた後の書き方。

override suspend fun find(blobName: String): FileObject? {
    val bucket = storage.get(bucketName)
    val blob = bucket.get(blobName)
    var credentialsToSign = storage.options.credentials
    if (credentialsToSign is UserCredentials) {
        credentialsToSign = ImpersonatedCredentials.create(
            credentialsToSign as GoogleCredentials,
            "service-account-id@project-name.iam.gserviceaccount.com",
            Collections.emptyList(),
            Collections.emptyList(),
            3600
        )
    }
    val url = blob.signUrl(
        15,
        TimeUnit.MINUTES,
        Storage.SignUrlOption.httpMethod(HttpMethod.PUT),
        Storage.SignUrlOption.withExtHeaders(mapOf("Content-Type" to "application/octet-stream")),
        Storage.SignUrlOption.withV4Signature(),
        Storage.SignUrlOption.signWith(credentialsToSign as ServiceAccountSigner)
        Storage.SignUrlOption.withV4Signature()
    )
    ...
}

しかし、結果は変わらず。このメッセージだけじゃ具体的に何が原因なのかわからなかったので、とりあえず例外処理を外して、実際のエラーの内容を確認してみることにした。

すると次のようなログが出力された。

Caused by: java.io.IOException: Error code 403 trying to sign provided bytes: IAM Service Account Credentials API has not been used in project xxxxxxxxxxxx before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/iamcredentials.googleapis.com/overview?project=xxxxxxxxxxxx then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.

つまり、IAM Service Account Credentials APIが有効になっていなかったのが原因だったことがわかった。

有効化してみた。

これによりエラーは消えた。