App Distribution Android SDK を使って in-app new build alerts を組み込んでみた

80919 ワード

こんにちは、 カウシェ の Android 担当 sintario です。
シェア買いECとして急成長中の弊社カウシェのアプリ開発の現場ではこんな事をやっています、というご紹介させていただきます。


App Distribution Android SDK

2022年3月の Firebase Android BoM 29.2.0 のリリースノートの冒頭に以下のような記述がありました。

App Distribution を検証用ビルドの配信に使っている方もいらっしゃるかと思います。便利なんですが、新バージョンが届いたのをお知らせし忘れて古いビルドのままテストしてしまった〜なんてこともたまにはあったりします。アプリを使う中で自然に新着アプリをお知らせしたりできたらこういう齟齬を防げそうに思いますよね。

今回、新着ビルドのお知らせをアプリ内で受け取ってその場でダウンロードもできる App Distribution Android SDK が利用可能になりました。
本稿は、 利用ガイドをみながら実際に組み込んでみました、という記事です。

  • Firebase project 側の設定作業とかは本稿では説明省いていますので、利用ガイドに従ってセットアップしてください。
  • 一部事前説明無しで自作 Composable 関数が使われていたりしますがご容赦ください。
  • 説明抜きで Edge-to-edge 対応していたりしていますが、これはまた改めてご紹介すると思います。

実装前の検討

利用ガイドには最速で使うために MainActivity#onResume で更新チェックするサンプルコードが書いてあります。最近のアプリですとシングルアクティビティ構成にして画面の中は navigation fragment or navigation compose で、、、という感じでしょうから、アプリ内唯一の MainActivity に対してガイド通り愚直に実装すれば最低限動きます。

しかしながら、実際のアプリ開発の現場だと以下のようなユースケースが往々にしてありますよね。

  • 意図的に過去バージョンをインストールする
    • バージョンアップのテストのため
    • 過去バージョンでの不具合の再現をとるため
  • onResume でなにか UI の表示を伴うような特別な処理を他にしている
    • 何らかの CRM が組み込んであって、再訪契機でポップアップを出しますとか

こういう状況下にあると、実際のデバッグや検証中にアプリを開いて、新着ビルドのお知らせ邪魔だなあ、、、って感想になってしまうかもしれません(というか邪魔になりました)。

そこで今回は以下のような要件で構成することにしました。

  • DebugAcitivty を用意して、アプリを開いている状態で端末シェイクしたら開くようにする
  • DebugActivity 上でテスター開始・テスター廃業できるようにする
  • DebugActivity でテスター開始済みであれば、画面を開いたときに新着ビルドチェックが行われるようにする
  • DebugActivity および app distribution をチェックする実装が本番アプリに入らないようにする

開発環境

主要なところだけ

  • Android Studio Bumblebee 2021.1.1 Patch 3
  • Android Gradle Plugin 7.1.3
  • Kotlin 1.6.10
  • Kotlin Coroutines 1.6.0
  • Jetpack Compose 1.1.1
  • Hilt 2.41
  • App Distribution Android SDK 16.0.0-beta02

ソース構成

カウシェの場合はマルチモジュール構成になっていまして、デバッグ機能用の feature module があります。そのモジュールの debug のソースツリーにのみ DebugActivity を用意します。

App Distribution SDK も debug のときだけ組み込まれるように gradle を構成します。

feature_debug/build.gradle.kts
// ... 周辺省略 ...

dependencies {
    debugImplementation("com.google.firebase:firebase-appdistribution:16.0.0-beta02")
}

build variant が debug 以外の場合はソースがそもそも含まれない、とすることで DebugActivity および app distribution をチェックする実装が本番アプリに入らないようにする を満たしました。

デバッグ画面の呼び出しを作る

端末シェイクでデバッグ画面を開きたい のでお手軽に seismic 使います

feature_debug/src/debug/com/kauche/feature/debug/DebugLauncher.kt
package com.kauche.feature.debug

