diff --git a/.gitignore b/.gitignore index b3531703..f8a0e8e7 100644 --- a/.gitignore +++ b/.gitignore @@ -46,4 +46,5 @@ service-data.json canary-file.json # Claude -.claude/ \ No newline at end of file +.claude/ +CLAUDE.local.md \ No newline at end of file diff --git a/README.md b/README.md index fb5c5225..609be87c 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,29 @@ For the full, up-to-date changelog see [GitHub Releases](https://github.com/grun --- +### **v4.11.0** [PR [#259](https://github.com/gruntsoftware/android/pull/259)] +--- +#### ๐ŸŽฎ Fallinmoji v1.6.0 โ€” Ready, Set, GO! +Tapping **Play** (or **Play Again** from the end screen) now runs a short intro sequence before the round begins instead of dropping straight into gameplay: +- **Ready dialog** โ€” shows the round's 3 target emojis on star icons alongside the bonus-scoring rules for ~4.8s +- **3-2-1-GO! countdown** โ€” a rewired full-screen countdown over a looping 6-frame ray-burst flipbook and center star accent, replacing the old libGDX `ParticleEffect`-driven burst for more reliable screen coverage and consistent timing +- Main menu button artwork polished (Play / How-To buttons) + +#### ๐Ÿงช Test Coverage +Added 48 new unit tests across 7 new test classes, closing coverage gaps on previously-untested pure-logic utilities and the `gameinterface` package: +- `Base58Test`, `TypesConverterTest`, `BytesUtilTest`, `BRCompressorTest`, `BRDateUtilTest` โ€” encode/decode round-trips, gzip/bz2 compression round-trips, and relative-time formatting edge cases +- `GameKoinModuleTest` โ€” verifies the `gameModule` Koin DI wiring resolves `GameSlot` to `GdxGameSlot` as a singleton +- `GdxGameViewTest` โ€” covers `GameExitData` JSON parsing (defaults, unknown-key tolerance) and the social-share dispatch logic in `handleGameExit` (Twitter/Instagram screenshot sharing, missing/empty screenshots, malformed JSON, unrecognized networks) + +#### ๐Ÿ”ง Chores +- Added `skills-lock.json` to track installed Claude Code skills (`aso`, `mobile-android-design`) +- `.gitignore`: also exclude `CLAUDE.local.md` +- Version bumped: **v4.10.6 (202506345) โ†’ v4.11.0 (202506346)** + +**Full Changelog**: https://github.com/gruntsoftware/android/compare/v4.10.6...v4.11.0 + +--- + ### **v4.10.6** [PR [#253](https://github.com/gruntsoftware/android/pull/253)] --- #### ๐Ÿ› Crash Fixes diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f52729ee..510df9cc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -31,8 +31,8 @@ android { applicationId = "ltd.grunt.brainwallet" minSdk = 29 targetSdk = 35 - versionCode = 202506345 - versionName = "v4.10.6" + versionCode = 202506347 + versionName = "v4.11.0" multiDexEnabled = true base.archivesName.set("${defaultConfig.versionName}(${defaultConfig.versionCode})") testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 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/gameinterface/GdxGameView.kt b/app/src/main/java/com/brainwallet/gameinterface/GdxGameView.kt index c3114a98..a29b11bb 100644 --- a/app/src/main/java/com/brainwallet/gameinterface/GdxGameView.kt +++ b/app/src/main/java/com/brainwallet/gameinterface/GdxGameView.kt @@ -35,7 +35,7 @@ import org.koin.compose.viewmodel.koinViewModel import timber.log.Timber @Serializable -private data class GameExitData( +internal data class GameExitData( @SerialName("social_network") val socialNetwork: String = "none", val timestamp: Long = 0L, @SerialName("total_score") val totalScore: Int = 0, @@ -102,7 +102,7 @@ private fun removeFragment(fm: FragmentManager, tag: String) { } } -private fun handleGameExit( +internal fun handleGameExit( jsonString: String, bytes: ByteArray?, gameHubViewModel: GameHubViewModel, 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/tools/crypto/Base58Test.kt b/app/src/test/java/com/brainwallet/tools/crypto/Base58Test.kt new file mode 100644 index 00000000..a98622eb --- /dev/null +++ b/app/src/test/java/com/brainwallet/tools/crypto/Base58Test.kt @@ -0,0 +1,79 @@ +package com.brainwallet.tools.crypto + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class Base58Test { + + @Test + fun `given empty byte array, when encode, then returns empty string`() { + assertEquals("", Base58.encode(ByteArray(0))) + } + + @Test + fun `given empty string, when decode, then returns empty byte array`() { + assertArrayEquals(ByteArray(0), Base58.decode("")) + } + + @Test + fun `given known byte sequence, when encode, then matches known base58 vector`() { + // "Hello World" in bytes is a well known Base58 test vector. + val input = "Hello World".toByteArray(Charsets.UTF_8) + assertEquals("JxF12TrwUP45BMd", Base58.encode(input)) + } + + @Test + fun `given known base58 string, when decode, then matches known byte vector`() { + val decoded = Base58.decode("JxF12TrwUP45BMd") + assertArrayEquals("Hello World".toByteArray(Charsets.UTF_8), decoded) + } + + @Test + fun `given bytes with leading zeroes, when encode then decode, then round trips exactly`() { + val input = byteArrayOf(0, 0, 0, 1, 2, 3, 4, 5) + val encoded = Base58.encode(input) + + // Leading zero bytes are represented as leading '1' characters. + assertEquals('1', encoded[0]) + assertEquals('1', encoded[1]) + assertEquals('1', encoded[2]) + + assertArrayEquals(input, Base58.decode(encoded)) + } + + @Test + fun `given all zero bytes, when encode then decode, then round trips exactly`() { + val input = ByteArray(5) + val encoded = Base58.encode(input) + + assertEquals("11111", encoded) + assertArrayEquals(input, Base58.decode(encoded)) + } + + @Test + fun `given arbitrary byte array, when encode then decode, then round trips exactly`() { + val input = byteArrayOf( + 0x00, + 0x01.toByte(), + 0xFF.toByte(), + 0x7F, + 0x80.toByte(), + 0x10, + 0x2A + ) + val encoded = Base58.encode(input) + val decoded = Base58.decode(encoded) + + assertArrayEquals(input, decoded) + } + + @Test + fun `given string with invalid base58 character, when decode, then throws RuntimeException`() { + // '0', 'O', 'I', 'l' are excluded from the Base58 alphabet. + assertThrows(RuntimeException::class.java) { + Base58.decode("0OIl") + } + } +} diff --git a/app/src/test/java/com/brainwallet/tools/util/BRCompressorTest.kt b/app/src/test/java/com/brainwallet/tools/util/BRCompressorTest.kt new file mode 100644 index 00000000..061ce8c1 --- /dev/null +++ b/app/src/test/java/com/brainwallet/tools/util/BRCompressorTest.kt @@ -0,0 +1,58 @@ +package com.brainwallet.tools.util + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class BRCompressorTest { + + @Test + fun `given data, when gZipCompress then gZipExtract, then round trips exactly`() { + val data = "The quick brown fox jumps over the lazy dog".repeat(20).toByteArray(Charsets.UTF_8) + + val compressed = BRCompressor.gZipCompress(data) + val extracted = BRCompressor.gZipExtract(compressed) + + assertArrayEquals(data, extracted) + } + + @Test + fun `given null data, when gZipCompress, then returns null`() { + assertNull(BRCompressor.gZipCompress(null)) + } + + @Test + fun `given null or empty compressed data, when gZipExtract, then returns null`() { + assertNull(BRCompressor.gZipExtract(null)) + assertNull(BRCompressor.gZipExtract(ByteArray(0))) + } + + @Test + fun `given data, when bz2Compress then bz2Extract, then round trips exactly`() { + val data = "The quick brown fox jumps over the lazy dog".repeat(20).toByteArray(Charsets.UTF_8) + + val compressed = BRCompressor.bz2Compress(data) + val extracted = BRCompressor.bz2Extract(compressed) + + assertArrayEquals(data, extracted) + } + + @Test + fun `given null or empty compressed data, when bz2Extract, then returns null`() { + assertNull(BRCompressor.bz2Extract(null)) + assertNull(BRCompressor.bz2Extract(ByteArray(0))) + } + + @Test + fun `given gzip compressed bytes, when isGZIPStream, then returns true`() { + val compressed = BRCompressor.gZipCompress("payload".toByteArray(Charsets.UTF_8)) + assertTrue(BRCompressor.isGZIPStream(compressed!!)) + } + + @Test + fun `given non gzip bytes, when isGZIPStream, then returns false`() { + assertFalse(BRCompressor.isGZIPStream(byteArrayOf(0x01, 0x02))) + } +} diff --git a/app/src/test/java/com/brainwallet/tools/util/BRDateUtilTest.kt b/app/src/test/java/com/brainwallet/tools/util/BRDateUtilTest.kt new file mode 100644 index 00000000..250acda4 --- /dev/null +++ b/app/src/test/java/com/brainwallet/tools/util/BRDateUtilTest.kt @@ -0,0 +1,59 @@ +package com.brainwallet.tools.util + +import org.junit.Assert.assertEquals +import org.junit.Test +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +class BRDateUtilTest { + + private fun minutesAgo(minutes: Long): Date = Date(System.currentTimeMillis() - minutes * 60 * 1000) + + private fun hoursAgo(hours: Long): Date = Date(System.currentTimeMillis() - hours * 60 * 60 * 1000) + + private fun daysAgo(days: Long): Date = Date(System.currentTimeMillis() - days * 24 * 60 * 60 * 1000) + + @Test + fun `given date under a minute ago, then formats as zero minutes`() { + assertEquals("0 m", BRDateUtil.getCustomSpan(Date())) + } + + @Test + fun `given date several minutes ago, then formats in minutes`() { + assertEquals("15 m", BRDateUtil.getCustomSpan(minutesAgo(15))) + } + + @Test + fun `given date just under an hour ago, then still formats in minutes`() { + assertEquals("59 m", BRDateUtil.getCustomSpan(minutesAgo(59))) + } + + @Test + fun `given date exactly one hour ago, then formats in hours`() { + assertEquals("1 h", BRDateUtil.getCustomSpan(hoursAgo(1))) + } + + @Test + fun `given date just under a day ago, then still formats in hours`() { + assertEquals("23 h", BRDateUtil.getCustomSpan(hoursAgo(23))) + } + + @Test + fun `given date exactly one day ago, then formats in days`() { + assertEquals("1 d", BRDateUtil.getCustomSpan(daysAgo(1))) + } + + @Test + fun `given date just under a week ago, then still formats in days`() { + assertEquals("6 d", BRDateUtil.getCustomSpan(daysAgo(6))) + } + + @Test + fun `given date a week or more ago, then formats as month and day`() { + val date = daysAgo(7) + val expected = SimpleDateFormat("MMM dd", Locale.getDefault()).format(date) + + assertEquals(expected, BRDateUtil.getCustomSpan(date)) + } +} diff --git a/app/src/test/java/com/brainwallet/tools/util/BytesUtilTest.kt b/app/src/test/java/com/brainwallet/tools/util/BytesUtilTest.kt new file mode 100644 index 00000000..1d2cdf79 --- /dev/null +++ b/app/src/test/java/com/brainwallet/tools/util/BytesUtilTest.kt @@ -0,0 +1,59 @@ +package com.brainwallet.tools.util + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Test +import java.io.ByteArrayInputStream +import java.io.IOException +import java.io.InputStream + +class BytesUtilTest { + + @Test + fun `given empty stream, when readBytesFromStream, then returns empty byte array`() { + val result = BytesUtil.readBytesFromStream(ByteArrayInputStream(ByteArray(0))) + assertArrayEquals(ByteArray(0), result) + } + + @Test + fun `given small stream, when readBytesFromStream, then returns all bytes`() { + val data = "hello world".toByteArray(Charsets.UTF_8) + val result = BytesUtil.readBytesFromStream(ByteArrayInputStream(data)) + assertArrayEquals(data, result) + } + + @Test + fun `given stream larger than internal buffer, when readBytesFromStream, then returns all bytes`() { + // Internal buffer is 1024 bytes, so exercise multiple read iterations. + val data = ByteArray(1024 * 3 + 17) { (it % 256).toByte() } + val result = BytesUtil.readBytesFromStream(ByteArrayInputStream(data)) + assertArrayEquals(data, result) + } + + @Test + fun `given stream that throws IOException, when readBytesFromStream, then returns bytes read so far without throwing`() { + val failingStream = + object : InputStream() { + private var callCount = 0 + + override fun read(): Int = throw UnsupportedOperationException("not used") + + override fun read( + b: ByteArray, + off: Int, + len: Int, + ): Int { + callCount++ + if (callCount == 1) { + b[off] = 42 + return 1 + } + throw IOException("boom") + } + } + + val result = BytesUtil.readBytesFromStream(failingStream) + assertEquals(1, result.size) + assertEquals(42.toByte(), result[0]) + } +} diff --git a/app/src/test/java/com/brainwallet/tools/util/TypesConverterTest.kt b/app/src/test/java/com/brainwallet/tools/util/TypesConverterTest.kt new file mode 100644 index 00000000..a452fdb3 --- /dev/null +++ b/app/src/test/java/com/brainwallet/tools/util/TypesConverterTest.kt @@ -0,0 +1,68 @@ +package com.brainwallet.tools.util + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Test + +class TypesConverterTest { + + @Test + fun `given positive int, when intToBytes then bytesToInt, then round trips exactly`() { + val value = 123456789 + assertEquals(value, TypesConverter.bytesToInt(TypesConverter.intToBytes(value))) + } + + @Test + fun `given negative int, when intToBytes then bytesToInt, then round trips exactly`() { + val value = -42 + assertEquals(value, TypesConverter.bytesToInt(TypesConverter.intToBytes(value))) + } + + @Test + fun `given zero int, when intToBytes, then returns four zero bytes`() { + assertArrayEquals(byteArrayOf(0, 0, 0, 0), TypesConverter.intToBytes(0)) + } + + @Test + fun `given positive long, when long2byteArray then byteArray2long, then round trips exactly`() { + val value = 9876543210123L + assertEquals(value, TypesConverter.byteArray2long(TypesConverter.long2byteArray(value))) + } + + @Test + fun `given negative long, when long2byteArray then byteArray2long, then round trips exactly`() { + val value = -1L + assertEquals(value, TypesConverter.byteArray2long(TypesConverter.long2byteArray(value))) + } + + @Test + fun `given char array, when toBytes then toChars, then values match as unsigned bytes`() { + val chars = charArrayOf('a', 'B', 'z', '1') + val bytes = TypesConverter.toBytes(chars) + val roundTripped = TypesConverter.toChars(bytes) + + assertArrayEquals(chars, roundTripped) + } + + @Test + fun `given mixed case char array, when lowerCaseCharArray, then all chars are lower case`() { + val chars = charArrayOf('A', 'b', 'C', '1', 'D') + assertArrayEquals(charArrayOf('a', 'b', 'c', '1', 'd'), TypesConverter.lowerCaseCharArray(chars)) + } + + @Test + fun `given char array, when charsToBytes, then matches UTF-8 encoded bytes`() { + val chars = "hello".toCharArray() + assertArrayEquals("hello".toByteArray(Charsets.UTF_8), TypesConverter.charsToBytes(chars)) + } + + @Test + fun `given seed bytes, when getNullTerminatedPhrase, then appends zero byte and zeroes original`() { + val rawSeed = byteArrayOf(1, 2, 3, 4) + val result = TypesConverter.getNullTerminatedPhrase(rawSeed) + + assertArrayEquals(byteArrayOf(1, 2, 3, 4, 0), result) + // The original array must be wiped for security once copied. + assertArrayEquals(byteArrayOf(0, 0, 0, 0), rawSeed) + } +} 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" diff --git a/app/src/test/kotlin/com/brainwallet/gameinterface/GameKoinModuleTest.kt b/app/src/test/kotlin/com/brainwallet/gameinterface/GameKoinModuleTest.kt new file mode 100644 index 00000000..498d1ed2 --- /dev/null +++ b/app/src/test/kotlin/com/brainwallet/gameinterface/GameKoinModuleTest.kt @@ -0,0 +1,30 @@ +package com.brainwallet.gameinterface + +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.koin.dsl.koinApplication + +class GameKoinModuleTest { + + @Test + fun `gameModule provides a GameSlot instance`() { + val koin = koinApplication { modules(gameModule) }.koin + + val gameSlot = koin.get() + + assertNotNull(gameSlot) + assertTrue(gameSlot is GdxGameSlot) + } + + @Test + fun `gameModule provides GameSlot as a singleton`() { + val koin = koinApplication { modules(gameModule) }.koin + + val first = koin.get() + val second = koin.get() + + assertSame(first, second) + } +} diff --git a/app/src/test/kotlin/com/brainwallet/gameinterface/GdxGameViewTest.kt b/app/src/test/kotlin/com/brainwallet/gameinterface/GdxGameViewTest.kt new file mode 100644 index 00000000..f891594d --- /dev/null +++ b/app/src/test/kotlin/com/brainwallet/gameinterface/GdxGameViewTest.kt @@ -0,0 +1,144 @@ +package com.brainwallet.gameinterface + +import com.brainwallet.ui.screens.gamehub.GameHubEvent +import com.brainwallet.ui.screens.gamehub.GameHubViewModel +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlinx.serialization.json.Json +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Test + +class GdxGameViewTest { + + private val lenientJson = Json { ignoreUnknownKeys = true } + + // โ”€โ”€ GameExitData parsing โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + @Test + fun `given full json payload, when decoding GameExitData, then all fields are populated`() { + val json = """ + { + "social_network": "twitter", + "timestamp": 1700000000, + "total_score": 120, + "bonus_amount": 20, + "score_a": 40, + "score_b": 40, + "score_c": 40 + } + """.trimIndent() + + val result = lenientJson.decodeFromString(json) + + assertEquals("twitter", result.socialNetwork) + assertEquals(1700000000L, result.timestamp) + assertEquals(120, result.totalScore) + assertEquals(20, result.bonusAmount) + assertEquals(40, result.scoreA) + assertEquals(40, result.scoreB) + assertEquals(40, result.scoreC) + } + + @Test + fun `given json missing fields, when decoding GameExitData, then defaults are used`() { + val result = lenientJson.decodeFromString("{}") + + assertEquals("none", result.socialNetwork) + assertEquals(0L, result.timestamp) + assertEquals(0, result.totalScore) + assertEquals(0, result.bonusAmount) + } + + @Test + fun `given json with unknown keys, when decoding GameExitData, then unknown keys are ignored`() { + val json = """{"social_network":"instagram","some_future_field":"unused"}""" + + val result = lenientJson.decodeFromString(json) + + assertEquals("instagram", result.socialNetwork) + } + + // โ”€โ”€ handleGameExit dispatch logic โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + @Test + fun `given twitter exit with screenshot, when handleGameExit, then dispatches OnGameExited with the screenshot`() { + val gameHubViewModel = mockk(relaxed = true) + val screenshot = byteArrayOf(1, 2, 3, 4) + val json = """{"social_network":"twitter","total_score":100}""" + + handleGameExit(json, screenshot, gameHubViewModel) + + val slot = slot() + verify(exactly = 1) { gameHubViewModel.onEvent(capture(slot)) } + + val captured = slot.captured as GameHubEvent.OnGameExited + assertEquals(json, captured.jsonPayload) + assertArrayEquals(screenshot, captured.byteArray) + } + + @Test + fun `given instagram exit with screenshot, when handleGameExit, then dispatches OnGameExited with the screenshot`() { + val gameHubViewModel = mockk(relaxed = true) + val screenshot = byteArrayOf(5, 6, 7) + val json = """{"social_network":"instagram"}""" + + handleGameExit(json, screenshot, gameHubViewModel) + + val slot = slot() + verify(exactly = 1) { gameHubViewModel.onEvent(capture(slot)) } + + val captured = slot.captured as GameHubEvent.OnGameExited + assertArrayEquals(screenshot, captured.byteArray) + } + + @Test + fun `given twitter exit with null screenshot, when handleGameExit, then does not dispatch an event`() { + val gameHubViewModel = mockk(relaxed = true) + val json = """{"social_network":"twitter"}""" + + handleGameExit(json, null, gameHubViewModel) + + verify(exactly = 0) { gameHubViewModel.onEvent(any()) } + } + + @Test + fun `given twitter exit with empty screenshot, when handleGameExit, then does not dispatch an event`() { + val gameHubViewModel = mockk(relaxed = true) + val json = """{"social_network":"twitter"}""" + + handleGameExit(json, ByteArray(0), gameHubViewModel) + + verify(exactly = 0) { gameHubViewModel.onEvent(any()) } + } + + @Test + fun `given exit with no social network, when handleGameExit, then does not dispatch an event`() { + val gameHubViewModel = mockk(relaxed = true) + val json = """{"total_score":50}""" + + handleGameExit(json, byteArrayOf(1), gameHubViewModel) + + verify(exactly = 0) { gameHubViewModel.onEvent(any()) } + } + + @Test + fun `given exit with unrecognised social network, when handleGameExit, then does not dispatch an event`() { + val gameHubViewModel = mockk(relaxed = true) + val json = """{"social_network":"facebook"}""" + + handleGameExit(json, byteArrayOf(1), gameHubViewModel) + + verify(exactly = 0) { gameHubViewModel.onEvent(any()) } + } + + @Test + fun `given malformed json, when handleGameExit, then does not throw and does not dispatch an event`() { + val gameHubViewModel = mockk(relaxed = true) + + handleGameExit("not valid json", byteArrayOf(1), gameHubViewModel) + + verify(exactly = 0) { gameHubViewModel.onEvent(any()) } + } +} diff --git a/bw-gdlib b/bw-gdlib index 5c2faab0..db543da2 160000 --- a/bw-gdlib +++ b/bw-gdlib @@ -1 +1 @@ -Subproject commit 5c2faab03cd27183ecbc1a2268fac7006e712340 +Subproject commit db543da26e36ce1955fa7c596fe8b85099afe629 diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 00000000..a9fcfaa7 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,17 @@ +{ + "version": 1, + "skills": { + "aso": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "skillPath": "skills/aso/SKILL.md", + "computedHash": "f84a1fb5cb1a8a7f7171bd60ae6850f24a82fa6af94bd5f12e5b3e9227480a34" + }, + "mobile-android-design": { + "source": "wshobson/agents", + "sourceType": "github", + "skillPath": "plugins/ui-design/skills/mobile-android-design/SKILL.md", + "computedHash": "bcb274bcc4e0ad076b5947f90e5a247f370ed4da1274b2b7347dc293ee396309" + } + } +}