1
0
Fork 0
mirror of https://github.com/bitwarden/android.git synced 2025-02-22 08:39:01 +03:00

PM-15383 PM-15381 - Show the google play review prompt ()

Co-authored-by: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com>
This commit is contained in:
Dave Severns 2024-12-20 10:30:39 -05:00 committed by GitHub
parent 843247b02d
commit 6c355ae5b7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 201 additions and 3 deletions
README.md
app
build.gradle.kts
src
fdroid/java/com/x8bit/bitwarden/ui/platform/manager/review
main
java/com/x8bit/bitwarden/ui
platform
vault/feature/vault
res/values
standard/java/com/x8bit/bitwarden/ui/platform/manager/review
test/java/com/x8bit/bitwarden/ui/vault/feature/vault
gradle

View file

@ -132,6 +132,11 @@ The following is a list of all third-party dependencies included as part of the
- https://github.com/firebase/firebase-android-sdk
- Purpose: SDK for crash and non-fatal error reporting. (**NOTE:** This dependency is not included in builds distributed via F-Droid.)
- License: Apache 2.0
- **Google Play Reviews**
- https://developer.android.com/reference/com/google/android/play/core/release-notes
- Purpose: On standard builds provide an interface to add a review for the password manager application in Google Play.
- License: Apache 2.0
- **Glide**
- https://github.com/bumptech/glide

View file

