App Distribution Android SDK を使って in-app new build alerts を組み込んでみた
こんにちは、 カウシェ の 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 を構成します。
// ... 周辺省略 ...
dependencies {
debugImplementation("com.google.firebase:firebase-appdistribution:16.0.0-beta02")
}
build variant が debug 以外の場合はソースがそもそも含まれない、とすることで DebugActivity および app distribution をチェックする実装が本番アプリに入らないようにする
を満たしました。
デバッグ画面の呼び出しを作る
端末シェイクでデバッグ画面を開きたい
のでお手軽に seismic 使います
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)
}
}
}
}
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 を簡素に用意しました。
@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 アプリを育て上げていってくださる仲間を募集しております。腕に覚えのある方もこれからサービスとともに成長していきたい方も、気になった方はぜひ下記のページをご覧いただけますと嬉しいです!
Android 以外の職種も積極採用中です!
Discussion