[#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:
parent
0f5a094a92
commit
665042e6d9
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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)
|
||||
) {
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
|
@ -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
|
||||
)
|
|
@ -0,0 +1,9 @@
|
|||
package co.electriccoin.zcash.ui.screen.update.model
|
||||
|
||||
enum class UpdateState {
|
||||
Prepared,
|
||||
Running,
|
||||
Failed,
|
||||
Done,
|
||||
Canceled
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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>
|
Loading…
Reference in New Issue