diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 656dca40..510df9cc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -31,7 +31,7 @@ android { applicationId = "ltd.grunt.brainwallet" minSdk = 29 targetSdk = 35 - versionCode = 202506346 + versionCode = 202506347 versionName = "v4.11.0" multiDexEnabled = true base.archivesName.set("${defaultConfig.versionName}(${defaultConfig.versionCode})") diff --git a/app/src/main/java/com/brainwallet/appreview/InAppReviewService.kt b/app/src/main/java/com/brainwallet/appreview/InAppReviewService.kt index dbfe628f..503742be 100644 --- a/app/src/main/java/com/brainwallet/appreview/InAppReviewService.kt +++ b/app/src/main/java/com/brainwallet/appreview/InAppReviewService.kt @@ -20,7 +20,7 @@ class InAppReviewService( public fun showInAppReviewDialogIfNeeded() { val activity = activityProvider() ?: return if (!BRSharedPrefs.isInAppReviewDone(app) && - BRSharedPrefs.getSendTransactionCount(app) > 2 + BRSharedPrefs.getSendTransactionCount(app) > 1 ) { val manager = ReviewManagerFactory.create(app) val request = manager.requestReviewFlow() diff --git a/app/src/main/java/com/brainwallet/di/AppModule.kt b/app/src/main/java/com/brainwallet/di/AppModule.kt index b06bf48c..d9274ac0 100644 --- a/app/src/main/java/com/brainwallet/di/AppModule.kt +++ b/app/src/main/java/com/brainwallet/di/AppModule.kt @@ -1,8 +1,11 @@ package com.brainwallet.di +import android.app.Activity import android.content.Context import android.content.SharedPreferences +import com.brainwallet.BrainwalletApp import com.brainwallet.BuildConfig +import com.brainwallet.appreview.InAppReviewService import com.brainwallet.data.repository.TxRepository import com.brainwallet.data.source.RemoteApiSource import com.brainwallet.tools.sqlite.CurrencyDataSource @@ -45,6 +48,7 @@ object AppModule { single { provideSharedPreferences(context = androidApplication()) } single { TxRepositoryImpl(get()) } single { SyncThreadManager(get(), get()) } + single { InAppReviewService(androidApplication()) { BrainwalletApp.breadContext as? Activity } } } private fun provideSharedPreferences( diff --git a/app/src/main/java/com/brainwallet/ui/bentosections/balancebento/BalanceBentoScreen.kt b/app/src/main/java/com/brainwallet/ui/bentosections/balancebento/BalanceBentoScreen.kt index dbc05bb8..8537d17e 100644 --- a/app/src/main/java/com/brainwallet/ui/bentosections/balancebento/BalanceBentoScreen.kt +++ b/app/src/main/java/com/brainwallet/ui/bentosections/balancebento/BalanceBentoScreen.kt @@ -32,6 +32,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -51,6 +52,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.viewmodel.compose.viewModel import com.brainwallet.R +import com.brainwallet.appreview.InAppReviewService import com.brainwallet.presenter.entities.TxItem import com.brainwallet.tools.manager.AnalyticsManager import com.brainwallet.ui.screens.main.MainScreenEvent @@ -61,6 +63,9 @@ import com.brainwallet.ui.theme.IBMPlexSans import com.brainwallet.ui.theme.balanceGameBentoSurface import com.brainwallet.ui.theme.blurWhen import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import java.math.BigDecimal @@ -107,8 +112,10 @@ fun BalanceBentoScreen( onDebugStatusUpdate: () -> Unit = {}, onDebugTxAdded: () -> Unit = {}, onDebugBalanceChanged: () -> Unit = {}, + inAppReviewService: InAppReviewService = koinInject(), ) { val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() val infiniteTransition = rememberInfiniteTransition() var previousBalance by remember { mutableStateOf(BigDecimal.ZERO) } val boingAudioPlayer = remember { MediaPlayer.create(context, R.raw.boingspringmouthharp042013) } @@ -183,6 +190,12 @@ fun BalanceBentoScreen( isSwapped = !isSwapped boingAudioPlayer.start() AnalyticsManager.logCustomEventWithParams("did_toggle_fiat_ltc", null) + if (isSwapped) { + coroutineScope.launch { + delay(800L) + inAppReviewService.showInAppReviewDialogIfNeeded() + } + } } ) { // ──────── MY BALANCE LABEL ──────── @@ -255,9 +268,14 @@ fun BalanceBentoScreen( .clickable( interactionSource = remember { MutableInteractionSource() }, ) { - AnalyticsManager.logCustomEventWithParams("did_toggle_balance_visibility", null) - onEvent(BalanceBentoEvent.OnToggleBalanceVisibility) + AnalyticsManager.logCustomEventWithParams("did_toggle_balance_visibility", null) + if (state.balanceHidden) { + coroutineScope.launch { + delay(800L) + inAppReviewService.showInAppReviewDialogIfNeeded() + } + } } .blurWhen(!mainState.isInternetReachable), painter = iconImage, diff --git a/app/src/main/java/com/brainwallet/ui/bentosections/tutorials/send/TutorialSendPage2.kt b/app/src/main/java/com/brainwallet/ui/bentosections/tutorials/send/TutorialSendPage2.kt index bd3bab4d..53052134 100644 --- a/app/src/main/java/com/brainwallet/ui/bentosections/tutorials/send/TutorialSendPage2.kt +++ b/app/src/main/java/com/brainwallet/ui/bentosections/tutorials/send/TutorialSendPage2.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color @@ -21,15 +22,21 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.brainwallet.appreview.InAppReviewService import com.brainwallet.ui.composable.CalloutWithPointers import com.brainwallet.ui.composable.Pointer import com.brainwallet.ui.theme.IBMPlexSans +import org.koin.compose.koinInject @Composable fun TutorialSendPage2( darkMode: Boolean, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + inAppReviewService: InAppReviewService = koinInject() ) { + LaunchedEffect(Unit) { + inAppReviewService.showInAppReviewDialogIfNeeded() + } Box( modifier = Modifier .fillMaxWidth() diff --git a/app/src/main/java/com/brainwallet/ui/bentosections/tutorials/walkthrough/TutorialWalkthroughPage3.kt b/app/src/main/java/com/brainwallet/ui/bentosections/tutorials/walkthrough/TutorialWalkthroughPage3.kt index 560f04d9..0be51438 100644 --- a/app/src/main/java/com/brainwallet/ui/bentosections/tutorials/walkthrough/TutorialWalkthroughPage3.kt +++ b/app/src/main/java/com/brainwallet/ui/bentosections/tutorials/walkthrough/TutorialWalkthroughPage3.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color @@ -20,15 +21,22 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.brainwallet.appreview.InAppReviewService import com.brainwallet.ui.composable.CalloutWithPointers import com.brainwallet.ui.composable.Pointer import com.brainwallet.ui.theme.IBMPlexSans +import org.koin.compose.koinInject @Composable fun TutorialWalkthroughPage3( darkMode: Boolean, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + inAppReviewService: InAppReviewService = koinInject() ) { + LaunchedEffect(Unit) { + inAppReviewService.showInAppReviewDialogIfNeeded() + } + Box( modifier = Modifier .fillMaxWidth() diff --git a/app/src/main/java/com/brainwallet/ui/screens/gamehub/GameHubViewModel.kt b/app/src/main/java/com/brainwallet/ui/screens/gamehub/GameHubViewModel.kt index 2da1e95a..2b2d061b 100644 --- a/app/src/main/java/com/brainwallet/ui/screens/gamehub/GameHubViewModel.kt +++ b/app/src/main/java/com/brainwallet/ui/screens/gamehub/GameHubViewModel.kt @@ -7,11 +7,15 @@ import android.graphics.BitmapFactory import android.os.Bundle import android.os.Environment import androidx.core.content.FileProvider +import androidx.lifecycle.viewModelScope +import com.brainwallet.appreview.InAppReviewService import com.brainwallet.tools.manager.AnalyticsManager import com.brainwallet.ui.BrainwalletViewModel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive @@ -22,6 +26,7 @@ import java.io.FileOutputStream @KoinViewModel class GameHubViewModel( private val app: Application, + private val inAppReviewService: InAppReviewService, ) : BrainwalletViewModel() { private val _state = MutableStateFlow(GameHubState()) @@ -115,6 +120,12 @@ class GameHubViewModel( } ) } + + AnalyticsManager.logCustomEventWithParams("did_play_game", null) + viewModelScope.launch { + delay(800L) + inAppReviewService.showInAppReviewDialogIfNeeded() + } } } } diff --git a/app/src/main/java/com/brainwallet/ui/screens/send/SendViewModel.kt b/app/src/main/java/com/brainwallet/ui/screens/send/SendViewModel.kt index a3dc3275..f5696037 100644 --- a/app/src/main/java/com/brainwallet/ui/screens/send/SendViewModel.kt +++ b/app/src/main/java/com/brainwallet/ui/screens/send/SendViewModel.kt @@ -3,6 +3,7 @@ package com.brainwallet.ui.screens.send import android.app.Application import androidx.lifecycle.viewModelScope import com.brainwallet.R +import com.brainwallet.appreview.InAppReviewService import com.brainwallet.constants.BWConstants import com.brainwallet.data.repository.SettingRepository import com.brainwallet.data.repository.TxRepository @@ -46,6 +47,7 @@ class SendViewModel( private val txRepository: TxRepository, private val settingRepository: SettingRepository, private val currencyDataGetter: CurrencyDataGetter, + private val inAppReviewService: InAppReviewService, private val isWalletCreated: () -> Boolean = { BRWalletManager.getInstance().isCreated() }, private val validateAddress: (String) -> Boolean = { BRWalletManager.getInstance().validateAddress(it) }, private val getBalance: () -> Long = { BRWalletManager.getInstance().getBalance(app) }, @@ -57,7 +59,6 @@ class SendViewModel( val state: StateFlow = _state.asStateFlow() private val _effect = MutableSharedFlow(extraBufferCapacity = 1) val effect: SharedFlow = _effect.asSharedFlow() - init { viewModelScope.launch { settingRepository.currentSettings.collect { currentSettings -> @@ -281,8 +282,10 @@ class SendViewModel( _effect.emit(SendEffect.DismissSheet) AnalyticsManager.logCustomEvent(BWConstants._20191105_DSL) BRSharedPrefs.incrementSendTransactionCount(app) + delay(800L) + inAppReviewService.showInAppReviewDialogIfNeeded() } - is BWSendResult.Error.InsufficientFunds -> { + is Error.InsufficientFunds -> { _state.update { it.copy( errorResultString = String.format( @@ -293,7 +296,7 @@ class SendViewModel( ) } } - is BWSendResult.Error.AmountTooSmall -> { + is Error.AmountTooSmall -> { _state.update { it.copy( errorResultString = String.format( @@ -304,9 +307,9 @@ class SendViewModel( ) } } - BWSendResult.Error.AlreadySending, - BWSendResult.Error.TimedOut, - is BWSendResult.Error.Unknown -> { + Error.AlreadySending, + Error.TimedOut, + is Error.Unknown -> { Timber.e("Send unknown error: $result") _state.update { it.copy( diff --git a/app/src/test/java/com/brainwallet/appreview/InAppReviewServiceTest.kt b/app/src/test/java/com/brainwallet/appreview/InAppReviewServiceTest.kt new file mode 100644 index 00000000..0f0cd15c --- /dev/null +++ b/app/src/test/java/com/brainwallet/appreview/InAppReviewServiceTest.kt @@ -0,0 +1,157 @@ +package com.brainwallet.appreview + +import android.app.Activity +import android.app.Application +import com.brainwallet.constants.BWConstants +import com.brainwallet.tools.manager.AnalyticsManager +import com.brainwallet.tools.manager.BRSharedPrefs +import com.google.android.gms.tasks.OnCompleteListener +import com.google.android.gms.tasks.Task +import com.google.android.play.core.review.ReviewInfo +import com.google.android.play.core.review.ReviewManager +import com.google.android.play.core.review.ReviewManagerFactory +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import io.mockk.verify +import org.junit.After +import org.junit.Before +import org.junit.Test +import java.lang.Void + +class InAppReviewServiceTest { + + private lateinit var app: Application + private lateinit var activity: Activity + private lateinit var manager: ReviewManager + private lateinit var reviewInfo: ReviewInfo + private lateinit var requestTask: Task + private lateinit var launchTask: Task + + private fun service(activityResult: Activity? = activity) = + InAppReviewService(app) { activityResult } + + @Before + fun setUp() { + app = mockk(relaxed = true) + activity = mockk(relaxed = true) + manager = mockk() + reviewInfo = mockk() + requestTask = mockk() + launchTask = mockk() + + mockkStatic(ReviewManagerFactory::class) + every { ReviewManagerFactory.create(app) } returns manager + every { manager.requestReviewFlow() } returns requestTask + every { manager.launchReviewFlow(activity, reviewInfo) } returns launchTask + + // Resolve both Play Core tasks synchronously so the flow under test + // completes deterministically, without needing real Play Store services. + every { requestTask.addOnCompleteListener(any()) } answers { + firstArg>().onComplete(requestTask) + requestTask + } + every { launchTask.addOnCompleteListener(any()) } answers { + firstArg>().onComplete(launchTask) + launchTask + } + + mockkStatic(BRSharedPrefs::class) + mockkStatic(AnalyticsManager::class) + every { AnalyticsManager.logCustomEvent(any()) } returns Unit + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `given no current activity, when showInAppReviewDialogIfNeeded, then does not request a review`() { + every { BRSharedPrefs.isInAppReviewDone(app) } returns false + every { BRSharedPrefs.getSendTransactionCount(app) } returns 5 + + service(activityResult = null).showInAppReviewDialogIfNeeded() + + verify(exactly = 0) { ReviewManagerFactory.create(any()) } + } + + @Test + fun `given review already done, when showInAppReviewDialogIfNeeded, then does not request a review`() { + every { BRSharedPrefs.isInAppReviewDone(app) } returns true + every { BRSharedPrefs.getSendTransactionCount(app) } returns 10 + + service().showInAppReviewDialogIfNeeded() + + verify(exactly = 0) { ReviewManagerFactory.create(any()) } + } + + @Test + fun `given send count at threshold, when showInAppReviewDialogIfNeeded, then does not request a review`() { + every { BRSharedPrefs.isInAppReviewDone(app) } returns false + every { BRSharedPrefs.getSendTransactionCount(app) } returns 1 + + service().showInAppReviewDialogIfNeeded() + + verify(exactly = 0) { ReviewManagerFactory.create(any()) } + } + + @Test + fun `given send count below threshold, when showInAppReviewDialogIfNeeded, then does not request a review`() { + every { BRSharedPrefs.isInAppReviewDone(app) } returns false + every { BRSharedPrefs.getSendTransactionCount(app) } returns 0 + + service().showInAppReviewDialogIfNeeded() + + verify(exactly = 0) { ReviewManagerFactory.create(any()) } + } + + @Test + fun `given eligible user and successful review flow, when showInAppReviewDialogIfNeeded, then marks review done and logs both analytics events`() { + every { BRSharedPrefs.isInAppReviewDone(app) } returns false + every { BRSharedPrefs.getSendTransactionCount(app) } returns 5 + every { requestTask.isSuccessful() } returns true + every { requestTask.getResult() } returns reviewInfo + every { launchTask.isSuccessful() } returns true + every { BRSharedPrefs.inAppReviewDone(app) } returns Unit + + service().showInAppReviewDialogIfNeeded() + + verify { manager.launchReviewFlow(activity, reviewInfo) } + verify { BRSharedPrefs.inAppReviewDone(app) } + verify { AnalyticsManager.logCustomEvent(BWConstants._20241006_DRR) } + verify { AnalyticsManager.logCustomEvent(BWConstants._20241006_UCR) } + } + + @Test + fun `given request review flow fails, when showInAppReviewDialogIfNeeded, then does not launch review flow or mark done`() { + every { BRSharedPrefs.isInAppReviewDone(app) } returns false + every { BRSharedPrefs.getSendTransactionCount(app) } returns 5 + every { requestTask.isSuccessful() } returns false + every { requestTask.getException() } returns Exception("boom") + + service().showInAppReviewDialogIfNeeded() + + verify(exactly = 0) { manager.launchReviewFlow(any(), any()) } + verify(exactly = 0) { BRSharedPrefs.inAppReviewDone(app) } + verify { AnalyticsManager.logCustomEvent(BWConstants._20241006_DRR) } + verify(exactly = 0) { AnalyticsManager.logCustomEvent(BWConstants._20241006_UCR) } + } + + @Test + fun `given launch review flow fails, when showInAppReviewDialogIfNeeded, then does not mark done or log completion event`() { + every { BRSharedPrefs.isInAppReviewDone(app) } returns false + every { BRSharedPrefs.getSendTransactionCount(app) } returns 5 + every { requestTask.isSuccessful() } returns true + every { requestTask.getResult() } returns reviewInfo + every { launchTask.isSuccessful() } returns false + + service().showInAppReviewDialogIfNeeded() + + verify { manager.launchReviewFlow(activity, reviewInfo) } + verify(exactly = 0) { BRSharedPrefs.inAppReviewDone(app) } + verify { AnalyticsManager.logCustomEvent(BWConstants._20241006_DRR) } + verify(exactly = 0) { AnalyticsManager.logCustomEvent(BWConstants._20241006_UCR) } + } +} diff --git a/app/src/test/java/com/brainwallet/ui/screens/send/SendViewModelTest.kt b/app/src/test/java/com/brainwallet/ui/screens/send/SendViewModelTest.kt index eb16f87a..d010ad46 100644 --- a/app/src/test/java/com/brainwallet/ui/screens/send/SendViewModelTest.kt +++ b/app/src/test/java/com/brainwallet/ui/screens/send/SendViewModelTest.kt @@ -1,6 +1,7 @@ package com.brainwallet.ui.screens.send import android.app.Application +import com.brainwallet.appreview.InAppReviewService import com.brainwallet.data.model.AppSetting import com.brainwallet.data.model.CurrencyEntity import com.brainwallet.data.repository.SettingRepository @@ -44,6 +45,7 @@ class SendViewModelTest { private lateinit var txRepository: TxRepository private lateinit var settingRepository: SettingRepository private lateinit var currencyDataGetter: CurrencyDataGetter + private lateinit var inAppReviewService: InAppReviewService private val usdCurrency = CurrencyEntity("USD", "US Dollar", 100f, "$") @@ -67,7 +69,8 @@ class SendViewModelTest { getBalance = getBalance, getCurrentFee = getCurrentFee, getOpsFee = getOpsFee, - currencyDataGetter = currencyDataGetter + currencyDataGetter = currencyDataGetter, + inAppReviewService = inAppReviewService ).also { advanceUntilIdle() } @Before @@ -88,6 +91,7 @@ class SendViewModelTest { currencyDataGetter = mockk { every { getCurrencyByIso("USD") } returns usdCurrency } + inAppReviewService = mockk(relaxed = true) mockkStatic(BRKeyStore::class) every { BRKeyStore.getPinCode(any()) } returns "1234"