import android.app.Activity
import android.content.Intent
import android.hardware.SensorManager
import androidx.core.content.getSystemService
import com.squareup.seismic.ShakeDetector

class DebugLauncher {
    fun install(activity: Activity) {
        activity.run {
            val sensor: SensorManager? = getSystemService()
            if (sensor != null) {
                val detector = ShakeDetector {
                    startActivity(Intent(this, DebugActivity::class.java))
                }
                detector.start(sensor, SensorManager.SENSOR_DELAY_NORMAL)
            }
        }
    }
}
feature_debug/src/release/com/kauche/feature/debug/DebugLauncher.kt
package com.kauche.feature.debug

import android.app.Activity

class DebugLauncher {
    fun install(activity: Activity) = Unit
}

こんな感じの雑なシェイク検出器を用意しまして、あとはアプリのアクティビティの onCreate で DebugLauncher().install(this) を実行するだけ。
Activity の破棄や再生成も気にされる方はもうちょっと作り込んでも良いと思います、今回は超手抜きです。

FirebaseAppDistribution を包む

FirebaseAppDistribution のシングルトンがテスターの状態管理や新着ビルドの取得を担ってくれるんですが、 Firebase のいつもの API よろしく  UpdateTask を返してくるため listener をつけてコールバックされるようにして、、、という実装をすることになります。ここは一層包んで Coroutine Flow に変換し、受け取り側で collect できるようにしてみました。

package com.kauche.feature.debug.app.distribution

import com.google.firebase.appdistribution.FirebaseAppDistribution
import com.google.firebase.appdistribution.FirebaseAppDistributionException
import com.kauche.feature.debug.LoadState
import dagger.hilt.android.scopes.ViewModelScoped
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import javax.inject.Inject
import kotlin.contracts.ExperimentalContracts

@OptIn(ExperimentalContracts::class)
@ViewModelScoped
class AppDistributionWrapper @Inject constructor(
    private val appDistribution: FirebaseAppDistribution,
) {

    fun isTesterSignedIn(): Boolean = appDistribution.isTesterSignedIn

    fun startTester() = updateIfNewReleaseAvailable()

    fun updateIfNewReleaseAvailable(): Flow<LoadState<UpdateResult>> = callbackFlow {
        appDistribution.updateIfNewReleaseAvailable()
            .addOnProgressListener {
                trySend(LoadState.Loading(progress = it.apkBytesDownloaded, total = it.apkFileTotalBytes))
            }
            .addOnSuccessListener {
                trySend(LoadState.Loaded(UpdateResult.Success))
                close()
            }
            .addOnFailureListener { e ->
                when (e) {
                    is FirebaseAppDistributionException -> {
                        when (e.errorCode) {
                            FirebaseAppDistributionException.Status.UNKNOWN ->
                                trySend(LoadState.Loaded(UpdateResult.UnknownException(e)))
                            FirebaseAppDistributionException.Status.AUTHENTICATION_FAILURE ->
                                trySend(LoadState.Loaded(UpdateResult.AuthenticationFailure(e.localizedMessage.orEmpty())))
                            FirebaseAppDistributionException.Status.AUTHENTICATION_CANCELED ->
                                trySend(LoadState.Loaded(UpdateResult.AuthenticationCanceled(e.localizedMessage.orEmpty())))
                            FirebaseAppDistributionException.Status.NETWORK_FAILURE ->
                                trySend(LoadState.Loaded(UpdateResult.NetworkFailure(e.localizedMessage.orEmpty())))
                            FirebaseAppDistributionException.Status.DOWNLOAD_FAILURE ->
                                trySend(LoadState.Loaded(UpdateResult.DownloadFailure(e.localizedMessage.orEmpty())))
                            FirebaseAppDistributionException.Status.INSTALLATION_FAILURE ->
                                trySend(LoadState.Loaded(UpdateResult.InstallationFailure(e.localizedMessage.orEmpty())))
                            FirebaseAppDistributionException.Status.INSTALLATION_CANCELED ->
                                trySend(LoadState.Loaded(UpdateResult.InstallationCanceled(e.localizedMessage.orEmpty())))
                            FirebaseAppDistributionException.Status.UPDATE_NOT_AVAILABLE ->
                                trySend(LoadState.Loaded(UpdateResult.UpdateNotAvailable(e.localizedMessage.orEmpty())))
                            FirebaseAppDistributionException.Status.HOST_ACTIVITY_INTERRUPTED ->
                                trySend(LoadState.Loaded(UpdateResult.HostActivityInterrupted(e.localizedMessage.orEmpty())))
                        }
                    }
                    else -> trySend(LoadState.Loaded(UpdateResult.UnknownException(e)))
                }
                close()
            }

        awaitClose()
    }

    fun signOutTester(): SignOutResult =
        if (appDistribution.isTesterSignedIn) {
            appDistribution.signOutTester()
            SignOutResult.Success
        } else {
            SignOutResult.NotTester
        }

    sealed interface SignOutResult {
        object Success : SignOutResult
        object NotTester : SignOutResult
    }


    sealed interface UpdateResult {
        object Success : UpdateResult
        data class AuthenticationFailure(val message: String) : UpdateResult
        data class AuthenticationCanceled(val message: String) : UpdateResult
        data class NetworkFailure(val message: String) : UpdateResult
        data class DownloadFailure(val message: String) : UpdateResult
        data class InstallationFailure(val message: String) : UpdateResult
        data class InstallationCanceled(val message: String) : UpdateResult
        data class UpdateNotAvailable(val message: String) : UpdateResult
        data class HostActivityInterrupted(val message: String) : UpdateResult
        data class UnknownException(val ex: Exception) : UpdateResult
    }
}

