[#211] check for app updates

- Basic screen UI scaffolding.
- Set up communication with Google Play in-app update API.
- Mocking communication with Google Play API.
- Added unit, UI and integration tests for the in-app update screen model, util, view and viewmodel classes.
- Added mock implementation of helper AppUpdateChecker class.
- Introduced ViewModel integration test.
- Filed issue for future manual testing of implemented in-app update mechanisms after the wallet app is released on Google Play.
-  Implement FakeAppUpdateManager in AppUpdateCheckerMock instead of the ugly AppUpdateInfo instantiation - this affected the tests too.
Co-authored-by: Carter Jernigan <git@carterjernigan.com>
This commit is contained in:
Honza Rychnovsky 2022-05-16 13:40:50 +02:00 committed by GitHub
parent 0f5a094a92
commit 665042e6d9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 1471 additions and 6 deletions

View File

@ -94,12 +94,15 @@ JACOCO_VERSION=0.8.8
KOTLIN_VERSION=1.6.20
KOTLINX_COROUTINES_VERSION=1.6.1
KOTLINX_DATETIME_VERSION=0.3.2
PLAY_CORE_VERSION=1.10.3
PLAY_CORE_KTX_VERSION=1.8.1
ZCASH_ANDROID_WALLET_PLUGINS_VERSION=1.0.0
ZCASH_BIP39_VERSION=1.0.2
# TODO [#279]: Revert to stable SDK before app release
ZCASH_SDK_VERSION=1.4.0-beta01-SNAPSHOT
ZXING_VERSION=3.4.1
# Toolchain is the Java version used to build the application, which is separate from the
# Java version used to run the application. Android requires a minimum of 11.
JVM_TOOLCHAIN=17

View File

@ -62,6 +62,7 @@ dependencyResolutionManagement {
includeGroup("android.arch.core")
includeGroup("com.google.android.material")
includeGroup("com.google.testing.platform")
includeGroup("com.google.android.play")
includeGroupByRegex("androidx.*")
includeGroupByRegex("com\\.android.*")
}
@ -73,6 +74,7 @@ dependencyResolutionManagement {
excludeGroup("android.arch.lifecycle")
excludeGroup("android.arch.core")
excludeGroup("com.google.android.material")
excludeGroup("com.google.android.play")
excludeGroupByRegex("androidx.*")
excludeGroupByRegex("com\\.android.*")
}
@ -115,6 +117,8 @@ dependencyResolutionManagement {
val kotlinVersion = extra["KOTLIN_VERSION"].toString()
val kotlinxDateTimeVersion = extra["KOTLINX_DATETIME_VERSION"].toString()
val kotlinxCoroutinesVersion = extra["KOTLINX_COROUTINES_VERSION"].toString()
val playCoreVersion = extra["PLAY_CORE_VERSION"].toString()
val playCoreKtxVersion = extra["PLAY_CORE_KTX_VERSION"].toString()
val zcashBip39Version = extra["ZCASH_BIP39_VERSION"].toString()
val zcashSdkVersion = extra["ZCASH_SDK_VERSION"].toString()
val zxingVersion = extra["ZXING_VERSION"].toString()
@ -150,10 +154,13 @@ dependencyResolutionManagement {
library("kotlinx-coroutines-android", "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlinxCoroutinesVersion")
library("kotlinx-coroutines-core", "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinxCoroutinesVersion")
library("kotlinx-datetime", "org.jetbrains.kotlinx:kotlinx-datetime:$kotlinxDateTimeVersion")
library("play-core", "com.google.android.play:core:$playCoreVersion")
library("play-core-ktx", "com.google.android.play:core-ktx:$playCoreKtxVersion")
library("zcash-sdk", "cash.z.ecc.android:zcash-android-sdk:$zcashSdkVersion")
library("zcash-bip39", "cash.z.ecc.android:kotlin-bip39:$zcashBip39Version")
library("zcash-walletplgns", "cash.z.ecc.android:zcash-android-wallet-plugins:$zcashBip39Version")
library("zxing", "com.google.zxing:core:$zxingVersion")
// Test libraries
library("androidx-compose-test-junit", "androidx.compose.ui:ui-test-junit4:$androidxComposeVersion")
library("androidx-compose-test-manifest", "androidx.compose.ui:ui-test-manifest:$androidxComposeVersion")
@ -189,6 +196,13 @@ dependencyResolutionManagement {
"androidx-viewmodel-compose"
)
)
bundle(
"play-core",
listOf(
"play-core",
"play-core-ktx",
)
)
bundle(
"androidx-test",
listOf(

View File

@ -58,7 +58,8 @@ fun PrimaryButton(
fun SecondaryButton(
onClick: () -> Unit,
text: String,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
enabled: Boolean = true
) {
Button(
onClick = onClick,
@ -67,6 +68,7 @@ fun SecondaryButton(
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
),
enabled = enabled,
colors = buttonColors(containerColor = MaterialTheme.colorScheme.secondary)
) {
Text(
@ -99,7 +101,8 @@ fun NavigationButton(
fun TertiaryButton(
onClick: () -> Unit,
text: String,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
enabled: Boolean = true
) {
Button(
onClick = onClick,
@ -108,6 +111,7 @@ fun TertiaryButton(
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
),
enabled = enabled,
elevation = ButtonDefaults.buttonElevation(0.dp, 0.dp, 0.dp),
colors = buttonColors(containerColor = ZcashTheme.colors.tertiary)
) {

View File

@ -1,9 +1,12 @@
package co.electriccoin.zcash.ui.design.component
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
@Composable
@ -57,3 +60,20 @@ fun ListHeader(
modifier = modifier
)
}
@Composable
fun Reference(
text: String,
modifier: Modifier = Modifier,
onClick: () -> Unit
) {
ClickableText(
text = AnnotatedString(text),
style = MaterialTheme.typography.bodyLarge
.merge(TextStyle(color = ZcashTheme.colors.reference)),
modifier = modifier,
onClick = {
onClick()
}
)
}

View File

@ -26,7 +26,8 @@ data class ExtendedColors(
val addressHighlightTransparent: Color,
val addressHighlightViewing: Color,
val dangerous: Color,
val onDangerous: Color
val onDangerous: Color,
val reference: Color
) {
@Composable
fun surfaceGradient() = Brush.verticalGradient(

View File

@ -54,6 +54,8 @@ internal object Dark {
val dangerous = Color(0xFFEC0008)
val onDangerous = Color(0xFFFFFFFF)
val reference = Color(0xFF10A5FF)
}
internal object Light {
@ -103,6 +105,8 @@ internal object Light {
val dangerous = Color(0xFFEC0008)
val onDangerous = Color(0xFFFFFFFF)
val reference = Color(0xFF10A5FF)
}
internal val DarkColorPalette = darkColorScheme(
@ -146,7 +150,8 @@ internal val DarkExtendedColorPalette = ExtendedColors(
addressHighlightTransparent = Dark.addressHighlightTransparent,
addressHighlightViewing = Dark.addressHighlightViewing,
dangerous = Dark.dangerous,
onDangerous = Dark.onDangerous
onDangerous = Dark.onDangerous,
reference = Dark.reference
)
internal val LightExtendedColorPalette = ExtendedColors(
@ -168,7 +173,8 @@ internal val LightExtendedColorPalette = ExtendedColors(
addressHighlightTransparent = Light.addressHighlightTransparent,
addressHighlightViewing = Light.addressHighlightViewing,
dangerous = Light.dangerous,
onDangerous = Light.onDangerous
onDangerous = Light.onDangerous,
reference = Light.reference
)
internal val LocalExtendedColors = staticCompositionLocalOf {
@ -191,6 +197,7 @@ internal val LocalExtendedColors = staticCompositionLocalOf {
addressHighlightTransparent = Color.Unspecified,
addressHighlightViewing = Color.Unspecified,
dangerous = Color.Unspecified,
onDangerous = Color.Unspecified
onDangerous = Color.Unspecified,
reference = Color.Unspecified
)
}

View File

@ -46,6 +46,7 @@ android {
"src/main/res/ui/send",
"src/main/res/ui/settings",
"src/main/res/ui/support",
"src/main/res/ui/update",
"src/main/res/ui/wallet_address"
)
)
@ -65,6 +66,7 @@ dependencies {
implementation(libs.androidx.workmanager)
implementation(libs.bundles.androidx.compose.core)
implementation(libs.bundles.androidx.compose.extended)
implementation(libs.bundles.play.core)
implementation(libs.kotlin.stdlib)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.coroutines.core)

View File

@ -11,6 +11,9 @@
<activity
android:name="co.electriccoin.zcash.ui.screen.backup.TestBackupActivity"
android:exported="false" />
<activity
android:name="co.electriccoin.zcash.ui.screen.update.TestUpdateActivity"
android:exported="false" />
</application>
</manifest>

View File

@ -0,0 +1,36 @@
package co.electriccoin.zcash.ui.screen.update
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.ui.Modifier
import co.electriccoin.zcash.ui.design.component.GradientSurface
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.screen.update.fixture.UpdateInfoFixture
class TestUpdateActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setupUiContent()
}
private fun setupUiContent() {
setContent {
ZcashTheme {
GradientSurface(
Modifier
.fillMaxWidth()
.fillMaxHeight()
) {
WrapUpdate(
this,
UpdateInfoFixture.new(appUpdateInfo = null)
)
}
}
}
}
}

View File

@ -0,0 +1,23 @@
package co.electriccoin.zcash.ui.screen.update.fixture
import androidx.test.filters.SmallTest
import kotlin.test.Test
import kotlin.test.assertEquals
class UpdateInfoFixtureTest {
companion object {
val updateInfo = UpdateInfoFixture.new(appUpdateInfo = null)
}
@Test
@SmallTest
fun fixture_result_test() {
updateInfo.also {
assertEquals(it.priority, UpdateInfoFixture.INITIAL_PRIORITY)
assertEquals(it.isForce, UpdateInfoFixture.INITIAL_FORCE)
assertEquals(it.state, UpdateInfoFixture.INITIAL_STATE)
assertEquals(it.appUpdateInfo, null)
}
}
}

View File

@ -0,0 +1,62 @@
package co.electriccoin.zcash.ui.screen.update.integration
import androidx.compose.ui.test.junit4.StateRestorationTester
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.filters.MediumTest
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.screen.update.AppUpdateChecker
import co.electriccoin.zcash.ui.screen.update.fixture.UpdateInfoFixture
import co.electriccoin.zcash.ui.screen.update.model.UpdateInfo
import co.electriccoin.zcash.ui.screen.update.model.UpdateState
import co.electriccoin.zcash.ui.screen.update.view.UpdateViewTestSetup
import co.electriccoin.zcash.ui.test.getStringResource
import org.junit.Rule
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
class UpdateViewIntegrationTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
@MediumTest
fun update_info_state_restoration() {
val restorationTester = StateRestorationTester(composeTestRule)
val testSetup = newTestSetup(
UpdateInfoFixture.new(
priority = AppUpdateChecker.Priority.HIGH,
force = true,
appUpdateInfo = null,
state = UpdateState.Prepared,
)
)
restorationTester.setContent {
testSetup.getDefaultContent()
}
assertEquals(testSetup.getUpdateInfo().priority, AppUpdateChecker.Priority.HIGH)
assertEquals(testSetup.getUpdateState(), UpdateState.Prepared)
composeTestRule.onNodeWithText(getStringResource(R.string.update_download_button)).also {
it.performClick()
}
// can be Running, Done, Canceled or Failed - depends on the Play API response
assertNotEquals(testSetup.getUpdateState(), UpdateState.Prepared)
restorationTester.emulateSavedInstanceStateRestore()
assertEquals(testSetup.getUpdateInfo().priority, AppUpdateChecker.Priority.HIGH)
assertNotEquals(testSetup.getUpdateState(), UpdateState.Prepared)
}
private fun newTestSetup(updateInfo: UpdateInfo) = UpdateViewTestSetup(
composeTestRule,
updateInfo
)
}

View File

@ -0,0 +1,107 @@
package co.electriccoin.zcash.ui.screen.update.integration
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.lifecycle.viewModelScope
import androidx.test.filters.MediumTest
import co.electriccoin.zcash.ui.screen.update.AppUpdateChecker
import co.electriccoin.zcash.ui.screen.update.TestUpdateActivity
import co.electriccoin.zcash.ui.screen.update.fixture.UpdateInfoFixture
import co.electriccoin.zcash.ui.screen.update.model.UpdateInfo
import co.electriccoin.zcash.ui.screen.update.model.UpdateState
import co.electriccoin.zcash.ui.screen.update.view.AppUpdateCheckerMock
import co.electriccoin.zcash.ui.screen.update.viewmodel.UpdateViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.collectIndexed
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
@ExperimentalCoroutinesApi
class UpdateViewModelTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<TestUpdateActivity>()
private lateinit var viewModel: UpdateViewModel
private lateinit var checker: AppUpdateCheckerMock
private lateinit var initialUpdateInfo: UpdateInfo
@Before
fun setup() {
checker = AppUpdateCheckerMock.new()
initialUpdateInfo = UpdateInfoFixture.new(
appUpdateInfo = null,
state = UpdateState.Prepared,
priority = AppUpdateChecker.Priority.LOW,
force = false
)
viewModel = UpdateViewModel(
composeTestRule.activity.application,
initialUpdateInfo,
checker
)
}
@After
fun cleanup() {
viewModel.viewModelScope.cancel()
}
@Test
@MediumTest
fun validate_result_of_update_methods_calls() = runTest {
viewModel.checkForAppUpdate()
// Although this test does not copy the real world situation, as the initial and result objects
// should be mostly the same, we test VM proper functionality. VM emits the initial object
// defined in this class, then we expect the result object from the AppUpdateCheckerMock class
// and a newly acquired AppUpdateInfo object.
viewModel.updateInfo.take(4).collectIndexed { index, incomingInfo ->
when (index) {
0 -> {
// checkForAppUpdate initial callback
incomingInfo.also {
assertNull(it.appUpdateInfo)
assertEquals(initialUpdateInfo.state, it.state)
assertEquals(initialUpdateInfo.appUpdateInfo, it.appUpdateInfo)
assertEquals(initialUpdateInfo.priority, it.priority)
assertEquals(initialUpdateInfo.state, it.state)
assertEquals(initialUpdateInfo.isForce, it.isForce)
}
}
1 -> {
// checkForAppUpdate result callback
incomingInfo.also {
assertNotNull(it.appUpdateInfo)
assertEquals(AppUpdateCheckerMock.resultUpdateInfo.state, it.state)
assertEquals(AppUpdateCheckerMock.resultUpdateInfo.priority, it.priority)
assertEquals(AppUpdateCheckerMock.resultUpdateInfo.isForce, it.isForce)
}
// now we can start the update
viewModel.goForUpdate(composeTestRule.activity, incomingInfo.appUpdateInfo!!)
}
2 -> {
// goForUpdate initial callback
assertNotNull(incomingInfo.appUpdateInfo)
assertEquals(UpdateState.Running, incomingInfo.state)
}
3 -> {
// goForUpdate result callback
assertNotNull(incomingInfo.appUpdateInfo)
assertEquals(UpdateState.Done, incomingInfo.state)
}
}
}
}
}

View File

@ -0,0 +1,28 @@
package co.electriccoin.zcash.ui.screen.update.util
import android.content.Context
import android.content.Intent
import androidx.test.core.app.ApplicationProvider
import androidx.test.filters.SmallTest
import kotlin.test.Test
import kotlin.test.assertContains
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
class PlayStoreUtilTest {
companion object {
val PLAY_STORE_URI = PlayStoreUtil.PLAY_STORE_APP_URI +
ApplicationProvider.getApplicationContext<Context>().packageName
}
@Test
@SmallTest
fun check_intent_for_store() {
val intent = PlayStoreUtil.newActivityIntent(ApplicationProvider.getApplicationContext())
assertNotNull(intent)
assertEquals(intent.action, Intent.ACTION_VIEW)
assertContains(PLAY_STORE_URI, intent.data.toString())
assertEquals(PlayStoreUtil.FLAGS, intent.flags)
}
}

View File

@ -0,0 +1,86 @@
package co.electriccoin.zcash.ui.screen.update.view
import android.app.Activity
import android.content.Context
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.core.app.ApplicationProvider
import androidx.test.filters.MediumTest
import cash.z.ecc.android.sdk.ext.onFirst
import co.electriccoin.zcash.ui.screen.update.AppUpdateCheckerImp
import co.electriccoin.zcash.ui.screen.update.TestUpdateActivity
import co.electriccoin.zcash.ui.screen.update.model.UpdateInfo
import co.electriccoin.zcash.ui.screen.update.model.UpdateState
import com.google.android.play.core.install.model.ActivityResult
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import kotlin.test.Test
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class AppUpdateCheckerImpTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<TestUpdateActivity>()
companion object {
val context: Context = ApplicationProvider.getApplicationContext()
val updateChecker = AppUpdateCheckerImp.new()
}
private fun getAppUpdateInfoFlow(): Flow<UpdateInfo> {
@Suppress("MagicNumber")
return updateChecker.newCheckForUpdateAvailabilityFlow(
context
)
}
@Test
@MediumTest
@OptIn(ExperimentalCoroutinesApi::class)
fun check_for_update_availability_test() = runTest {
assertNotNull(updateChecker)
getAppUpdateInfoFlow().onFirst { updateInfo ->
assertTrue(
listOf(
UpdateState.Failed,
UpdateState.Prepared,
UpdateState.Done
).contains(updateInfo.state)
)
}
}
@Test
@MediumTest
@OptIn(ExperimentalCoroutinesApi::class)
fun start_update_availability_test() = runTest {
getAppUpdateInfoFlow().onFirst { updateInfo ->
// In case we get result with FAILED state, e.g. app is still not released in the Google
// Play store, there is no way to continue with the test.
if (updateInfo.state == UpdateState.Failed) {
assertNull(updateInfo.appUpdateInfo)
return@onFirst
}
assertNotNull(updateInfo.appUpdateInfo)
updateChecker.newStartUpdateFlow(
composeTestRule.activity,
updateInfo.appUpdateInfo!!
).onFirst { result ->
assertTrue {
listOf(
Activity.RESULT_OK,
Activity.RESULT_CANCELED,
ActivityResult.RESULT_IN_APP_UPDATE_FAILED
).contains(result)
}
}
}
}
}

View File

@ -0,0 +1,79 @@
package co.electriccoin.zcash.ui.screen.update.view
import android.app.Activity
import android.content.Context
import androidx.activity.ComponentActivity
import co.electriccoin.zcash.ui.screen.update.AppUpdateChecker
import co.electriccoin.zcash.ui.screen.update.fixture.UpdateInfoFixture
import co.electriccoin.zcash.ui.screen.update.model.UpdateInfo
import co.electriccoin.zcash.ui.screen.update.model.UpdateState
import co.electriccoin.zcash.util.VersionCodeCompat
import co.electriccoin.zcash.util.myPackageInfo
import com.google.android.play.core.appupdate.AppUpdateInfo
import com.google.android.play.core.appupdate.testing.FakeAppUpdateManager
import com.google.android.play.core.install.model.AppUpdateType
import kotlinx.coroutines.channels.ProducerScope
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flow
class AppUpdateCheckerMock private constructor() : AppUpdateChecker {
companion object {
private const val DEFAULT_STALENESS_DAYS = 3
fun new() = AppUpdateCheckerMock()
// used mostly for tests
val resultUpdateInfo = UpdateInfoFixture.new(
appUpdateInfo = null,
state = UpdateState.Prepared,
priority = AppUpdateChecker.Priority.HIGH,
force = true
)
}
override val stalenessDays = DEFAULT_STALENESS_DAYS
override fun newCheckForUpdateAvailabilityFlow(
context: Context
): Flow<UpdateInfo> = callbackFlow {
val fakeAppUpdateManager = FakeAppUpdateManager(context.applicationContext).also {
it.setClientVersionStalenessDays(stalenessDays)
it.setUpdateAvailable(
VersionCodeCompat.getVersionCode(context.myPackageInfo(0)).toInt(),
AppUpdateType.IMMEDIATE
)
it.setUpdatePriority(resultUpdateInfo.priority.priorityUpperBorder())
}
val appUpdateInfoTask = fakeAppUpdateManager.appUpdateInfo
appUpdateInfoTask.addOnCompleteListener { infoTask ->
emitResult(this, infoTask.result)
}
awaitClose {
// No resources to release
}
}
private fun emitResult(producerScope: ProducerScope<UpdateInfo>, info: AppUpdateInfo) {
producerScope.trySend(
UpdateInfoFixture.new(
getPriority(info.updatePriority()),
isHighPriority(info.updatePriority()),
info,
resultUpdateInfo.state
)
)
}
override fun newStartUpdateFlow(
activity: ComponentActivity,
appUpdateInfo: AppUpdateInfo
): Flow<Int> = flow {
emit(Activity.RESULT_OK)
}
}

View File

@ -0,0 +1,170 @@
package co.electriccoin.zcash.ui.screen.update.view
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
import androidx.test.filters.MediumTest
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.screen.update.AppUpdateChecker
import co.electriccoin.zcash.ui.screen.update.RestoreTag
import co.electriccoin.zcash.ui.screen.update.TestUpdateActivity
import co.electriccoin.zcash.ui.screen.update.fixture.UpdateInfoFixture
import co.electriccoin.zcash.ui.screen.update.model.UpdateInfo
import co.electriccoin.zcash.ui.screen.update.model.UpdateState
import co.electriccoin.zcash.ui.test.getStringResource
import co.electriccoin.zcash.ui.test.getStringResourceWithArgs
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
class UpdateViewTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<TestUpdateActivity>()
@Test
@MediumTest
fun later_btn_force_update_test() {
val updateInfo = UpdateInfoFixture.new(
priority = AppUpdateChecker.Priority.HIGH,
force = true,
appUpdateInfo = null,
state = UpdateState.Prepared,
)
val testSetup = newTestSetup(updateInfo)
assertEquals(0, testSetup.getOnLaterCount())
composeTestRule.clickLater()
assertEquals(0, testSetup.getOnLaterCount())
composeTestRule.activity.onBackPressed()
assertEquals(0, testSetup.getOnLaterCount())
}
@Test
@MediumTest
fun texts_force_update_test() {
val updateInfo = UpdateInfoFixture.new(
priority = AppUpdateChecker.Priority.HIGH,
force = true,
appUpdateInfo = null,
state = UpdateState.Prepared,
)
newTestSetup(updateInfo)
composeTestRule.onNodeWithText(
getStringResourceWithArgs(R.string.update_critical_header)
).also {
it.assertExists()
}
composeTestRule.onNodeWithText(
getStringResourceWithArgs(R.string.update_later_disabled_button)
).also {
it.assertExists()
}
}
@Test
@MediumTest
fun later_btn_not_force_update_test() {
val updateInfo = UpdateInfoFixture.new(
priority = AppUpdateChecker.Priority.LOW,
force = false,
appUpdateInfo = null,
state = UpdateState.Prepared,
)
val testSetup = newTestSetup(updateInfo)
assertEquals(0, testSetup.getOnLaterCount())
composeTestRule.clickLater()
assertEquals(1, testSetup.getOnLaterCount())
}
@Test
@MediumTest
fun texts_not_force_update_test() {
val updateInfo = UpdateInfoFixture.new(
priority = AppUpdateChecker.Priority.MEDIUM,
force = false,
appUpdateInfo = null,
state = UpdateState.Prepared,
)
newTestSetup(updateInfo)
composeTestRule.onNodeWithText(getStringResourceWithArgs(R.string.update_header)).also {
it.assertExists()
}
composeTestRule.onNodeWithText(
getStringResourceWithArgs(R.string.update_later_enabled_button)
).also {
it.assertExists()
}
}
@Test
@MediumTest
fun download_btn_test() {
val updateInfo = UpdateInfoFixture.new(appUpdateInfo = null)
val testSetup = newTestSetup(updateInfo)
assertEquals(0, testSetup.getOnDownloadCount())
composeTestRule.onNodeWithText(RestoreTag.PROGRESSBAR_DOWNLOADING).also {
it.assertDoesNotExist()
}
composeTestRule.clickDownload()
assertEquals(1, testSetup.getOnDownloadCount())
}
@Test
@MediumTest
fun play_store_ref_test() {
val updateInfo = UpdateInfoFixture.new(appUpdateInfo = null)
val testSetup = newTestSetup(updateInfo)
assertEquals(0, testSetup.getOnReferenceCount())
composeTestRule.onRoot().assertExists()
composeTestRule.onNodeWithText(getStringResource(R.string.update_link_text)).also {
it.assertExists()
it.performClick()
}
assertEquals(1, testSetup.getOnReferenceCount())
}
private fun newTestSetup(updateInfo: UpdateInfo) = UpdateViewTestSetup(
composeTestRule,
updateInfo
).apply {
setDefaultContent()
}
}
private fun ComposeContentTestRule.clickLater() {
onNodeWithTag(RestoreTag.BTN_LATER).also {
it.performClick()
}
}
private fun ComposeContentTestRule.clickDownload() {
onNodeWithTag(RestoreTag.BTN_DOWNLOAD).also {
it.performClick()
}
}

View File

@ -0,0 +1,71 @@
package co.electriccoin.zcash.ui.screen.update.view
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.screen.update.model.UpdateInfo
import co.electriccoin.zcash.ui.screen.update.model.UpdateState
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference
class UpdateViewTestSetup(
private val composeTestRule: ComposeContentTestRule,
private val updateInfo: UpdateInfo
) {
private val onDownloadCount = AtomicInteger(0)
private val onLaterCount = AtomicInteger(0)
private val onReferenceCount = AtomicInteger(0)
private val updateState = AtomicReference(UpdateState.Prepared)
fun getOnDownloadCount(): Int {
composeTestRule.waitForIdle()
return onDownloadCount.get()
}
fun getOnLaterCount(): Int {
composeTestRule.waitForIdle()
return onLaterCount.get()
}
fun getOnReferenceCount(): Int {
composeTestRule.waitForIdle()
return onReferenceCount.get()
}
fun getUpdateState(): UpdateState {
composeTestRule.waitForIdle()
return updateState.get()
}
fun getUpdateInfo(): UpdateInfo {
composeTestRule.waitForIdle()
return updateInfo
}
@Composable
fun getDefaultContent() {
Update(
snackbarHostState = SnackbarHostState(),
updateInfo = updateInfo,
onDownload = { newState ->
onDownloadCount.incrementAndGet()
updateState.set(newState)
},
onLater = {
onLaterCount.incrementAndGet()
},
onReference = {
onReferenceCount.incrementAndGet()
}
)
}
fun setDefaultContent() {
composeTestRule.setContent {
ZcashTheme {
getDefaultContent()
}
}
}
}

View File

@ -37,6 +37,7 @@ import co.electriccoin.zcash.ui.screen.backup.WrapBackup
import co.electriccoin.zcash.ui.screen.backup.copyToClipboard
import co.electriccoin.zcash.ui.screen.home.model.spendableBalance
import co.electriccoin.zcash.ui.screen.home.view.Home
import co.electriccoin.zcash.ui.screen.home.viewmodel.CheckUpdateViewModel
import co.electriccoin.zcash.ui.screen.home.viewmodel.SecretState
import co.electriccoin.zcash.ui.screen.home.viewmodel.WalletViewModel
import co.electriccoin.zcash.ui.screen.onboarding.view.Onboarding
@ -50,6 +51,9 @@ import co.electriccoin.zcash.ui.screen.seed.view.Seed
import co.electriccoin.zcash.ui.screen.send.view.Send
import co.electriccoin.zcash.ui.screen.settings.view.Settings
import co.electriccoin.zcash.ui.screen.support.WrapSupport
import co.electriccoin.zcash.ui.screen.update.AppUpdateCheckerImp
import co.electriccoin.zcash.ui.screen.update.WrapUpdate
import co.electriccoin.zcash.ui.screen.update.model.UpdateState
import co.electriccoin.zcash.ui.screen.wallet_address.view.WalletAddresses
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
@ -63,6 +67,16 @@ class MainActivity : ComponentActivity() {
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
val walletViewModel by viewModels<WalletViewModel>()
// TODO [#382]: https://github.com/zcash/secant-android-wallet/issues/382
// TODO [#403]: https://github.com/zcash/secant-android-wallet/issues/403
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
val checkUpdateViewModel by viewModels<CheckUpdateViewModel> {
CheckUpdateViewModel.CheckUpdateViewModelFactory(
application,
AppUpdateCheckerImp.new()
)
}
@VisibleForTesting(otherwise = VisibleForTesting.NONE)
lateinit var navControllerForTesting: NavHostController
@ -228,6 +242,7 @@ class MainActivity : ComponentActivity() {
@Suppress("LongMethod")
@Composable
@SuppressWarnings("LongMethod")
private fun Navigation() {
val navController = rememberNavController().also {
// This suppress is necessary, as this is how we set up the nav controller for tests.
@ -316,6 +331,21 @@ class MainActivity : ComponentActivity() {
)
reportFullyDrawn()
WrapCheckForUpdate()
}
}
@Composable
private fun WrapCheckForUpdate() {
// and then check for an app update asynchronously
checkUpdateViewModel.checkForAppUpdate()
val updateInfo = checkUpdateViewModel.updateInfo.collectAsState().value
updateInfo?.let {
if (it.appUpdateInfo != null && it.state == UpdateState.Prepared) {
WrapUpdate(updateInfo)
}
}
}

View File

@ -0,0 +1,44 @@
package co.electriccoin.zcash.ui.screen.home.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import cash.z.ecc.android.sdk.ext.onFirst
import co.electriccoin.zcash.ui.screen.update.AppUpdateChecker
import co.electriccoin.zcash.ui.screen.update.model.UpdateInfo
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
class CheckUpdateViewModel(
application: Application,
private val appUpdateChecker: AppUpdateChecker
) : AndroidViewModel(application) {
val updateInfo: MutableStateFlow<UpdateInfo?> = MutableStateFlow(null)
fun checkForAppUpdate() {
viewModelScope.launch {
appUpdateChecker.newCheckForUpdateAvailabilityFlow(
getApplication()
).onFirst { newInfo ->
updateInfo.value = newInfo
}
}
}
@Suppress("UNCHECKED_CAST")
class CheckUpdateViewModelFactory(
private val application: Application,
private val appUpdateChecker: AppUpdateChecker
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return if (modelClass.isAssignableFrom(CheckUpdateViewModel::class.java)) {
CheckUpdateViewModel(application, appUpdateChecker) as T
} else {
throw IllegalArgumentException("ViewModel Not Found.")
}
}
}
}

View File

@ -0,0 +1,103 @@
package co.electriccoin.zcash.ui.screen.update
import android.content.Context
import androidx.activity.ComponentActivity
import androidx.activity.viewModels
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import co.electriccoin.zcash.ui.MainActivity
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.screen.update.model.UpdateInfo
import co.electriccoin.zcash.ui.screen.update.model.UpdateState
import co.electriccoin.zcash.ui.screen.update.util.PlayStoreUtil
import co.electriccoin.zcash.ui.screen.update.view.Update
import co.electriccoin.zcash.ui.screen.update.viewmodel.UpdateViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@Composable
internal fun MainActivity.WrapUpdate(
updateInfo: UpdateInfo
) {
WrapUpdate(
activity = this,
inputUpdateInfo = updateInfo
)
}
@Composable
internal fun WrapUpdate(
activity: ComponentActivity,
inputUpdateInfo: UpdateInfo
) {
val viewModel by activity.viewModels<UpdateViewModel> {
UpdateViewModel.UpdateViewModelFactory(
activity.application,
inputUpdateInfo,
AppUpdateCheckerImp.new()
)
}
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val updateInfo = viewModel.updateInfo.collectAsState().value
when (updateInfo.state) {
UpdateState.Done, UpdateState.Canceled -> {
// just return as we are already in Home compose
return
}
UpdateState.Failed -> {
// we need to refresh AppUpdateInfo object, as it can be used only once
viewModel.checkForAppUpdate()
}
UpdateState.Prepared, UpdateState.Running -> {
// valid stages
}
}
Update(
snackbarHostState,
updateInfo,
onDownload = {
// in this state of the update we have the AppUpdateInfo filled
requireNotNull(updateInfo.appUpdateInfo)
viewModel.goForUpdate(
activity,
updateInfo.appUpdateInfo
)
},
onLater = {
viewModel.remindLater()
},
onReference = {
openPlayStoreAppPage(
activity.applicationContext,
snackbarHostState,
scope
)
}
)
}
fun openPlayStoreAppPage(
context: Context,
snackbarHostState: SnackbarHostState,
scope: CoroutineScope,
) {
val storeIntent = PlayStoreUtil.newActivityIntent(context)
runCatching {
context.startActivity(storeIntent)
}.onFailure {
scope.launch {
snackbarHostState.showSnackbar(
message = context.getString(R.string.update_unable_to_open_play_store)
)
}
}
}

View File

@ -0,0 +1,55 @@
package co.electriccoin.zcash.ui.screen.update
import android.content.Context
import androidx.activity.ComponentActivity
import co.electriccoin.zcash.ui.screen.update.model.UpdateInfo
import com.google.android.play.core.appupdate.AppUpdateInfo
import kotlinx.coroutines.flow.Flow
interface AppUpdateChecker {
val stalenessDays: Int
enum class Priority {
LOW {
override fun priorityUpperBorder() = 1
override fun belongs(actualPriority: Int) =
actualPriority <= this.priorityUpperBorder()
},
MEDIUM {
override fun priorityUpperBorder() = 3
override fun belongs(actualPriority: Int) =
actualPriority > LOW.priorityUpperBorder() && actualPriority <= this.priorityUpperBorder()
},
HIGH {
override fun priorityUpperBorder() = 5
override fun belongs(actualPriority: Int) =
actualPriority > MEDIUM.priorityUpperBorder() && actualPriority <= this.priorityUpperBorder()
};
abstract fun priorityUpperBorder(): Int
abstract fun belongs(actualPriority: Int): Boolean
}
fun getPriority(inAppUpdatePriority: Int): Priority {
return when {
Priority.LOW.belongs(inAppUpdatePriority) -> Priority.LOW
Priority.MEDIUM.belongs(inAppUpdatePriority) -> Priority.MEDIUM
Priority.HIGH.belongs(inAppUpdatePriority) -> Priority.HIGH
else -> Priority.LOW
}
}
fun isHighPriority(inAppUpdatePriority: Int): Boolean {
return getPriority(inAppUpdatePriority) == Priority.HIGH
}
fun newCheckForUpdateAvailabilityFlow(
context: Context,
): Flow<UpdateInfo>
fun newStartUpdateFlow(
activity: ComponentActivity,
appUpdateInfo: AppUpdateInfo
): Flow<Int>
}

View File

@ -0,0 +1,128 @@
package co.electriccoin.zcash.ui.screen.update
import android.content.Context
import androidx.activity.ComponentActivity
import co.electriccoin.zcash.ui.screen.update.model.UpdateInfo
import co.electriccoin.zcash.ui.screen.update.model.UpdateState
import com.google.android.play.core.appupdate.AppUpdateInfo
import com.google.android.play.core.appupdate.AppUpdateManagerFactory
import com.google.android.play.core.appupdate.AppUpdateOptions
import com.google.android.play.core.install.model.ActivityResult
import com.google.android.play.core.install.model.AppUpdateType
import com.google.android.play.core.install.model.UpdateAvailability
import kotlinx.coroutines.channels.ProducerScope
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
class AppUpdateCheckerImp private constructor() : AppUpdateChecker {
companion object {
private const val DEFAULT_STALENESS_DAYS = 3
fun new() = AppUpdateCheckerImp()
}
override val stalenessDays = DEFAULT_STALENESS_DAYS
/**
* This function checks available app update released on Google Play. It returns UpdateInfo object
* encapsulated in Flow in case of high priority update or in case of staleness days passed.
*
* For setting up the PRIORITY of an update in Google Play
* https://developer.android.com/guide/playcore/in-app-updates/kotlin-java#update-priority.
*
* @param context
*
* @return UpdateInfo object encapsulated in Flow in case of conditions succeeded
*/
override fun newCheckForUpdateAvailabilityFlow(
context: Context
): Flow<UpdateInfo> = callbackFlow {
val appUpdateInfoTask = AppUpdateManagerFactory.create(context.applicationContext).appUpdateInfo
appUpdateInfoTask.addOnCompleteListener { infoTask ->
if (!infoTask.isSuccessful) {
emitFailure(this)
return@addOnCompleteListener
}
val appUpdateInfo = infoTask.result
if (appUpdateInfo.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE &&
appUpdateInfo.isUpdateTypeAllowed(AppUpdateType.IMMEDIATE)
) {
// we force user to update immediately in case of high priority
// or in case of staleness days passed
if (isHighPriority(appUpdateInfo.updatePriority()) ||
(appUpdateInfo.clientVersionStalenessDays() ?: -1) >= stalenessDays
) {
emitSuccess(this, infoTask.result, UpdateState.Prepared)
} else {
emitSuccess(this, infoTask.result, UpdateState.Done)
}
}
}
awaitClose {
// No resources to release
}
}
private fun emitSuccess(producerScope: ProducerScope<UpdateInfo>, info: AppUpdateInfo, state: UpdateState) {
producerScope.trySend(
UpdateInfo(
getPriority(info.updatePriority()),
isHighPriority(info.updatePriority()),
info,
state
)
)
}
private fun emitFailure(producerScope: ProducerScope<UpdateInfo>) {
producerScope.trySend(
UpdateInfo(
AppUpdateChecker.Priority.LOW,
false,
null,
UpdateState.Failed
)
)
}
/**
* This function is used for triggering in-app update with IMMEDIATE app update type.
*
* The immediate update can result with these values:
* Activity.RESULT_OK: The user accepted and the update succeeded (which, in practice, your app
* never should never receive because it already updated).
* Activity.RESULT_CANCELED: The user denied or canceled the update.
* ActivityResult.RESULT_IN_APP_UPDATE_FAILED: The flow failed either during the user confirmation,
* the download, or the installation.
*
* @param activity
* @param appUpdateInfo object is necessary for starting the update process,
* for getting it see {@link #checkForUpdateAvailability()}
*/
override fun newStartUpdateFlow(
activity: ComponentActivity,
appUpdateInfo: AppUpdateInfo
): Flow<Int> = callbackFlow {
val appUpdateResultTask = AppUpdateManagerFactory.create(activity).startUpdateFlow(
appUpdateInfo,
activity,
AppUpdateOptions.defaultOptions(AppUpdateType.IMMEDIATE)
)
appUpdateResultTask.addOnCompleteListener { resultTask ->
if (resultTask.isSuccessful) {
trySend(resultTask.result)
} else {
trySend(ActivityResult.RESULT_IN_APP_UPDATE_FAILED)
}
}
awaitClose {
// No resources to release
}
}
}

View File

@ -0,0 +1,10 @@
package co.electriccoin.zcash.ui.screen.update
/**
* These are only used for automated testing.
*/
object RestoreTag {
const val BTN_LATER = "btn_later"
const val BTN_DOWNLOAD = "btn_download"
const val PROGRESSBAR_DOWNLOADING = "progressbar_downloading"
}

View File

@ -0,0 +1,25 @@
package co.electriccoin.zcash.ui.screen.update.fixture
import co.electriccoin.zcash.ui.screen.update.AppUpdateChecker
import co.electriccoin.zcash.ui.screen.update.model.UpdateInfo
import co.electriccoin.zcash.ui.screen.update.model.UpdateState
import com.google.android.play.core.appupdate.AppUpdateInfo
object UpdateInfoFixture {
val INITIAL_PRIORITY = AppUpdateChecker.Priority.LOW
val INITIAL_STATE = UpdateState.Prepared
const val INITIAL_FORCE = false
fun new(
priority: AppUpdateChecker.Priority = INITIAL_PRIORITY,
force: Boolean = INITIAL_FORCE,
appUpdateInfo: AppUpdateInfo? = null,
state: UpdateState = INITIAL_STATE
) = UpdateInfo(
priority,
force,
appUpdateInfo,
state
)
}

View File

@ -0,0 +1,19 @@
package co.electriccoin.zcash.ui.screen.update.model
import co.electriccoin.zcash.ui.screen.update.AppUpdateChecker
import com.google.android.play.core.appupdate.AppUpdateInfo
// UpdateInfo can be refactored once to have stronger representation invariants
// (eliminate the null, priority + failed state probably doesn't have much meaning, etc).
//
// sealed class UpdateInfo {
// data class Success(priority, info, state) : UpdateInfo()
// object Failed : UpdateInfo()
// }
data class UpdateInfo(
val priority: AppUpdateChecker.Priority,
val isForce: Boolean,
val appUpdateInfo: AppUpdateInfo?,
val state: UpdateState
)

View File

@ -0,0 +1,9 @@
package co.electriccoin.zcash.ui.screen.update.model
enum class UpdateState {
Prepared,
Running,
Failed,
Done,
Canceled
}

View File

@ -0,0 +1,33 @@
package co.electriccoin.zcash.ui.screen.update.util
import android.content.Context
import android.content.Intent
import android.net.Uri
object PlayStoreUtil {
const val PLAY_STORE_APP_URI = "market://details?id="
const val FLAGS = Intent.FLAG_ACTIVITY_NO_HISTORY or
Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_MULTIPLE_TASK
/**
* Returns Google Play store app intent. We assume the Play store app is installed, as we use
* In-app update API.
*
* @param context
*
* @return Intent for launching the Play Store.
*/
internal fun newActivityIntent(context: Context): Intent {
val packageName = context.packageName
val storeUri = Uri.parse("$PLAY_STORE_APP_URI$packageName")
val storeIntent = Intent(Intent.ACTION_VIEW, storeUri)
// To properly handle the Play store app backstack while navigate back to our app.
storeIntent.addFlags(FLAGS)
return storeIntent
}
}

View File

@ -0,0 +1,195 @@
package co.electriccoin.zcash.ui.screen.update.view
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SmallTopAppBar
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.imageResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.design.component.Body
import co.electriccoin.zcash.ui.design.component.GradientSurface
import co.electriccoin.zcash.ui.design.component.PrimaryButton
import co.electriccoin.zcash.ui.design.component.Reference
import co.electriccoin.zcash.ui.design.component.TertiaryButton
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.screen.update.RestoreTag
import co.electriccoin.zcash.ui.screen.update.fixture.UpdateInfoFixture
import co.electriccoin.zcash.ui.screen.update.model.UpdateInfo
import co.electriccoin.zcash.ui.screen.update.model.UpdateState
@Preview("Update")
@Composable
fun PreviewUpdate() {
ZcashTheme(darkTheme = true) {
GradientSurface {
Update(
snackbarHostState = SnackbarHostState(),
UpdateInfoFixture.new(appUpdateInfo = null),
onDownload = {},
onLater = {},
onReference = {}
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Update(
snackbarHostState: SnackbarHostState,
updateInfo: UpdateInfo,
onDownload: (state: UpdateState) -> Unit,
onLater: () -> Unit,
onReference: () -> Unit
) {
BackHandler(enabled = true) {
if (updateInfo.isForce) {
return@BackHandler
}
onLater()
}
Scaffold(
topBar = {
UpdateTopAppBar(updateInfo)
},
snackbarHost = {
SnackbarHost(snackbarHostState)
},
bottomBar = {
UpdateBottomAppBar(
updateInfo,
onDownload,
onLater
)
}
) {
UpdateContentNormal(onReference)
}
UpdateOverlayRunning(updateInfo)
}
@Suppress("MagicNumber")
@Composable
fun UpdateOverlayRunning(updateInfo: UpdateInfo) {
if (updateInfo.state == UpdateState.Running) {
Column(
Modifier
.background(ZcashTheme.colors.overlay.copy(0.5f))
.fillMaxWidth()
.fillMaxHeight()
.testTag(RestoreTag.PROGRESSBAR_DOWNLOADING),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
CircularProgressIndicator()
}
}
}
@Composable
private fun UpdateTopAppBar(updateInfo: UpdateInfo) {
SmallTopAppBar(
title = {
Text(
text = stringResource(
updateInfo.isForce.let { force ->
if (force) {
R.string.update_critical_header
} else {
R.string.update_header
}
}
)
)
},
)
}
@Composable
private fun UpdateBottomAppBar(
updateInfo: UpdateInfo,
onDownload: (state: UpdateState) -> Unit,
onLater: () -> Unit
) {
Column {
PrimaryButton(
onClick = { onDownload(UpdateState.Running) },
text = stringResource(R.string.update_download_button),
modifier = Modifier
.fillMaxWidth()
.testTag(RestoreTag.BTN_DOWNLOAD),
enabled = updateInfo.state != UpdateState.Running
)
TertiaryButton(
onClick = onLater,
text = stringResource(
updateInfo.isForce.let { force ->
if (force) {
R.string.update_later_disabled_button
} else {
R.string.update_later_enabled_button
}
}
),
modifier = Modifier
.fillMaxWidth()
.testTag(RestoreTag.BTN_LATER),
enabled = !updateInfo.isForce && updateInfo.state != UpdateState.Running
)
}
}
@Composable
private fun UpdateContentNormal(
onReference: () -> Unit
) {
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
// TODO [#17]: This suppression and magic number will get replaced once we have real assets
@Suppress("MagicNumber")
Image(
ImageBitmap.imageResource(id = R.drawable.update_main_graphic),
contentDescription = stringResource(id = R.string.update_image_content_description),
Modifier.fillMaxSize(0.50f)
)
Body(
text = stringResource(id = R.string.update_description),
modifier = Modifier
.wrapContentHeight()
.align(Alignment.CenterHorizontally)
)
Reference(
text = stringResource(id = R.string.update_link_text),
modifier = Modifier
.wrapContentHeight()
.align(Alignment.CenterHorizontally),
onClick = {
onReference()
}
)
}
}

View File

@ -0,0 +1,82 @@
package co.electriccoin.zcash.ui.screen.update.viewmodel
import android.app.Activity
import android.app.Application
import androidx.activity.ComponentActivity
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import cash.z.ecc.android.sdk.ext.onFirst
import co.electriccoin.zcash.ui.screen.update.AppUpdateChecker
import co.electriccoin.zcash.ui.screen.update.model.UpdateInfo
import co.electriccoin.zcash.ui.screen.update.model.UpdateState
import com.google.android.play.core.appupdate.AppUpdateInfo
import com.google.android.play.core.install.model.ActivityResult
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
class UpdateViewModel(
application: Application,
updateInfo: UpdateInfo,
private val appUpdateChecker: AppUpdateChecker
) : AndroidViewModel(application) {
val updateInfo: MutableStateFlow<UpdateInfo> = MutableStateFlow(updateInfo)
fun checkForAppUpdate() {
viewModelScope.launch {
appUpdateChecker.newCheckForUpdateAvailabilityFlow(
getApplication()
).onFirst { newInfo ->
updateInfo.value = newInfo
}
}
}
fun goForUpdate(
activity: ComponentActivity,
appUpdateInfo: AppUpdateInfo
) {
if (updateInfo.value.state == UpdateState.Running) {
return
}
updateInfo.value = updateInfo.value.copy(state = UpdateState.Running)
viewModelScope.launch {
appUpdateChecker.newStartUpdateFlow(
activity,
appUpdateInfo
).onFirst { resultCode ->
val state = when (resultCode) {
Activity.RESULT_OK -> UpdateState.Done
Activity.RESULT_CANCELED -> UpdateState.Canceled
ActivityResult.RESULT_IN_APP_UPDATE_FAILED -> UpdateState.Failed
else -> UpdateState.Prepared
}
updateInfo.value = updateInfo.value.copy(state = state)
}
}
}
fun remindLater() {
// for mvp we just return user back to the previous screen
updateInfo.value = updateInfo.value.copy(state = UpdateState.Canceled)
}
@Suppress("UNCHECKED_CAST")
class UpdateViewModelFactory(
private val application: Application,
private val updateInfo: UpdateInfo,
private val appUpdateChecker: AppUpdateChecker
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return if (modelClass.isAssignableFrom(UpdateViewModel::class.java)) {
UpdateViewModel(application, updateInfo, appUpdateChecker) as T
} else {
throw IllegalArgumentException("ViewModel Not Found.")
}
}
}
}

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<bitmap xmlns:android="http://schemas.android.com/apk/res/android"
android:src="@drawable/onboarding_1_shielded">
</bitmap>

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="update_header">Update available!</string>
<string name="update_critical_header">Critical update required!</string>
<string name="update_image_content_description"></string>
<string name="update_description">There is a new version of the app available.</string>
<string name="update_link_text">Learn more about this update here.</string>
<string name="update_download_button">Download Update</string>
<string name="update_later_enabled_button">Remind me later</string>
<string name="update_later_disabled_button">This can not be skipped.</string>
<string name="update_unable_to_open_play_store">Unable to launch Google Play store app.</string>
</resources>