App Distribution Android SDK를 사용하여 in-app new build alerts 가져오기
시장점유율로 EC를 사들여 급성장 중인 폐해회사 카우쉬의 앱 개발 현장에서 이런 일을 하고 있으니 제가 소개해 드리겠습니다.
App Distribution Android SDK
2022년 3월 Firebase Android Bom 29.2.0의 발매 노트서두에는 다음과 같은 내용이 담겼다.
누군가 앱 디스트릭을 이용해 검증용 빌딩을 보내고 있지 않을까 싶다.편리하지만 새 버전을 알려주는 것을 잊고 옛 건물에서 테스트를 계속하는 경우도 가끔 있다.응용 프로그램을 사용하는 과정에서 새로 도착한 응용 프로그램에 대해 자연스럽게 알려주면 이런 시비를 막을 수 있을 것 같다.
이번에는 앱 내에서 새 빌딩에 대한 알림을 받고 즉석에서 앱 디스트리뷰션 안드로이드 SDK를 다운로드할 수 있다.
본문은 보면서사용 설명서 실제로 편입된 문장이다.
구현 전 논의
사용 안내서에는 가장 빨리 사용하기 위해
MainActivity#onResume
검사를 업데이트하는 샘플 코드가 적혀 있다.최근 앱은 단일 이벤트로 구성되어 있고, 화면에는 나비게이션 프레임 오어 나비게이션 컴포지션이 담겨 있다. 이런 느낌으로 앱 내 유일한MainActivity에 대해 마법사의 지시에 따라 조작하면 최소한 이동이 가능하다.그러나 실제 응용 개발 현장에서는 다음과 같은 경우가 종종 있다.
따라서 이번 결정은 다음과 같은 조건으로 구성된다.
DebugAcitivty
준비, 어플리케이션을 켠 상태에서 터미널 대기 후 켜기DebugActivity
테스트 시작/중지 허용DebugActivity
에서 이미 테스트를 시작한 경우 화면을 열 때 새로운 구축 검사를 실시합니다DebugActivity
및 검사 응용 프로그램의 실현이 실제 응용에 들어가지 않음개발 환경
중요한 곳만 있어요.
소스 구성
카와이의 경우 다중 모듈로 구성되어 있으며 디버깅 기능이 있는feature 모듈입니다.해당 모듈의 debug 소스 트리에만 DebugActivity를 준비합니다.
앱 디스tribution 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 をチェックする実装が本番アプリに入らないようにする
.디버그 화면 호출 만들기
端末シェイクでデバッグ画面を開きたい
때문에 사용하기 편하다seismicfeature_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.ktpackage com.kauche.feature.debug
import android.app.Activity
class DebugLauncher {
fun install(activity: Activity) = Unit
}
우리는 이런 잡다한 Sheque 검측기를 준비한 후에 응용 활동의 onCreate에서 실행한다. DebugLauncher().install(this)
액티비티의 폐기와 재생성에 관심이 있는 분들은 조금만 더 해도 될 것 같은데, 이번에는 초과 품앗이입니다.패키지에 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
는 다음과 같은 진전을 얻기 위한 얇은 sealedclasspackage 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)
과 같은 느낌은 진도열이 나타나기 때문에 화면 측면의 상태 표시에 사용된다.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 컨텐츠를 간단하게 준비하는 Compoosable
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)
}
}
}
}
}
}
완성
이상은 개요입니다.앱이 설치되면 터미널을 닫고 스타트 테스터에서 테스트자의 구글 계정에 로그인해 테스트를 시작하세요.
테스트가 시작된 후 새 건물이 검출되면 이런 느낌으로 경보가 표시되며 프로그램에서 업데이트에서 재시작까지 가능합니다.
step
screen sample
new build alert
downloading
confirm
installing
finish
끝맺다
어때?카슈의 가치 중 하나로 트리퍼스트의 정신으로 한꺼번에 새로운 기능을 실용적으로 탑재했다고 소개했다.Jetpack Compose, Hilt, Coroutines를 활용하여 실천하는 모습도 보셨을 거라고 생각합니다.
안드로이드 앱을 함께 육성할 파트너를 모집 중입니다.능력이 있는 사람이든 앞으로 서비스와 함께 성장하고 싶은 사람이든 마음에 들면 아래 페이지를 꼭 보세요!
안드로이드 이외의 직업도 적극 채용 중!
Reference
이 문제에 관하여(App Distribution Android SDK를 사용하여 in-app new build alerts 가져오기), 우리는 이곳에서 더 많은 자료를 발견하고 링크를 클릭하여 보았다 https://zenn.dev/sintario_kauche/articles/a05a83b06739f1텍스트를 자유롭게 공유하거나 복사할 수 있습니다.하지만 이 문서의 URL은 참조 URL로 남겨 두십시오.
우수한 개발자 콘텐츠 발견에 전념 (Collection and Share based on the CC Protocol.)