ここで LoadState は以下のような進捗取得のための薄い sealed class です

package com.kauche.feature.debug

import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.contract

sealed class LoadState<out T> {
    object Idling : LoadState<Nothing>()
    data class Loading(val progress: Long, val total: Long) : LoadState<Nothing>()
    data class Loaded<out T>(val data: T) : LoadState<T>()

    suspend fun onLoading(action: suspend (Loading) -> Unit): LoadState<T> = also {
        if (it.isLoading()) {
            action.invoke(it)
        }
    }

    suspend fun onLoaded(action: suspend (T) -> Unit): LoadState<T> = also {
        if (it.isLoaded()) {
            action.invoke(it.data)
        }
    }
}

@OptIn(ExperimentalContracts::class)
fun <T> LoadState<T>.isLoading(): Boolean {
    contract { returns(true) implies (this@isLoading is LoadState.Loading) }
    return this is LoadState.Loading
}

@OptIn(ExperimentalContracts::class)
fun <T> LoadState<T>.isLoaded(): Boolean {
    contract { returns(true) implies (this@isLoaded is LoadState.Loaded<T>) }
    return this is LoadState.Loaded
}

これで updateIfNewReleaseAvailable() を呼び出すと

Loading(10%)
Loading(20%)
...
Loading(90%)
Loaded(Success)

みたいな感じに進捗列が emit されてくるので、画面側の状態表示に使います。

ViewModel を実装する

  • テスター登録する
  • 新着ビルドチェックする
  • テスター廃業する

の3つを実装しただけのシンプルな  ViewModel を用意しました。

package com.kauche.feature.debug

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.kauche.feature.debug.app.distribution.AppDistributionWrapper
import com.kauche.feature.debug.app.distribution.AppDistributionWrapper.UpdateResult
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.updateAndGet
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.contracts.ExperimentalContracts

