たかぎとねこの忘備録

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

ビルドバリアントごとに処理の実装を切り替えたい場合に発生したコンフリクトを解消する方法

ビルドバリアントでは、mainディレクトリに置いてあるファイルとの差分の内容を、ビルドバリアントのディレクトリに配置することで、そのビルドバリアントを選択してビルドした際に優先的に読み込まれるのかと思っていた。

結論から言うと、ビルドタイプの名称をもつディレクトリを作成し、mainディレクトリと構成の齟齬が無いように差分のファイルを配置することが必要です。

Androidのビルドバリアントをイチから理解する | フューチャー技術ブログ

これを踏まえて、sodaビルドタイプとcolaビルドタイプを作成してみた。

buildTypes {
    release {
        minifyEnabled false
        proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
    }
    debug {
        applicationIdSuffix ".debug"
        debuggable true
    }
    cola {
        applicationIdSuffix ".cola"
        debuggable true
    }
    soda {
        applicationIdSuffix ".soda"
        debuggable true
    }
}

そして、次のようにmain/ソースセットにGreeting.ktを配置した。

package com.takagimeow.material3catalog.components

class Greeting {
    fun greet() = "Main Greeting"
}

ファイル階層は次の通り。

  • src
    • main
      • java
        • com.takagimeow.material3catalog
          • components
            • Greeting.kt

そして、sodaビルドタイプ固有のファイルを作成するために次のディレクトリ階層を作成した。

  • src
    • soda
      • java
        • com.takagimeow.material3catalog
          • components
            • Greeting.kt

Greeting.ktの内容は次の通り。

package com.takagimeow.material3catalog.components

import androidx.compose.material3.Text
import androidx.compose.runtime.Composable


class Greeting {
    fun greet() = "Soda Greeting"
}

すると、次のようなRedeclaration: Greetingエラーが発生した。

色々調べた結果、実装をビルドバリアント毎に変えたいクラスやコンポーザブルなどのリリース用の実装は、main/ソースセットに配置するのではなく、新たにreleaseビルドタイプ固有のファイルを作成する必要があることがわかった。

gradle - Error Redeclaration class configuring Android build variants - Stack Overflow

なので、main/ソースセットに配置したGreeting.ktは一旦削除する。そして、releaseビルドタイプ固有のファイルとしてGreeting.ktを新たに作成する。

  • src
    • release
      • java
        • com.takagimeow.material3catalog
          • components
            • Greeting.kt

これにより、Redeclarationエラーは解消される。

試しに、上記で定義したGreetingクラスのgreet()MainActivityで呼び出してみる。

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        Log.d("MainActivity", "${Greeting().greet()}")
        setContent {
            Material3CatalogTheme {
                // A surface container using the 'background' color from the theme
                Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
                    Greeting(stringResource(id = R.string.app_name))
                }
            }
        }
    }
}

[Build] -> [Select Build Variant]に移動して、ビルドバリアントをsodaに切り替えて実行してみると、sodaビルドタイプで定義した内容が優先的に実行されるようになる。

実行した結果、LogcatにSoda Greetingと表示されていることがわかる。

今度は、colaビルドバリアントに変更して実行してみることにする。

もちろんcolaビルドタイプ固有のGreeting.ktを作成していないので、Unresolved referenceエラーが発生する。

このように、ビルドバリアントごとに実装を切り替えたい場合はその実装をmainディレクトリには配置せずにreleaseディレクトリを作成してその中に本番用の実装を定義するようにすることで、同じパッケージ名でファイルを作成したとしてもエラーを回避することができるようになる。

参考

ビルドバリアントによる複数バージョンのAPKのビルド - ZOZO TECH BLOG

ビルド バリアントを設定する  |  Android デベロッパー  |  Android Developers

KotlinのExposedで作成日に基づいて並び替えた値に対してページネーションを行いたい場合

ExposedのDAO APIを使って次のようなモデルを定義してみる。

object Posts : UUIDTable() {
    val text = varchar("text", 144)
    val createdAt = datetime("created_at")
}

class Post(id: EntityID<UUID>) : UUIDEntity(id) {
    companion object : UUIDEntityClass<Post>(Posts)

    var text by Posts.text
    var createdAt by Posts.createdAt
}

この時ページネーションを行いたいときに、次のような条件を考えてみる。

  • 取得数とオフセットを使って取得する範囲を特定する。
  • 日付を基に降順でデータを取得したい。

色々調べて思いついたのが、sortedByDescending()を使ってみること。

override suspend fun limitedPosts(n: Int, offset: Long): List<PostRecord> {
    return transaction {
        Post.all().limit(n, offset).sortedByDescending { post -> post.createdAt }.map { it.toPostRecord() }
    }
}

ExposedのDSL/DAOサンプル集 - Qiita

ただ、これだとlimit()で既に取得した値に対してソートを行った結果を取得することになってしまう。

これを回避するために、orderBy()limit()を組み合わせる。

override suspend fun limitedPosts(isActive: Boolean, n: Int, offset: Long): List<PostRecord> {
    return transaction {
        Post.all().orderBy(Posts.createdAt to SortOrder.DESC).limit(n, offset)
            .filter { post -> post.isActive == isActive }
            .map { it.toPostRecord() }
    }
}

これにより、ソートされた結果に基づいてlimit()を呼び出した内容を取得することができる。

参考

How does sorting work with limit in kotlin exposed model? - Stack Overflow

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

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を設定した - 縁側プログラミング