App Distribution Android SDK를 사용하여 in-app new build alerts 가져오기

79687 단어 AndroidFirebasetech
안녕하세요, 저는 카와이의 안드로이드 담당자인sintario입니다.
시장점유율로 EC를 사들여 급성장 중인 폐해회사 카우쉬의 앱 개발 현장에서 이런 일을 하고 있으니 제가 소개해 드리겠습니다.

App Distribution Android SDK


2022년 3월 Firebase Android Bom 29.2.0의 발매 노트서두에는 다음과 같은 내용이 담겼다.

누군가 앱 디스트릭을 이용해 검증용 빌딩을 보내고 있지 않을까 싶다.편리하지만 새 버전을 알려주는 것을 잊고 옛 건물에서 테스트를 계속하는 경우도 가끔 있다.응용 프로그램을 사용하는 과정에서 새로 도착한 응용 프로그램에 대해 자연스럽게 알려주면 이런 시비를 막을 수 있을 것 같다.
이번에는 앱 내에서 새 빌딩에 대한 알림을 받고 즉석에서 앱 디스트리뷰션 안드로이드 SDK를 다운로드할 수 있다.
본문은 보면서사용 설명서 실제로 편입된 문장이다.
  • Firebase 프로젝트 측의 설정 작업은 본고에서 설명을 생략하였으니 사용 지침에 따라 설정하십시오.
  • 일부분은 사전 설명을 거치지 않고 자체제작Compoosable 함수를 사용하였으니 양해해 주십시오.
  • 설명 없이 Edge-to-edge에 대응했지만 다시 소개해 드리려고 합니다.
  • 구현 전 논의


    사용 안내서에는 가장 빨리 사용하기 위해MainActivity#onResume 검사를 업데이트하는 샘플 코드가 적혀 있다.최근 앱은 단일 이벤트로 구성되어 있고, 화면에는 나비게이션 프레임 오어 나비게이션 컴포지션이 담겨 있다. 이런 느낌으로 앱 내 유일한MainActivity에 대해 마법사의 지시에 따라 조작하면 최소한 이동이 가능하다.
    그러나 실제 응용 개발 현장에서는 다음과 같은 경우가 종종 있다.
  • 일부러 이전 버전 설치
  • 버전 업그레이드를 위한 테스트
  • 과거 버전의 고장을 재현하기 위해
  • onResume에서 UI 디스플레이와 함께 특수 처리
  • 어떤 CRM이 내장되어 있어 재방문을 계기로 팝업 음악을 선보인다
  • 이런 상황에서 실제 디버깅과 검증 과정에서 응용 프로그램을 켜서 새 건물의 알림을 방해했다는 소감이 있을 수 있다.
    따라서 이번 결정은 다음과 같은 조건으로 구성된다.
  • DebugAcitivty 준비, 어플리케이션을 켠 상태에서 터미널 대기 후 켜기
  • DebugActivity 테스트 시작/중지 허용
  • DebugActivity에서 이미 테스트를 시작한 경우 화면을 열 때 새로운 구축 검사를 실시합니다
  • DebugActivity 및 검사 응용 프로그램의 실현이 실제 응용에 들어가지 않음
  • 개발 환경


    중요한 곳만 있어요.
  • 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 모듈입니다.해당 모듈의 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 をチェックする実装が本番アプリに入らないようにする.

    디버그 화면 호출 만들기

    端末シェイクでデバッグ画面を開きたい 때문에 사용하기 편하다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
    }
    
    우리는 이런 잡다한 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는 다음과 같은 진전을 얻기 위한 얇은 sealedclass
    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)
    
    과 같은 느낌은 진도열이 나타나기 때문에 화면 측면의 상태 표시에 사용된다.

    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를 활용하여 실천하는 모습도 보셨을 거라고 생각합니다.
    안드로이드 앱을 함께 육성할 파트너를 모집 중입니다.능력이 있는 사람이든 앞으로 서비스와 함께 성장하고 싶은 사람이든 마음에 들면 아래 페이지를 꼭 보세요!
    https://enjoy-working.kauche.com/
    안드로이드 이외의 직업도 적극 채용 중!

    좋은 웹페이지 즐겨찾기