@OptIn(ExperimentalContracts::class)
@HiltViewModel
internal class DebugViewModel @Inject constructor(
    private val appDistributionWrapper: AppDistributionWrapper,
) : ViewModel() {
    private val _popupMessage = MutableSharedFlow<String>()
    val popupMessage = _popupMessage.asSharedFlow()

    private val _isAppDistributionTester = MutableStateFlow(appDistributionWrapper.isTesterSignedIn())
    val isAppDistributionTester = _isAppDistributionTester.asStateFlow()

    private val _appDistributionUpdateResult = MutableStateFlow<LoadState<UpdateResult>>(LoadState.Idling)
    private val _newReleaseDownloadProgress = MutableStateFlow<String?>(null)
    val newReleaseDownloadProgress = _newReleaseDownloadProgress.asStateFlow()

    init {
        _appDistributionUpdateResult.onEach {
            it.onLoaded { result ->
                when (result) {
                    is UpdateResult.AuthenticationCanceled -> _isAppDistributionTester.emit(false)
                    is UpdateResult.AuthenticationFailure -> _isAppDistributionTester.emit(false)
                    is UpdateResult.DownloadFailure -> _popupMessage.tryEmit(result.message)
                    is UpdateResult.HostActivityInterrupted -> _popupMessage.tryEmit(result.message)
                    is UpdateResult.InstallationCanceled -> _popupMessage.tryEmit(result.message)
                    is UpdateResult.InstallationFailure -> _popupMessage.tryEmit(result.message)
                    is UpdateResult.NetworkFailure -> _popupMessage.tryEmit(result.message)
                    is UpdateResult.UnknownException -> _popupMessage.tryEmit(result.ex.localizedMessage.orEmpty())
                    is UpdateResult.Success -> _isAppDistributionTester.emit(true)
                    is UpdateResult.UpdateNotAvailable -> _isAppDistributionTester.emit(true)
                }
                _newReleaseDownloadProgress.emit(null)
            }.onLoading { loading ->
                _newReleaseDownloadProgress.emit("now downloading... ${loading.progress} / ${loading.total}")
            }
        }.launchIn(viewModelScope)
    }

    fun onStartTester() {
        viewModelScope.launch {
            appDistributionWrapper.startTester().collect { _appDistributionUpdateResult.emit(it) }
        }
    }

    @OptIn(ExperimentalContracts::class)
    fun onCheckNewRelease() {
        viewModelScope.launch {
            if (_isAppDistributionTester.updateAndGet { appDistributionWrapper.isTesterSignedIn() }) {
                appDistributionWrapper.updateIfNewReleaseAvailable().collect { _appDistributionUpdateResult.emit(it) }
            }
        }
    }
    
    fun onSignOutTester() {
        viewModelScope.launch {
            appDistributionWrapper.signOutTester()
            _isAppDistributionTester.update { appDistributionWrapper.isTesterSignedIn() }
        }
    }
}

デバッグ画面を用意する

DebugAcivity の中身になる Composable を簡素に用意しました。

feature_debug/src/debug/com/kauche/debug/DebugScreen.kt
@Composable
internal fun DebugScreen(
    onClickUp: () -> Unit,
    viewModel: DebugViewModel = viewModel()
) {
    val scaffoldState = rememberScaffoldState()

    DebugScreen(
        scaffoldState = scaffoldState,
        onClickUp = onClickUp,
        isTester = viewModel.isAppDistributionTester.collectAsState().value,
        newReleaseDownloadProgress = viewModel.newReleaseDownloadProgress.collectAsState().value,
        onStartTester = viewModel::onStartTester,
        onSignOutTester = viewModel::onSignOutTester
    )
    // 実際は viewModel.popupMessage を snackbar で表示したりしてるけど省略
    val observer = remember(viewModel) {
        object : DefaultLifecycleObserver {
            override fun onResume(owner: LifecycleOwner) {
                viewModel.onCheckNewRelease()
            }
        }
    }
    val lifecycle = LocalLifecycleOwner.current.lifecycle
    DisposableEffect(lifecycle, observer) {
        lifecycle.addObserver(observer)
        onDispose {
            lifecycle.removeObserver(observer)
        }
    }
}