@ -268,6 +268,7 @@ dependencies {
standardImplementation(libs.google.firebase.cloud.messaging)
standardImplementation(platform(libs.google.firebase.bom))
standardImplementation(libs.google.firebase.crashlytics)
standardImplementation(libs.google.play.review)
testImplementation(libs.androidx.compose.ui.test)
testImplementation(libs.google.hilt.android.testing)

View file

@ -0,0 +1,12 @@
package com.x8bit.bitwarden.ui.platform.manager.review
import android.app.Activity
/**
* No-op implementation of [AppReviewManager] for F-Droid builds.
*/
class AppReviewManagerImpl(
activity: Activity,
) : AppReviewManager {
override fun promptForReview() = Unit
}

View file

@ -25,6 +25,8 @@ import com.x8bit.bitwarden.ui.platform.manager.nfc.NfcManager
import com.x8bit.bitwarden.ui.platform.manager.nfc.NfcManagerImpl
import com.x8bit.bitwarden.ui.platform.manager.permissions.PermissionsManager
import com.x8bit.bitwarden.ui.platform.manager.permissions.PermissionsManagerImpl
import com.x8bit.bitwarden.ui.platform.manager.review.AppReviewManager
import com.x8bit.bitwarden.ui.platform.manager.review.AppReviewManagerImpl
/**
* Helper [Composable] that wraps a [content] and provides manager classes via [CompositionLocal].
@ -48,6 +50,7 @@ fun LocalManagerProvider(
LocalBiometricsManager provides BiometricsManagerImpl(activity),
LocalNfcManager provides NfcManagerImpl(activity),
LocalFido2CompletionManager provides fido2CompletionManager,
LocalAppReviewManager provides AppReviewManagerImpl(activity),
) {
content()
}
@ -88,7 +91,17 @@ val LocalNfcManager: ProvidableCompositionLocal<NfcManager> = compositionLocalOf
error("CompositionLocal NfcManager not present")
}
/**
* Provides access to the FIDO2 completion manager throughout the app.
*/
val LocalFido2CompletionManager: ProvidableCompositionLocal<Fido2CompletionManager> =
compositionLocalOf {
error("CompositionLocal Fido2CompletionManager not present")
}
/**
* Provides access to the app review manager throughout the app.
*/
val LocalAppReviewManager: ProvidableCompositionLocal<AppReviewManager> = compositionLocalOf {
error("CompositionLocal AppReviewManager not present")
}

View file

@ -0,0 +1,12 @@
package com.x8bit.bitwarden.ui.platform.manager.review
/**
* Manager for prompting the user to review the app.
*/
interface AppReviewManager {
/**
* Prompts the user to review the app.
*/
fun promptForReview()
}

View file

@ -15,6 +15,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
@ -25,9 +26,11 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.LivecycleEventEffect
import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.x8bit.bitwarden.ui.platform.components.account.BitwardenAccountActionItem
import com.x8bit.bitwarden.ui.platform.components.account.BitwardenAccountSwitcher
@ -52,22 +55,28 @@ import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarHost
import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarHostState
import com.x8bit.bitwarden.ui.platform.components.snackbar.rememberBitwardenSnackbarHostState
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.x8bit.bitwarden.ui.platform.composition.LocalAppReviewManager
import com.x8bit.bitwarden.ui.platform.composition.LocalExitManager
import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager
import com.x8bit.bitwarden.ui.platform.feature.search.model.SearchType
import com.x8bit.bitwarden.ui.platform.manager.exit.ExitManager
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.platform.manager.review.AppReviewManager
import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelay
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.model.ListingItemOverflowAction
import com.x8bit.bitwarden.ui.vault.feature.vault.handlers.VaultHandlers
import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
private const val APP_REVIEW_DELAY = 3000L
/**
* The vault screen for the application.
*/
@Suppress("LongMethod")
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun VaultScreen(
viewModel: VaultViewModel = hiltViewModel(),
@ -81,6 +90,7 @@ fun VaultScreen(
onNavigateToImportLogins: (SnackbarRelay) -> Unit,
exitManager: ExitManager = LocalExitManager.current,
intentManager: IntentManager = LocalIntentManager.current,
appReviewManager: AppReviewManager = LocalAppReviewManager.current,
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val context = LocalContext.current
@ -92,6 +102,24 @@ fun VaultScreen(
},
)
val snackbarHostState = rememberBitwardenSnackbarHostState()
LivecycleEventEffect { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> {
viewModel.trySendAction(VaultAction.LifecycleResumed)
}
else -> Unit
}
}
val scope = rememberCoroutineScope()
val launchPrompt = remember {
{
scope.launch {
delay(APP_REVIEW_DELAY)
appReviewManager.promptForReview()
}
}
}
EventsEffect(viewModel = viewModel) { event ->
when (event) {
VaultEvent.NavigateToAddItemScreen -> onNavigateToVaultAddItemScreen()
@ -124,6 +152,9 @@ fun VaultScreen(
}
is VaultEvent.ShowSnackbar -> snackbarHostState.showSnackbar(event.data)
VaultEvent.PromptForAppReview -> {
launchPrompt.invoke()
}
}
}
val vaultHandlers = remember(viewModel) { VaultHandlers.create(viewModel) }

View file

@ -11,6 +11,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.ReviewPromptManager
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
@ -75,7 +76,8 @@ class VaultViewModel @Inject constructor(
private val vaultRepository: VaultRepository,
private val firstTimeActionManager: FirstTimeActionManager,
private val snackbarRelayManager: SnackbarRelayManager,
featureFlagManager: FeatureFlagManager,
private val reviewPromptManager: ReviewPromptManager,
private val featureFlagManager: FeatureFlagManager,
) : BaseViewModel<VaultState, VaultEvent, VaultAction>(
initialState = run {
val userState = requireNotNull(authRepository.userStateFlow.value)
@ -201,6 +203,15 @@ class VaultViewModel @Inject constructor(
is VaultAction.Internal -> handleInternalAction(action)
VaultAction.DismissImportActionCard -> handleDismissImportActionCard()
VaultAction.ImportActionCardClick -> handleImportActionCardClick()
VaultAction.LifecycleResumed -> handleLifecycleResumed()
}
}
private fun handleLifecycleResumed() {
val shouldShowPrompt = reviewPromptManager.shouldPromptForAppReview() &&
featureFlagManager.getFeatureFlag(FlagKey.AppReviewPrompt)
if (shouldShowPrompt) {
sendEvent(VaultEvent.PromptForAppReview)
}
}
@ -1101,6 +1112,11 @@ sealed class VaultEvent {
*/
data object NavigateToImportLogins : VaultEvent()
/**
* Indicates that we should prompt the user for app review.
*/
data object PromptForAppReview : VaultEvent()
/**
* Show a toast with the given [message].
*/
@ -1275,6 +1291,11 @@ sealed class VaultAction {
val password: String,
) : VaultAction()
/**
* The lifecycle of the VaultScreen has entered a resumed state.
*/
data object LifecycleResumed : VaultAction()
/**
* Models actions that the [VaultViewModel] itself might send.
*/

View file

@ -1115,4 +1115,5 @@ Do you want to switch to this account?</string>
<string name="check_out_the_passphrase_generator">"Check out the passphrase generator"</string>
<string name="copied_to_clipboard">Copied to clipboard.</string>
<string name="we_couldnt_verify_the_servers_certificate">We couldnt verify the servers certificate. The certificate chain or proxy settings on your device or your Bitwarden server may not be set up correctly.</string>
<string name="review_flow_launched">Review flow launched!</string>
</resources>

View file

@ -0,0 +1,39 @@
package com.x8bit.bitwarden.ui.platform.manager.review
import android.app.Activity
import android.widget.Toast
import com.google.android.play.core.review.ReviewManagerFactory
import com.x8bit.bitwarden.BuildConfig
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import timber.log.Timber
/**
* Default implementation of [AppReviewManager].
*/
@OmitFromCoverage
class AppReviewManagerImpl(
private val activity: Activity,
) : AppReviewManager {
override fun promptForReview() {
val manager = ReviewManagerFactory.create(activity)
val request = manager.requestReviewFlow()
request.addOnCompleteListener { task ->
if (task.isSuccessful) {
val reviewInfo = task.result
manager.launchReviewFlow(activity, reviewInfo)
} else {
Timber.e(task.exception, "Failed to launch review flow.")
}
}
if (BuildConfig.DEBUG) {
Toast
.makeText(
activity,
activity.getString(R.string.review_flow_launched),
Toast.LENGTH_SHORT,
)
.show()
}
}
}

View file

@ -23,12 +23,14 @@ import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.data.platform.repository.util.baseIconUrl
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.data.util.advanceTimeByAndRunCurrent
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary
import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarData
import com.x8bit.bitwarden.ui.platform.manager.exit.ExitManager
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.platform.manager.review.AppReviewManager
import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelay
import com.x8bit.bitwarden.ui.util.assertLockOrLogoutDialogIsDisplayed
import com.x8bit.bitwarden.ui.util.assertLogoutConfirmationDialogIsDisplayed
@ -50,7 +52,9 @@ import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterData
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType
import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableStateFlow
@ -73,7 +77,9 @@ class VaultScreenTest : BaseComposeTest() {
private var onNavigateToSearchScreen = false
private val exitManager = mockk<ExitManager>(relaxed = true)
private val intentManager = mockk<IntentManager>(relaxed = true)
private val appReviewManager: AppReviewManager = mockk {
every { promptForReview() } just runs
}
private val mutableEventFlow = bufferedMutableSharedFlow<VaultEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val viewModel = mockk<VaultViewModel>(relaxed = true) {
@ -99,6 +105,7 @@ class VaultScreenTest : BaseComposeTest() {
},
exitManager = exitManager,
intentManager = intentManager,
appReviewManager = appReviewManager,
)
}
}
@ -1274,6 +1281,18 @@ class VaultScreenTest : BaseComposeTest() {
.onNodeWithText("mockSshKey")
.isNotDisplayed()
}
@Test
fun `LifecycleResumed action is sent when the screen is resumed`() {
verify { viewModel.trySendAction(VaultAction.LifecycleResumed) }
}
@Test
fun `PromptForAppReview triggers app review manager`() {
mutableEventFlow.tryEmit(VaultEvent.PromptForAppReview)
dispatcher.advanceTimeByAndRunCurrent(4000L)
verify(exactly = 1) { appReviewManager.promptForReview() }
}
}
private val ACTIVE_ACCOUNT_SUMMARY = AccountSummary(

View file

@ -12,6 +12,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.ReviewPromptManager
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager
import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState
@ -143,6 +144,7 @@ class VaultViewModelTest : BaseViewModelTest() {
getFeatureFlag(FlagKey.SshKeyCipherItems)
} returns mutableSshKeyVaultItemsEnabledFlow.value
}
private val reviewPromptManager: ReviewPromptManager = mockk()
@Test
fun `initial state should be correct and should trigger a syncIfNecessary call`() {
@ -1812,6 +1814,45 @@ class VaultViewModelTest : BaseViewModelTest() {
}
}
@Suppress("MaxLineLength")
@Test
fun `when LifecycleResumed action is handled, PromptForAppReview is sent if flag is enabled and criteria is met`() =
runTest {
every { featureFlagManager.getFeatureFlag(FlagKey.AppReviewPrompt) } returns true
every { reviewPromptManager.shouldPromptForAppReview() } returns true
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(VaultAction.LifecycleResumed)
assertEquals(VaultEvent.PromptForAppReview, awaitItem())
}
}
@Suppress("MaxLineLength")
@Test
fun `when LifecycleResumed action is handled, PromptForAppReview is not sent if flag is disabled`() =
runTest {
every { featureFlagManager.getFeatureFlag(FlagKey.AppReviewPrompt) } returns false
every { reviewPromptManager.shouldPromptForAppReview() } returns true
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(VaultAction.LifecycleResumed)
expectNoEvents()
}
}
@Suppress("MaxLineLength")
@Test
fun `when LifecycleResumed action is handled, PromptForAppReview is not sent if criteria is not met`() =
runTest {
every { featureFlagManager.getFeatureFlag(FlagKey.AppReviewPrompt) } returns true
every { reviewPromptManager.shouldPromptForAppReview() } returns false
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(VaultAction.LifecycleResumed)
expectNoEvents()
}
}
private fun createViewModel(): VaultViewModel =
VaultViewModel(
authRepository = authRepository,
@ -1824,6 +1865,7 @@ class VaultViewModelTest : BaseViewModelTest() {
featureFlagManager = featureFlagManager,
firstTimeActionManager = firstTimeActionManager,
snackbarRelayManager = snackbarRelayManager,
reviewPromptManager = reviewPromptManager,
)
}

View file

@ -32,6 +32,7 @@ detekt = "1.23.7"
firebaseBom = "33.7.0"
glide = "1.0.0-beta01"
googleServices = "4.4.2"
googleReview = "2.0.2"
hilt = "2.53.1"
junit5 = "5.11.3"
jvmTarget = "17"
@ -96,6 +97,7 @@ google-firebase-crashlytics = { module = "com.google.firebase:firebase-crashlyti
google-hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
google-hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" }
google-hilt-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" }
google-play-review = { module = "com.google.android.play:review", version.ref = "googleReview"}
junit-junit5 = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit5" }
junit-vintage = { module = "org.junit.vintage:junit-vintage-engine", version.ref = "junit5" }
kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" }