@Composable
private fun DebugScreen(
    scaffoldState: ScaffoldState,
    onClickUp: () -> Unit,
    isTester: Boolean,
    newReleaseDownloadProgress: String?,
    onStartTester: () -> Unit,
    onSignOutTester: () -> Unit,
) {
    Scaffold(
        scaffoldState = scaffoldState,
        topBar = {
            TopNavigationWithUp(
                title = stringResource(id = R.string.debug_title),
                onClickUp = onClickUp
            )
        },
        bottomBar = {
            Spacer(modifier = Modifier.navigationBarsPadding())
        }
    ) { contentPadding ->
        Column(
            verticalArrangement = Arrangement.spacedBy(8.dp),
            modifier = Modifier.padding(contentPadding).padding(8.dp)
        ) {
            Text(
                text = stringResource(id = R.string.debug_tester_section_label),
                fontWeight = FontWeight.Bold,
                modifier = Modifier.padding(horizontal = 8.dp)
            )

            if (newReleaseDownloadProgress.orEmpty().isNotBlank()) {
                Text(
                    text = newReleaseDownloadProgress.orEmpty(),
                    modifier = Modifier.padding(horizontal = 8.dp)
                )
            }

            if (isTester) {
                Row(
                    verticalAlignment = Alignment.CenterVertically,
                    horizontalArrangement = Arrangement.Center,
                    modifier = Modifier.fillMaxWidth()
                ) {
                    TextButton(
                        onClick = onSignOutTester,
                        colors = ButtonDefaults.textButtonColors(
                            contentColor = contentColorFor(MaterialTheme.colors.primarySurface)
                        )
                    ) {
                        Text(text = stringResource(id = R.string.debug_sign_out_tester))
                    }
                }
            } else {
                Row(
                    verticalAlignment = Alignment.CenterVertically,
                    horizontalArrangement = Arrangement.Center,
                    modifier = Modifier.fillMaxWidth()
                ) {
                    TextButton(
                        onClick = onStartTester,
                        colors = ButtonDefaults.textButtonColors(
                            contentColor = contentColorFor(MaterialTheme.colors.primarySurface)
                        )
                    ) {
                        Text(text = stringResource(id = R.string.debug_start_tester))
                    }
                }
            }
        }
    }
}

あとはこれを DebugActivity の中に組み込むだけです

package com.kauche.feature.debug

import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.Color
import androidx.core.view.WindowCompat
import com.google.accompanist.insets.ProvideWindowInsets
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import com.kauche.design.theme.KaucheTheme
import dagger.hilt.android.AndroidEntryPoint

@AndroidEntryPoint
class DebugActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        WindowCompat.setDecorFitsSystemWindows(window, false)
        setContent {
            val systemUiController = rememberSystemUiController()
            val useDarkIcons = MaterialTheme.colors.isLight
            SideEffect {
                systemUiController.setSystemBarsColor(Color.Transparent, darkIcons = useDarkIcons)
            }
            KaucheTheme {
                ProvideWindowInsets {
                    Surface {
                        DebugScreen(onClickUp = ::finish)
                    }
                }
            }
        }
    }
}

完成

以上が概略です。アプリをインストールしたら端末をシェイクして Start Tester からテスターのグーグルアカウントでサインインし、テスター開始しましょう。

テスター開始後に新着ビルドが検知されると、こんな感じでアラートが表示されて、アプリの中にいるままアップデートから再起動まですることができます。

step screen sample
new build alert
downloading
confirm
installing
finish

終わりに

いかがでしたでしょうか。 カウシェのバリュー のひとつ、 Try First の精神で、新機能をささっと実用搭載してみた、というご紹介でした。Jetpack Compose や Hilt, Coroutines を実践的に活用している様子もご覧いただけたのではないかと思います。

カウシェでは一緒に Android アプリを育て上げていってくださる仲間を募集しております。腕に覚えのある方もこれからサービスとともに成長していきたい方も、気になった方はぜひ下記のページをご覧いただけますと嬉しいです!