diff --git a/.circleci/config.yml b/.circleci/config.yml index ae0d3da8..125ace10 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -100,6 +100,54 @@ jobs: path: ~/test-results - store_artifacts: path: ~/test-results/junit + - run: + name: "Update tests-passed badge (shields.io gist endpoint)" + # Only the primary branch's run should own the badge state - a feature + # branch with fewer/broken tests shouldn't flap the README badge. + # Requires GIST_TOKEN (a PAT with only the `gist` scope) and GIST_ID + # to be set as CircleCI project env vars. + when: always + command: | + if [ "$CIRCLE_BRANCH" != "develop" ]; then + echo "Not on develop, skipping badge update." + exit 0 + fi + if [ -z "$GIST_TOKEN" ] || [ -z "$GIST_ID" ]; then + echo "GIST_TOKEN/GIST_ID not set, skipping badge update." + exit 0 + fi + + total=0 + failures=0 + skipped=0 + for f in ~/test-results/junit/*.xml; do + [ -f "$f" ] || continue + t=$(grep -o 'tests="[0-9]*"' "$f" | head -1 | grep -o '[0-9]*') + fl=$(grep -o 'failures="[0-9]*"' "$f" | head -1 | grep -o '[0-9]*') + sk=$(grep -o 'skipped="[0-9]*"' "$f" | head -1 | grep -o '[0-9]*') + total=$((total + ${t:-0})) + failures=$((failures + ${fl:-0})) + skipped=$((skipped + ${sk:-0})) + done + passed=$((total - failures - skipped)) + + if [ "$failures" -gt 0 ]; then + message="${passed} passed, ${failures} failed" + color="red" + else + message="${passed} passed" + color="brightgreen" + fi + + payload=$(printf '{"files":{"tests-badge.json":{"content":"{\\"schemaVersion\\":1,\\"label\\":\\"tests\\",\\"message\\":\\"%s\\",\\"color\\":\\"%s\\"}"}}}' "$message" "$color") + + curl -s -X PATCH \ + -H "Authorization: token ${GIST_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/gists/${GIST_ID}" \ + -d "$payload" > /dev/null + + echo "Badge updated: ${message}" screengrab: executor: diff --git a/README.md b/README.md index 609be87c..4980363f 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ ### CircleCI status [![Release](https://img.shields.io/github/v/release/gruntsoftware/android?style=plastic)](https://github.com/gruntsoftware/android/releases) [![CircleCI](https://dl.circleci.com/status-badge/img/gh/gruntsoftware/android/tree/main.svg?style=svg)](https://dl.circleci.com/status-badge/redirect/gh/gruntsoftware/android/tree/main) +[![Tests](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/grunt-claude-bot/ae4830cb1b5da4611598d20a517fd933/raw/tests-badge.json)](https://dl.circleci.com/status-badge/redirect/gh/gruntsoftware/android/tree/develop) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) ## Play Store @@ -42,7 +43,7 @@ ### Prerequisites - Android Studio (current stable) with SDK 36 installed, NDK `25.1.8937393`, CMake `3.22.1` -- `minSdk 29`, `targetSdk 35` +- `minSdk 29`, `targetSdk 36` ## Architecture @@ -73,6 +74,33 @@ For the full, up-to-date changelog see [GitHub Releases](https://github.com/grun --- +### **v4.12.0** [PR [#270](https://github.com/gruntsoftware/android/pull/270)] +--- +#### โœจ In-App Review Activated +Google Play's in-app review prompt is now actually wired up after sitting dormant โ€” `InAppReviewService` is registered in Koin and `showInAppReviewDialogIfNeeded()` is called from five placements: after a successful send, the game hub exit flow, the balance-visibility toggle, and two tutorial pages (#260). A follow-up fix decouples the prompt from the social-share flow so it fires on every game exit, not just when the player taps Twitter/Instagram share (#263). + +#### ๐Ÿ› Bug Fixes +- **Native `SIGSEGV` crash in `BRPeerManager`** โ€” several accessors (`BRPeerManagerEstimatedBlockHeight`, `LastBlockHeight`, `LastBlockTimestamp`, `SyncProgress`) dereferenced `lastBlock` without a null check, crashing the process if a checkpoint block was ever missing from the persisted block set. `core`'s `BRPeerManager.c` now guards every access and reports the recovery back to the app via a new `BRPeerManagerSetIntegrityWarningCallback`, surfaced through `BRPeerManager.onIntegrityWarning()` to Crashlytics instead of crashing (#269). +- **RSA public key parsing in `getEncryptedAgentString`** โ€” the provisioned key is an OpenSSH `ssh-rsa ` line, not base64-encoded PEM; the old PEM-stripping logic corrupted the payload by merging the `ssh-rsa` prefix and comment into it, failing on every call. Now parses the OpenSSH wire format (RFC 4253) directly (#265). +- **MoonPay signed-url `ipAddress` race** โ€” the Buy button in `ReceiveDialog` could reach MoonPay's signed-url request before the async ipify lookup resolved, sending an empty `ipAddress`. The lookup now happens fresh inside `fetchMoonpaySignedUrl` itself, awaited right before use (#264). + +#### ๐ŸŽฎ Fallinmoji v1.6.2 +"How To Play" screen update and a bento corner-radius fix, plus a bw-gdlib bump to pick it up (#268). + +#### ๐Ÿ“Š Analytics Cleanup +Removed excess/duplicate events (`app_launched`, `home_open`, a double-firing `did_skip_top_up`) and stopped logging `did_request_rating`/`user_completed_rating` as Firebase events โ€” the Play Core API gives no guarantee a review request actually shows the dialog, so these overstated what happened. Replaced with local `Timber.d` traces (#266). + +#### ๐Ÿงช Test Coverage +Added coverage for `InAppReviewService` (gating + Play Core success/failure branches), `UtilsAgentString`'s new OpenSSH parsing path, MoonPay ip-fetch success/failure, and `BRPeerManager.onIntegrityWarning`'s Crashlytics reporting. + +#### ๐Ÿ”ง Chores +- Bumped `targetSdk` to 36 (#267) +- Version bumped: **v4.11.0 (202506346) โ†’ v4.12.0 (202506350)** + +**Full Changelog**: https://github.com/gruntsoftware/android/compare/v4.11.0...v4.12.0 + +--- + ### **v4.11.0** [PR [#259](https://github.com/gruntsoftware/android/pull/259)] --- #### ๐ŸŽฎ Fallinmoji v1.6.0 โ€” Ready, Set, GO! diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 510df9cc..90243649 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -30,9 +30,9 @@ android { defaultConfig { applicationId = "ltd.grunt.brainwallet" minSdk = 29 - targetSdk = 35 - versionCode = 202506347 - versionName = "v4.11.0" + targetSdk = 36 + versionCode = 202506350 + versionName = "v4.12.0" multiDexEnabled = true base.archivesName.set("${defaultConfig.versionName}(${defaultConfig.versionCode})") testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/app/src/main/java/com/brainwallet/BrainwalletApp.kt b/app/src/main/java/com/brainwallet/BrainwalletApp.kt index 36cba83f..7f2f224e 100644 --- a/app/src/main/java/com/brainwallet/BrainwalletApp.kt +++ b/app/src/main/java/com/brainwallet/BrainwalletApp.kt @@ -37,7 +37,6 @@ open class BrainwalletApp : Application() { FirebaseCrashlytics.getInstance().setCustomKey("build_type", "debug") FirebaseCrashlytics.getInstance().setUserId("debug_bw_devices") } - AnalyticsManager.logCustomEvent(BWConstants._20191105_AL) if (BuildConfig.DEBUG) Timber.plant(DebugTree()) diff --git a/app/src/main/java/com/brainwallet/appreview/InAppReviewService.kt b/app/src/main/java/com/brainwallet/appreview/InAppReviewService.kt index 503742be..39d195fa 100644 --- a/app/src/main/java/com/brainwallet/appreview/InAppReviewService.kt +++ b/app/src/main/java/com/brainwallet/appreview/InAppReviewService.kt @@ -2,8 +2,6 @@ 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 @@ -26,7 +24,6 @@ class InAppReviewService( val request = manager.requestReviewFlow() request.addOnCompleteListener( OnCompleteListener { task: Task? -> - AnalyticsManager.logCustomEvent(BWConstants._20241006_DRR) if (task!!.isSuccessful()) { val reviewInfo = task.getResult() val flow = manager.launchReviewFlow(activity, reviewInfo) @@ -41,7 +38,6 @@ class InAppReviewService( ) if (task1.isSuccessful()) { BRSharedPrefs.inAppReviewDone(app) - AnalyticsManager.logCustomEvent(BWConstants._20241006_UCR) } } ) diff --git a/app/src/main/java/com/brainwallet/constants/BWConstants.kt b/app/src/main/java/com/brainwallet/constants/BWConstants.kt index fe413c28..10181919 100644 --- a/app/src/main/java/com/brainwallet/constants/BWConstants.kt +++ b/app/src/main/java/com/brainwallet/constants/BWConstants.kt @@ -116,11 +116,11 @@ object BWConstants { * API Hosts */ const val BW_API_PROD_HOST: String = "https://api.grunt.ltd" + const val IPIFY_API_HOST: String = "https://api.ipify.org?format=text" const val BLOCKCHAIR_EXPLORER_BASE_URL: String = "https://blockchair.com/litecoin/transaction/" const val BLOCKCYPHER_EXPLORER_BASE_URL: String = "https://live.blockcypher.com/ltc/" - const val _20191105_AL: String = "app_launched" const val _20191105_VSC: String = "visit_send_controller" const val _20202116_VRC: String = "visit_receive_controller" const val _20191105_DSL: String = "did_send_ltc" @@ -133,11 +133,7 @@ object BWConstants { const val _20201118_DTGS: String = "did_tap_get_support" const val _20200217_DU: String = "did_unlock" - const val _20250303_DSTU: String = "did_skip_top_up" const val _20250517_WCINFO: String = "wallet_callback_info" - const val _20241006_DRR: String = "did_request_rating" - const val _20241006_UCR: String = "user_completed_rating" - const val _HOME_OPEN: String = "home_open" /** * Analytics keys diff --git a/app/src/main/java/com/brainwallet/data/repository/LtcRepository.kt b/app/src/main/java/com/brainwallet/data/repository/LtcRepository.kt index ddab8a08..75c26c87 100644 --- a/app/src/main/java/com/brainwallet/data/repository/LtcRepository.kt +++ b/app/src/main/java/com/brainwallet/data/repository/LtcRepository.kt @@ -25,6 +25,8 @@ interface LtcRepository { suspend fun fetchMoonpaySignedUrl(params: Map): String + suspend fun fetchUserIpAddress(): String + companion object { const val PREF_KEY_NETWORK_FEE_PER_KB = "network_fee_per_kb" const val PREF_KEY_NETWORK_FEE_PER_KB_CACHED_AT = "${PREF_KEY_NETWORK_FEE_PER_KB}_cached_at" diff --git a/app/src/main/java/com/brainwallet/data/repository/LtcRepositoryImpl.kt b/app/src/main/java/com/brainwallet/data/repository/LtcRepositoryImpl.kt index 7628af17..84670ce8 100644 --- a/app/src/main/java/com/brainwallet/data/repository/LtcRepositoryImpl.kt +++ b/app/src/main/java/com/brainwallet/data/repository/LtcRepositoryImpl.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.SharedPreferences import androidx.core.net.toUri import com.brainwallet.BuildConfig +import com.brainwallet.constants.BWConstants import com.brainwallet.data.model.CurrencyEntity import com.brainwallet.data.model.Fee import com.brainwallet.data.model.LtcStats @@ -33,8 +34,11 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import okhttp3.OkHttpClient +import okhttp3.Request import org.koin.core.annotation.Single import timber.log.Timber @@ -47,6 +51,7 @@ class LtcRepositoryImpl( private val peerManagerSource: PeerManagerSource, private val repositoryScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO), private val sharedPreferences: SharedPreferences, + private val okHttpClient: OkHttpClient, ) : LtcRepository { // private val _rates = MutableStateFlow>( @@ -170,6 +175,7 @@ class LtcRepositoryImpl( "externalTransactionId" to externalTransactionID, "currencyCode" to "ltc", "themeId" to "main-v1.0.0", + "ipAddress" to fetchUserIpAddress(), ) return remoteApiSource.getMoonpaySignedUrl(finalParams) .signedUrl.toUri() @@ -182,4 +188,21 @@ class LtcRepositoryImpl( .build() .toString() } + + override suspend fun fetchUserIpAddress(): String = withContext(Dispatchers.IO) { + runCatching { + val request = Request.Builder() + .url(BWConstants.IPIFY_API_HOST) + .build() + okHttpClient.newCall(request).execute().use { response -> + if (response.isSuccessful) { + response.body?.string()?.trim().orEmpty() + } else { + "" + } + } + }.onFailure { + Timber.e(it, "fetchUserIpAddress failed") + }.getOrDefault("") + } } diff --git a/app/src/main/java/com/brainwallet/gameinterface/GdxGameView.kt b/app/src/main/java/com/brainwallet/gameinterface/GdxGameView.kt index a29b11bb..3746a2b0 100644 --- a/app/src/main/java/com/brainwallet/gameinterface/GdxGameView.kt +++ b/app/src/main/java/com/brainwallet/gameinterface/GdxGameView.kt @@ -77,6 +77,7 @@ fun GdxGameView( this.onExit = { jsonString, bytes -> removeFragment(fm, tag) viewModel.onEvent(MainScreenEvent.OnToggleGameHub) + gameHubViewModel.onEvent(GameHubEvent.OnGameFinished) handleGameExit(jsonString, bytes, gameHubViewModel) currentOnExit(jsonString, bytes) } diff --git a/app/src/main/java/com/brainwallet/presenter/activities/BreadActivity.java b/app/src/main/java/com/brainwallet/presenter/activities/BreadActivity.java index ae947471..af3cef39 100644 --- a/app/src/main/java/com/brainwallet/presenter/activities/BreadActivity.java +++ b/app/src/main/java/com/brainwallet/presenter/activities/BreadActivity.java @@ -38,7 +38,6 @@ import com.brainwallet.presenter.customviews.BRNotificationBar; import com.brainwallet.presenter.history.HistoryFragment; import com.brainwallet.tools.animation.TextSizeTransition; -import com.brainwallet.tools.manager.AnalyticsManager; import com.brainwallet.tools.manager.BRSharedPrefs; import com.brainwallet.tools.manager.InternetManager; import com.brainwallet.tools.manager.sync.SyncManager; @@ -46,7 +45,6 @@ import com.brainwallet.tools.security.PostAuth; import com.brainwallet.tools.sqlite.TransactionDataSource; import com.brainwallet.tools.threads.BRExecutor; -import com.brainwallet.constants.BWConstants; import com.brainwallet.tools.util.BRCurrency; import com.brainwallet.tools.util.BRExchange; import com.brainwallet.tools.util.ExtensionKt; @@ -98,7 +96,6 @@ public static BreadActivity getApp() { protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_bread); - AnalyticsManager.logCustomEvent(BWConstants._HOME_OPEN); app = this; getWindowManager().getDefaultDisplay().getSize(screenParametersPoint); diff --git a/app/src/main/java/com/brainwallet/tools/util/Utils.java b/app/src/main/java/com/brainwallet/tools/util/Utils.java index 0a1f180c..8ccf19e0 100644 --- a/app/src/main/java/com/brainwallet/tools/util/Utils.java +++ b/app/src/main/java/com/brainwallet/tools/util/Utils.java @@ -21,7 +21,9 @@ import java.security.NoSuchAlgorithmException; import java.security.PublicKey; import java.security.spec.InvalidKeySpecException; -import java.security.spec.X509EncodedKeySpec; +import java.security.spec.RSAPublicKeySpec; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; import java.util.Base64; import javax.crypto.BadPaddingException; @@ -198,24 +200,12 @@ public static String getEncryptedAgentString(Context app) { try { - // Convert base64 public key string to PublicKey object - byte[] keyBytes; + // Convert the base64-encoded OpenSSH "ssh-rsa " public key + // string into an RSAPublicKeySpec + RSAPublicKeySpec keySpec; try { String pubkey = Utils.fetchServiceItem(app, ServiceItems.AGENTPUBKEY).toString(); - keyBytes = Base64.getDecoder().decode(pubkey); - - // Decode the base64 to get the PEM string - byte[] pemBytes = Base64.getDecoder().decode(pubkey); - String pemString = new String(pemBytes, "UTF-8"); - - // Extract just the key data (remove PEM headers and whitespace) - String keyData = pemString - .replace("-----BEGIN PUBLIC KEY-----", "") - .replace("-----END PUBLIC KEY-----", "") - .replaceAll("\\s+", ""); - - // Decode the clean base64 key data - keyBytes = Base64.getDecoder().decode(keyData); + keySpec = parseOpenSshRsaPublicKey(pubkey); } catch (IllegalArgumentException e) { Timber.d("Invalid base64 public key format: %s", e.toString()); return "ERROR-CANNOT-KEYBYTES-DO-CONVERSION"; @@ -232,7 +222,7 @@ public static String getEncryptedAgentString(Context app) { // Generate PublicKey object PublicKey publicKey; try { - publicKey = keyFactory.generatePublic(new X509EncodedKeySpec(keyBytes)); + publicKey = keyFactory.generatePublic(keySpec); } catch (InvalidKeySpecException e) { Timber.d("Invalid public key specification: %s", e.toString()); return "ERROR-CANNOT-INVALID-PUBLIC-KEY-SPEC"; @@ -275,6 +265,42 @@ public static String getEncryptedAgentString(Context app) { } } + // Parses a base64-encoded OpenSSH "ssh-rsa " public key line + // (as provisioned in service-data.json's agent-base64-pubkey) into an RSAPublicKeySpec. + // Package-private so it can be unit-tested directly without needing a Context. + static RSAPublicKeySpec parseOpenSshRsaPublicKey(String base64EncodedOpenSshLine) { + String opensshLine = new String(Base64.getDecoder().decode(base64EncodedOpenSshLine), StandardCharsets.UTF_8); + + // OpenSSH format: "ssh-rsa [comment]" + String[] parts = opensshLine.trim().split("\\s+"); + if (parts.length < 2) { + throw new IllegalArgumentException("Unexpected OpenSSH public key format"); + } + byte[] keyBlob = Base64.getDecoder().decode(parts[1]); + return parseSshRsaPublicKeyBlob(keyBlob); + } + + // Parses an RFC 4253 "ssh-rsa" wire-format key blob (as found in an OpenSSH + // public key line's second field) into the modulus/exponent RSA needs. + static RSAPublicKeySpec parseSshRsaPublicKeyBlob(byte[] keyBlob) { + ByteBuffer buffer = ByteBuffer.wrap(keyBlob); + String keyType = new String(readSshField(buffer), StandardCharsets.UTF_8); + if (!"ssh-rsa".equals(keyType)) { + throw new IllegalArgumentException("Unsupported SSH key type: " + keyType); + } + BigInteger exponent = new BigInteger(readSshField(buffer)); + BigInteger modulus = new BigInteger(readSshField(buffer)); + return new RSAPublicKeySpec(modulus, exponent); + } + + // Each SSH wire-format field is a 4-byte big-endian length followed by that many bytes. + private static byte[] readSshField(ByteBuffer buffer) { + int length = buffer.getInt(); + byte[] data = new byte[length]; + buffer.get(data); + return data; + } + public static String reverseHex(String hex) { if (hex == null) return null; StringBuilder result = new StringBuilder(); 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 8537d17e..4bdaae77 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 @@ -67,6 +67,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel +import timber.log.Timber import java.math.BigDecimal @Composable @@ -194,6 +195,7 @@ fun BalanceBentoScreen( coroutineScope.launch { delay(800L) inAppReviewService.showInAppReviewDialogIfNeeded() + Timber.d("did_request_rating") } } } @@ -274,6 +276,7 @@ fun BalanceBentoScreen( coroutineScope.launch { delay(800L) inAppReviewService.showInAppReviewDialogIfNeeded() + Timber.d("did_request_rating") } } } diff --git a/app/src/main/java/com/brainwallet/ui/bentosections/buyreceivebento/receive/ReceiveDialog.kt b/app/src/main/java/com/brainwallet/ui/bentosections/buyreceivebento/receive/ReceiveDialog.kt index 60b5441a..a13b188d 100644 --- a/app/src/main/java/com/brainwallet/ui/bentosections/buyreceivebento/receive/ReceiveDialog.kt +++ b/app/src/main/java/com/brainwallet/ui/bentosections/buyreceivebento/receive/ReceiveDialog.kt @@ -210,19 +210,6 @@ private fun ReceiveDialog( } ) - // moonpay widget - // todo: revisit this later -// AnimatedVisibility(visible = state.moonpayWidgetVisible()) { -// state.moonpayBuySignedUrl?.let { signedUrl -> -// MoonpayBuyWidget( -// modifier = Modifier.height(500.dp), -// signedUrl = signedUrl -// ) -// } -// } - - // buy / receive -// AnimatedVisibility(visible = state.moonpayWidgetVisible().not()) { Column { Row( modifier = Modifier.fillMaxWidth(), @@ -418,14 +405,12 @@ private fun ReceiveDialog( modifier = Modifier.fillMaxWidth(), enabled = loadingState.visible.not(), onClick = { - // todo: revisit this later - // viewModel.onEvent(ReceiveDialogEvent.OnMoonpayButtonClick) onMoonPayLaunch( mapOf( "baseCurrencyCode" to state.selectedFiatCurrency.code, "baseCurrencyAmount" to state.fiatAmount.toString(), "language" to appSetting.languageCode, - "walletAddress" to state.address, + "walletAddress" to state.address ) ) }, diff --git a/app/src/main/java/com/brainwallet/ui/bentosections/tutorials/TutorialSendBentoScreen.kt b/app/src/main/java/com/brainwallet/ui/bentosections/tutorials/TutorialSendBentoScreen.kt index f2312f2d..48d6f05b 100644 --- a/app/src/main/java/com/brainwallet/ui/bentosections/tutorials/TutorialSendBentoScreen.kt +++ b/app/src/main/java/com/brainwallet/ui/bentosections/tutorials/TutorialSendBentoScreen.kt @@ -48,7 +48,7 @@ fun TutorialSendBentoScreen( Card( modifier = modifier.fillMaxSize(), - shape = RoundedCornerShape(18.dp), + shape = RoundedCornerShape(bentoCornerRadius), colors = CardDefaults.cardColors(containerColor = Color.Transparent), onClick = onClick ) { 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 53052134..a21ce135 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 @@ -27,6 +27,7 @@ import com.brainwallet.ui.composable.CalloutWithPointers import com.brainwallet.ui.composable.Pointer import com.brainwallet.ui.theme.IBMPlexSans import org.koin.compose.koinInject +import timber.log.Timber @Composable fun TutorialSendPage2( @@ -36,6 +37,7 @@ fun TutorialSendPage2( ) { LaunchedEffect(Unit) { inAppReviewService.showInAppReviewDialogIfNeeded() + Timber.d("did_request_rating") } Box( modifier = Modifier 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 0be51438..41349294 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 @@ -26,6 +26,7 @@ import com.brainwallet.ui.composable.CalloutWithPointers import com.brainwallet.ui.composable.Pointer import com.brainwallet.ui.theme.IBMPlexSans import org.koin.compose.koinInject +import timber.log.Timber @Composable fun TutorialWalkthroughPage3( @@ -35,6 +36,7 @@ fun TutorialWalkthroughPage3( ) { LaunchedEffect(Unit) { inAppReviewService.showInAppReviewDialogIfNeeded() + Timber.d("did_request_rating") } Box( diff --git a/app/src/main/java/com/brainwallet/ui/screens/buyreceive/BuyReceiveScreen.kt b/app/src/main/java/com/brainwallet/ui/screens/buyreceive/BuyReceiveScreen.kt index c70f2547..f303dd05 100644 --- a/app/src/main/java/com/brainwallet/ui/screens/buyreceive/BuyReceiveScreen.kt +++ b/app/src/main/java/com/brainwallet/ui/screens/buyreceive/BuyReceiveScreen.kt @@ -150,8 +150,7 @@ fun BuyReceiveScreen( "baseCurrencyAmount" to state.fiatAmount.toString(), "language" to appSetting.languageCode, "walletAddress" to state.address - ), - + ) ) } ) { diff --git a/app/src/main/java/com/brainwallet/ui/screens/gamehub/GameHubEvent.kt b/app/src/main/java/com/brainwallet/ui/screens/gamehub/GameHubEvent.kt index 246d2754..3fe80cd2 100644 --- a/app/src/main/java/com/brainwallet/ui/screens/gamehub/GameHubEvent.kt +++ b/app/src/main/java/com/brainwallet/ui/screens/gamehub/GameHubEvent.kt @@ -5,4 +5,5 @@ import android.content.Context sealed class GameHubEvent { data class OnLoad(val context: Context) : GameHubEvent() data class OnGameExited(val jsonPayload: String, val byteArray: ByteArray) : GameHubEvent() + data object OnGameFinished : GameHubEvent() } 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 2b2d061b..e6e7f20a 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 @@ -20,6 +20,7 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import org.koin.android.annotation.KoinViewModel +import timber.log.Timber import java.io.File import java.io.FileOutputStream @@ -36,6 +37,14 @@ class GameHubViewModel( when (event) { is GameHubEvent.OnLoad -> { } + is GameHubEvent.OnGameFinished -> { + AnalyticsManager.logCustomEventWithParams("did_play_game", null) + viewModelScope.launch { + delay(3_000L) + inAppReviewService.showInAppReviewDialogIfNeeded() + Timber.d("did_request_rating") + } + } is GameHubEvent.OnGameExited -> { val unixTimestamp = System.currentTimeMillis() / 1000 val bitmap = BitmapFactory.decodeByteArray(event.byteArray, 0, event.byteArray.size) 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 f5696037..dee8a4d3 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 @@ -284,6 +284,7 @@ class SendViewModel( BRSharedPrefs.incrementSendTransactionCount(app) delay(800L) inAppReviewService.showInAppReviewDialogIfNeeded() + Timber.d("did_request_rating") } is Error.InsufficientFunds -> { _state.update { @@ -371,21 +372,3 @@ class SendViewModel( data object DismissSheet : SendEffect() } } - -// if (allFilled) { -// BRSender.getInstance().sendTransaction( -// context, -// TransactionItem( -// sendAddress, -// Utils.fetchServiceItem(context, ServiceItems.WALLETOPS), -// null, -// litoshiAmount.toLong(), -// getOpsFee(litoshiAmount.toLong()), -// null, -// false, -// comment -// ), -// ) -// AnalyticsManager.logCustomEvent(BWConstants._20191105_DSL) -// BRSharedPrefs.incrementSendTransactionCount(context) -// } diff --git a/app/src/main/java/com/brainwallet/ui/screens/topup/TopUpScreen.kt b/app/src/main/java/com/brainwallet/ui/screens/topup/TopUpScreen.kt index 4e50d64d..bd56b0e2 100644 --- a/app/src/main/java/com/brainwallet/ui/screens/topup/TopUpScreen.kt +++ b/app/src/main/java/com/brainwallet/ui/screens/topup/TopUpScreen.kt @@ -31,11 +31,9 @@ import com.brainwallet.R import com.brainwallet.navigation.OnNavigate import com.brainwallet.navigation.Route import com.brainwallet.navigation.UiEffect -import com.brainwallet.tools.manager.AnalyticsManager import com.brainwallet.ui.composable.BorderedLargeButton import com.brainwallet.ui.composable.BrainwalletScaffold import com.brainwallet.ui.composable.BrainwalletTopAppBar -import com.brainwallet.constants.BWConstants import com.brainwallet.ui.screens.yourseedproveit.YourSeedProveItViewModel import org.koin.compose.viewmodel.koinViewModel @@ -117,7 +115,6 @@ fun TopUpScreen( } BorderedLargeButton( onClick = { - AnalyticsManager.logCustomEvent(BWConstants._20250303_DSTU) onNavigate.invoke( UiEffect.Navigate( destinationRoute = Route.Main, diff --git a/app/src/main/java/com/brainwallet/ui/screens/yourseedproveit/YourSeedProveItScreen.kt b/app/src/main/java/com/brainwallet/ui/screens/yourseedproveit/YourSeedProveItScreen.kt index 14168cbb..0761ebd0 100644 --- a/app/src/main/java/com/brainwallet/ui/screens/yourseedproveit/YourSeedProveItScreen.kt +++ b/app/src/main/java/com/brainwallet/ui/screens/yourseedproveit/YourSeedProveItScreen.kt @@ -52,13 +52,11 @@ import androidx.compose.ui.unit.dp import com.brainwallet.R import com.brainwallet.navigation.OnNavigate import com.brainwallet.navigation.UiEffect -import com.brainwallet.tools.manager.AnalyticsManager import com.brainwallet.ui.composable.BrainwalletScaffold import com.brainwallet.ui.composable.BrainwalletTopAppBar import com.brainwallet.ui.composable.LargeButton import com.brainwallet.ui.composable.SeedWordItem import com.brainwallet.ui.composable.SeedWordsLayout -import com.brainwallet.constants.BWConstants import com.brainwallet.navigation.Route import org.koin.compose.koinInject @@ -89,7 +87,6 @@ fun YourSeedProveItScreen( if (state.orderCorrected) { coinAudioPlayer.start() viewModel.onEvent(YourSeedProveItEvent.OnCompletedPaperKey) - AnalyticsManager.logCustomEvent(BWConstants._20250303_DSTU) } } diff --git a/app/src/main/java/com/brainwallet/wallet/BRPeerManager.java b/app/src/main/java/com/brainwallet/wallet/BRPeerManager.java index b561b816..5b361d47 100644 --- a/app/src/main/java/com/brainwallet/wallet/BRPeerManager.java +++ b/app/src/main/java/com/brainwallet/wallet/BRPeerManager.java @@ -16,6 +16,7 @@ import com.brainwallet.tools.sqlite.PeerDataSource; import com.brainwallet.tools.threads.BRExecutor; import com.brainwallet.tools.util.TrustedNode; +import com.google.firebase.crashlytics.FirebaseCrashlytics; import org.koin.java.KoinJavaComponent; @@ -109,6 +110,13 @@ public void run() { }); } @Suppress(names = "unused") // called via BRPeerManager callback + public static void onIntegrityWarning(String warning) { + Timber.e("timber: native integrity warning: %s", warning); + FirebaseCrashlytics.getInstance().recordException( + new RuntimeException("BRPeerManager native integrity warning: " + warning) + ); + } + @Suppress(names = "unused") // called via BRPeerManager callback public static void saveBlocks(final BlockEntity[] blockEntities, final boolean replace) { Timber.d("timber: saveBlocks: %s", blockEntities.length); diff --git a/app/src/main/jni/core b/app/src/main/jni/core index 4c903166..50ac71d6 160000 --- a/app/src/main/jni/core +++ b/app/src/main/jni/core @@ -1 +1 @@ -Subproject commit 4c90316690ce613482649ccd8b07583749a72266 +Subproject commit 50ac71d6ee39420d6ffc225576e6f7e65b4bcf3c diff --git a/app/src/main/jni/transition/PeerManager.c b/app/src/main/jni/transition/PeerManager.c index 8d249725..0d6638e5 100644 --- a/app/src/main/jni/transition/PeerManager.c +++ b/app/src/main/jni/transition/PeerManager.c @@ -122,6 +122,24 @@ static void txStatusUpdate(void *info) { (*env)->CallStaticVoidMethod(env, _peerManagerClass, mid); } +// called by core when it detects and recovers from unexpected internal state (e.g. a missing checkpoint +// block) instead of crashing. warning is a short, static, human-readable C string with no dynamic/sensitive +// content. See BRPeerManagerSetIntegrityWarningCallback() in core/BRPeerManager.h. +static void integrityWarning(void *info, const char *warning) { + __android_log_print(ANDROID_LOG_ERROR, "Message from C: ", "integrityWarning: %s", warning); + if (!_peerManager) return; + + JNIEnv *env = getEnv(); + jmethodID mid; + + if (!env) return; + + jstring jWarning = (*env)->NewStringUTF(env, warning); + mid = (*env)->GetStaticMethodID(env, _peerManagerClass, "onIntegrityWarning", "(Ljava/lang/String;)V"); + (*env)->CallStaticVoidMethod(env, _peerManagerClass, mid, jWarning); + (*env)->DeleteLocalRef(env, jWarning); +} + static void saveBlocks(void *info, int replace, BRMerkleBlock *blocks[], size_t count) { __android_log_print(ANDROID_LOG_DEBUG, "Message from C: ", "saveBlocks"); if (!_peerManager) return; @@ -386,6 +404,7 @@ Java_com_brainwallet_wallet_BRPeerManager_create(JNIEnv *env, jobject thiz, BRPeerManagerSetCallbacks(_peerManager, NULL, syncStarted, syncStopped, txStatusUpdate, saveBlocks, savePeers, networkIsReachable, threadCleanup); + BRPeerManagerSetIntegrityWarningCallback(_peerManager, integrityWarning); } if (_peerManager == NULL) { diff --git a/app/src/test/java/com/brainwallet/appreview/InAppReviewServiceTest.kt b/app/src/test/java/com/brainwallet/appreview/InAppReviewServiceTest.kt index 0f0cd15c..2b91f1ad 100644 --- a/app/src/test/java/com/brainwallet/appreview/InAppReviewServiceTest.kt +++ b/app/src/test/java/com/brainwallet/appreview/InAppReviewServiceTest.kt @@ -2,8 +2,6 @@ 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 @@ -58,8 +56,6 @@ class InAppReviewServiceTest { } mockkStatic(BRSharedPrefs::class) - mockkStatic(AnalyticsManager::class) - every { AnalyticsManager.logCustomEvent(any()) } returns Unit } @After @@ -108,7 +104,7 @@ class InAppReviewServiceTest { } @Test - fun `given eligible user and successful review flow, when showInAppReviewDialogIfNeeded, then marks review done and logs both analytics events`() { + fun `given eligible user and successful review flow, when showInAppReviewDialogIfNeeded, then marks review done`() { every { BRSharedPrefs.isInAppReviewDone(app) } returns false every { BRSharedPrefs.getSendTransactionCount(app) } returns 5 every { requestTask.isSuccessful() } returns true @@ -120,8 +116,6 @@ class InAppReviewServiceTest { verify { manager.launchReviewFlow(activity, reviewInfo) } verify { BRSharedPrefs.inAppReviewDone(app) } - verify { AnalyticsManager.logCustomEvent(BWConstants._20241006_DRR) } - verify { AnalyticsManager.logCustomEvent(BWConstants._20241006_UCR) } } @Test @@ -135,12 +129,10 @@ class InAppReviewServiceTest { 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`() { + fun `given launch review flow fails, when showInAppReviewDialogIfNeeded, then does not mark done`() { every { BRSharedPrefs.isInAppReviewDone(app) } returns false every { BRSharedPrefs.getSendTransactionCount(app) } returns 5 every { requestTask.isSuccessful() } returns true @@ -151,7 +143,5 @@ class InAppReviewServiceTest { 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/data/repository/LtcRepositoryImplTest.kt b/app/src/test/java/com/brainwallet/data/repository/LtcRepositoryImplTest.kt new file mode 100644 index 00000000..93e24c6a --- /dev/null +++ b/app/src/test/java/com/brainwallet/data/repository/LtcRepositoryImplTest.kt @@ -0,0 +1,193 @@ +package com.brainwallet.data.repository + +import android.content.Context +import android.content.SharedPreferences +import android.net.Uri +import com.brainwallet.data.source.PeerManagerSource +import com.brainwallet.data.source.RemoteApiSource +import com.brainwallet.data.source.RemoteConfigSource +import com.brainwallet.data.source.response.GetMoonpaySignUrlResponse +import com.brainwallet.tools.sqlite.CurrencyDataSource +import com.brainwallet.tools.util.Utils +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.unmockkAll +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.runBlocking +import okhttp3.Call +import okhttp3.OkHttpClient +import okhttp3.Response +import okhttp3.ResponseBody +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import java.io.IOException + +class LtcRepositoryImplTest { + + private lateinit var context: Context + private lateinit var remoteApiSource: RemoteApiSource + private lateinit var remoteConfigSource: RemoteConfigSource + private lateinit var currencyDataSource: CurrencyDataSource + private lateinit var peerManagerSource: PeerManagerSource + private lateinit var sharedPreferences: SharedPreferences + private lateinit var okHttpClient: OkHttpClient + private lateinit var repository: LtcRepositoryImpl + + @Before + fun setUp() { + context = mockk(relaxed = true) + remoteApiSource = mockk(relaxed = true) + remoteConfigSource = mockk(relaxed = true) + currencyDataSource = mockk(relaxed = true) + peerManagerSource = mockk(relaxed = true) + sharedPreferences = mockk(relaxed = true) + okHttpClient = mockk() + + every { currencyDataSource.getAllCurrencies(any()) } returns emptyList() + + mockkStatic(Utils::class) + every { Utils.getEncryptedAgentString(any()) } returns "fake-agent-string" + + repository = LtcRepositoryImpl( + context = context, + remoteApiSource = remoteApiSource, + remoteConfigSource = remoteConfigSource, + currencyDataSource = currencyDataSource, + peerManagerSource = peerManagerSource, + // cancelled scope: the sync-loop launched in init{} must not run during these tests + repositoryScope = CoroutineScope(Job().apply { cancel() }), + sharedPreferences = sharedPreferences, + okHttpClient = okHttpClient, + ) + } + + @After + fun tearDown() { + unmockkAll() + } + + private fun stubResponse(isSuccessful: Boolean, body: String?) { + val call = mockk() + val response = mockk(relaxed = true) + every { okHttpClient.newCall(any()) } returns call + every { call.execute() } returns response + every { response.isSuccessful } returns isSuccessful + if (body != null) { + val responseBody = mockk() + every { responseBody.string() } returns body + every { response.body } returns responseBody + } else { + every { response.body } returns null + } + } + + // โ”€โ”€ fetchUserIpAddress โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + @Test + fun `fetchUserIpAddress returns trimmed ip on success`() = runBlocking { + stubResponse(isSuccessful = true, body = "203.0.113.5\n") + + val result = repository.fetchUserIpAddress() + + assertEquals("203.0.113.5", result) + } + + @Test + fun `fetchUserIpAddress returns empty string when response unsuccessful`() = runBlocking { + stubResponse(isSuccessful = false, body = "error") + + val result = repository.fetchUserIpAddress() + + assertEquals("", result) + } + + @Test + fun `fetchUserIpAddress returns empty string when body is null`() = runBlocking { + stubResponse(isSuccessful = true, body = null) + + val result = repository.fetchUserIpAddress() + + assertEquals("", result) + } + + @Test + fun `fetchUserIpAddress returns empty string when call throws`() = runBlocking { + val call = mockk() + every { okHttpClient.newCall(any()) } returns call + every { call.execute() } throws IOException("network down") + + val result = repository.fetchUserIpAddress() + + assertEquals("", result) + } + + // โ”€โ”€ fetchMoonpaySignedUrl โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + // fetchMoonpaySignedUrl pipes the backend's signedUrl through android.net.Uri + // (via the toUri()/buildUpon() KTX chain), which isn't mockable on the plain + // android.jar stub used by JVM unit tests โ€” stub it out so the tests can focus + // on the params passed upstream, which is the thing under test here. + private fun stubUriConstruction(returnedUrlString: String) { + mockkStatic(Uri::class) + val mockUri = mockk(relaxed = true) + val mockBuilder = mockk(relaxed = true) + every { Uri.parse(any()) } returns mockUri + every { mockUri.buildUpon() } returns mockBuilder + every { mockBuilder.build() } returns mockUri + every { mockUri.toString() } returns returnedUrlString + } + + @Test + fun `fetchMoonpaySignedUrl includes a freshly fetched ipAddress in the request params`() = runBlocking { + stubUriConstruction("https://buy.moonpay.com/signed") + stubResponse(isSuccessful = true, body = "198.51.100.7") + + val paramsSlot = slot>() + coEvery { + remoteApiSource.getMoonpaySignedUrl(capture(paramsSlot)) + } returns GetMoonpaySignUrlResponse(signedUrl = "https://buy.moonpay.com/signed") + + repository.fetchMoonpaySignedUrl(mapOf("walletAddress" to "LTC_FAKE_ADDRESS")) + + assertEquals("198.51.100.7", paramsSlot.captured["ipAddress"]) + } + + @Test + fun `fetchMoonpaySignedUrl still succeeds with empty ipAddress when ip lookup fails`() = runBlocking { + stubUriConstruction("https://buy.moonpay.com/signed") + val call = mockk() + every { okHttpClient.newCall(any()) } returns call + every { call.execute() } throws IOException("network down") + + val paramsSlot = slot>() + coEvery { + remoteApiSource.getMoonpaySignedUrl(capture(paramsSlot)) + } returns GetMoonpaySignUrlResponse(signedUrl = "https://buy.moonpay.com/signed") + + val result = repository.fetchMoonpaySignedUrl(mapOf("walletAddress" to "LTC_FAKE_ADDRESS")) + + assertEquals("", paramsSlot.captured["ipAddress"]) + assertEquals("https://buy.moonpay.com/signed", result) + } + + @Test + fun `fetchMoonpaySignedUrl does not let a caller-supplied ipAddress override the fresh lookup`() = runBlocking { + stubUriConstruction("https://buy.moonpay.com/signed") + stubResponse(isSuccessful = true, body = "198.51.100.7") + + val paramsSlot = slot>() + coEvery { + remoteApiSource.getMoonpaySignedUrl(capture(paramsSlot)) + } returns GetMoonpaySignUrlResponse(signedUrl = "https://buy.moonpay.com/signed") + + repository.fetchMoonpaySignedUrl(mapOf("walletAddress" to "LTC_FAKE_ADDRESS", "ipAddress" to "stale-value")) + + assertEquals("198.51.100.7", paramsSlot.captured["ipAddress"]) + } +} diff --git a/app/src/test/java/com/brainwallet/tools/database/DatabaseTests.kt b/app/src/test/java/com/brainwallet/tools/database/DatabaseTests.kt deleted file mode 100644 index b787fae3..00000000 --- a/app/src/test/java/com/brainwallet/tools/database/DatabaseTests.kt +++ /dev/null @@ -1,84 +0,0 @@ -package com.brainwallet.tools.database - -class DatabaseTests -// TODO: Reopen in Kotlin mocckk -// TODO: BRSQLiteHelper.BRAINWALLET_FIAT_CODES Test -// package com.brainwallet.analytics; -// import androidx.test.ext.junit.runners.AndroidJUnit4; -// import android.util.Log; -// import androidx.test.ext.junit.rules.ActivityScenarioRule; -// -// import com.breadwallet.presenter.activities.intro.IntroActivity; -// import com.breadwallet.tools.util.BRConstants; -// -// import org.junit.After; -// import org.junit.Assert; -// import org.junit.Before; -// import org.junit.Rule; -// import org.junit.Test; -// import org.junit.runner.RunWith; -// -// import java.net.URI; -// -// @Deprecated -// @RunWith(AndroidJUnit4.class) -// public class ConstantsTests { -// public static final String TAG = ConstantsTests.class.getName(); -// @Rule -// public ActivityScenarioRule mActivityRule = new ActivityScenarioRule<>(IntroActivity.class); -// @Before -// public void setUp() { -// Log.e(TAG, "setUp: "); -// } -// -// @After -// public void tearDown() { -// } -// @Test -// public void testLitecoinSymbolConstants() { -// Assert.assertSame(BWConstants.litecoinLowercase,"ล‚"); -// Assert.assertSame(BWConstants.litecoinUppercase,"ล"); -// } -// @Test -// public void testAppExternalURLConstants() { -// Assert.assertSame(BWConstants.TWITTER_LINK,"https://twitter.com/Brainwallet_App"); -// Assert.assertSame(BWConstants.INSTAGRAM_LINK,"https://www.instagram.com/brainwalletapp"); -// Assert.assertSame(BWConstants.WEB_LINK,"https://brainwallet.co"); -// Assert.assertSame(BWConstants.TOS_LINK,"https://brainwallet.co/privacy"); -// Assert.assertSame(BWConstants.CUSTOMER_SUPPORT_LINK,"https://support.brainwallet.co/hc/en-us/requests/new"); -// Assert.assertSame(BWConstants.BITREFILL_AFFILIATE_LINK,"https://www.bitrefill.com/"); -// } -// @Test -// public void testFirebaseAnalyticsConstants() { -// Assert.assertSame(BWConstants._20191105_AL,"app_launched"); -// Assert.assertSame(BWConstants._20191105_VSC,"visit_send_controller"); -// Assert.assertSame(BWConstants._20202116_VRC,"visit_receive_controller"); -// Assert.assertSame(BWConstants._20191105_DSL,"did_send_ltc"); -// Assert.assertSame(BWConstants._20191105_DTBT,"did_tap_buy_tab"); -// Assert.assertSame(BWConstants._20200111_RNI,"rate_not_initialized"); -// Assert.assertSame(BWConstants._20200111_FNI,"feeperkb_not_initialized"); -// Assert.assertSame(BWConstants._20200111_TNI,"transaction_not_initialized"); -// Assert.assertSame(BWConstants._20200111_WNI,"wallet_not_initialized"); -// Assert.assertSame(BWConstants._20200111_PNI,"phrase_not_initialized"); -// Assert.assertSame(BWConstants._20200111_UTST,"unable_to_sign_transaction"); -// Assert.assertSame(BWConstants._20200112_ERR,"error"); -// Assert.assertSame(BWConstants._20200112_DSR,"did_start_resync"); -// Assert.assertSame(BWConstants._20200125_DSRR,"did_show_review_request"); -// Assert.assertSame(BWConstants._20201118_DTGS,"did_tap_get_support"); -// Assert.assertSame(BWConstants._20200217_DUWP,"did_unlock_with_pin"); -// Assert.assertSame(BWConstants._20200217_DUWB,"did_unlock_with_biometrics"); -// Assert.assertSame(BWConstants._20201121_SIL,"started_IFPS_lookup"); -// Assert.assertSame(BWConstants._20201121_DRIA,"did_resolve_IPFS_address"); -// Assert.assertSame(BWConstants._20201121_FRIA,"failed_resolve_IPFS_address"); -// Assert.assertSame(BWConstants._20200207_DTHB,"did_tap_header_balance"); -// Assert.assertSame(BWConstants._20210427_HCIEEH,"heartbeat_check_if_event_even_happens"); -// Assert.assertSame(BWConstants._20220822_UTOU,"user_tapped_on_ud"); -// Assert.assertSame(BWConstants._20230131_NENR,"no_error_nominal_response"); -// Assert.assertSame(BWConstants._20230407_DCS,"did_complete_sync"); -// Assert.assertSame(BWConstants._20240123_RAGI,"registered_android_general_interest"); -// Assert.assertSame(BWConstants._20231225_UAP,"user_accepted_push"); -// Assert.assertSame(BWConstants._20240101_US,"user_signup"); -// Assert.assertSame(BWConstants._20241006_DRR,"did_request_rating"); -// Assert.assertSame(BWConstants._20241006_UCR,"user_completed_rating"); -// } -// } diff --git a/app/src/test/java/com/brainwallet/tools/util/UtilsAgentStringTest.kt b/app/src/test/java/com/brainwallet/tools/util/UtilsAgentStringTest.kt new file mode 100644 index 00000000..cb897c10 --- /dev/null +++ b/app/src/test/java/com/brainwallet/tools/util/UtilsAgentStringTest.kt @@ -0,0 +1,145 @@ +package com.brainwallet.tools.util + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test +import java.math.BigInteger +import java.nio.ByteBuffer +import java.nio.charset.StandardCharsets +import java.security.KeyFactory +import java.security.KeyPairGenerator +import java.security.interfaces.RSAPrivateKey +import java.security.interfaces.RSAPublicKey +import java.util.Base64 +import javax.crypto.Cipher + +/** + * Covers the fix for Utils.getEncryptedAgentString()'s public key parsing. + * + * The key provisioned in service-data.json's `agent-base64-pubkey` is a base64-encoded + * OpenSSH "ssh-rsa " line, not base64-encoded PEM. The previous + * implementation stripped whitespace before re-decoding, which merged the "ssh-rsa" prefix + * and the trailing email comment into the base64 payload and made it undecodable + * (surfacing as "ERROR-CANNOT-KEYBYTES-DO-CONVERSION"). + */ +class UtilsAgentStringTest { + + // The actual (public, non-secret) key currently provisioned in + // app/src/main/assets/service-data.json's "agent-base64-pubkey" field. + // Decodes to: "ssh-rsa AAAAB3NzaC1yc2E... kerry@grunt.ltd" + private val realProvisionedAgentPubkey = + "c3NoLXJzYSBBQUFBQjNOemFDMXljMkVBQUFBREFRQUJBQUFCQVFEUUpnWmRqbDh6QWk4QWNObUJQd28y" + + "ZEZDR3pHcWdIK0RPeEpEb09ZRmN6b0pSK3FoR2xPcUoxT1o5UmtEUWVyTHZrMG05czd2RkFuYzlpWDJm" + + "akpReWIzTFlwZ0R5RE85ZjVFcHl3MWRuMkpoMFhJRTF6ZXRVMHdZMDlpNmZWVjhMUFFpa05UUGZyMSt1" + + "b3c2R1NsbGJOYWpidmR5TGkwdVorc2ZkZmJFazlkM2RCTGR4STlMb1hoanE3ZmZuZGx5MmlTcUNxUEND" + + "ZU9BY3poSUY0OGlSdEJsRjNtNzdFQzVnSDVuVHdzbW1hd1REV0VPSUZiNzZOQmJKR3RUUWpZOStxeDRV" + + "dnJ3dmdZcXpzMW9PalNhMFd6QWtSWVV2MzUzSG1la3lSU3doeEMybm14QVZGRU5uSERaWE1oREllVHp4" + + "VWZ3ejllSXd0SDhHeENaMEUyb0oga2VycnlAZ3J1bnQubHRk" + + @Test + fun `parseOpenSshRsaPublicKey decodes the real provisioned agent pubkey`() { + val keySpec = Utils.parseOpenSshRsaPublicKey(realProvisionedAgentPubkey) + + assertEquals(BigInteger.valueOf(65537), keySpec.publicExponent) + assertEquals(2048, keySpec.modulus.bitLength()) + } + + @Test + fun `parseOpenSshRsaPublicKey produces a key KeyFactory can build`() { + val keySpec = Utils.parseOpenSshRsaPublicKey(realProvisionedAgentPubkey) + + val publicKey = KeyFactory.getInstance("RSA").generatePublic(keySpec) + + assertEquals("RSA", publicKey.algorithm) + } + + @Test + fun `full round trip - generated keypair encrypts and decrypts through the new parsing path`() { + val keyPair = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.genKeyPair() + val rsaPublicKey = keyPair.public as RSAPublicKey + val agentPubkeyLine = buildOpenSshRsaLine(rsaPublicKey.publicExponent, rsaPublicKey.modulus) + val base64EncodedAgentPubkey = Base64.getEncoder().encodeToString( + agentPubkeyLine.toByteArray(StandardCharsets.UTF_8) + ) + + val keySpec = Utils.parseOpenSshRsaPublicKey(base64EncodedAgentPubkey) + val publicKey = KeyFactory.getInstance("RSA").generatePublic(keySpec) + + val encryptCipher = Cipher.getInstance("RSA/ECB/PKCS1Padding") + encryptCipher.init(Cipher.ENCRYPT_MODE, publicKey) + val plaintext = "brainwallet-android,1.0,manufacturer-device-model,some-uuid" + val encrypted = encryptCipher.doFinal(plaintext.toByteArray()) + + val decryptCipher = Cipher.getInstance("RSA/ECB/PKCS1Padding") + decryptCipher.init(Cipher.DECRYPT_MODE, keyPair.private as RSAPrivateKey) + val decrypted = String(decryptCipher.doFinal(encrypted)) + + assertEquals(plaintext, decrypted) + } + + @Test + fun `parseSshRsaPublicKeyBlob rejects a non-RSA SSH key type`() { + val blob = buildSshWireFormat( + keyType = "ssh-ed25519", + fields = listOf(ByteArray(32)) + ) + + assertThrows(IllegalArgumentException::class.java) { + Utils.parseSshRsaPublicKeyBlob(blob) + } + } + + @Test + fun `parseOpenSshRsaPublicKey throws when the OpenSSH line has no key blob field`() { + val lineWithoutKeyBlob = "ssh-rsa" + val base64Encoded = Base64.getEncoder().encodeToString( + lineWithoutKeyBlob.toByteArray(StandardCharsets.UTF_8) + ) + + assertThrows(IllegalArgumentException::class.java) { + Utils.parseOpenSshRsaPublicKey(base64Encoded) + } + } + + @Test + fun `parseOpenSshRsaPublicKey throws on garbage input instead of silently misparsing`() { + val notBase64AtAll = "%%%not-base64%%%" + + assertThrows(IllegalArgumentException::class.java) { + Utils.parseOpenSshRsaPublicKey(notBase64AtAll) + } + } + + @Test + fun `regression - the old PEM-header-stripping bug is not reproduced by the fix`() { + // This is exactly the shape of value that broke the previous implementation: + // an OpenSSH line whose fields, once whitespace is stripped, are no longer valid + // base64 (the "ssh-rsa" prefix and "kerry@grunt.ltd" comment corrupt the payload). + // The fix must not fall into that trap: it correctly isolates the key blob field + // instead of concatenating the whole line. + val keySpec = Utils.parseOpenSshRsaPublicKey(realProvisionedAgentPubkey) + assertEquals(BigInteger.valueOf(65537), keySpec.publicExponent) + } + + private fun buildOpenSshRsaLine(exponent: BigInteger, modulus: BigInteger): String { + val blob = buildSshWireFormat( + keyType = "ssh-rsa", + fields = listOf(exponent.toByteArray(), modulus.toByteArray()) + ) + val blobBase64 = Base64.getEncoder().encodeToString(blob) + return "ssh-rsa $blobBase64 test@brainwallet" + } + + private fun buildSshWireFormat(keyType: String, fields: List): ByteArray { + val typeBytes = keyType.toByteArray(StandardCharsets.UTF_8) + val totalSize = 4 + typeBytes.size + fields.sumOf { 4 + it.size } + val buffer = ByteBuffer.allocate(totalSize) + buffer.putInt(typeBytes.size) + buffer.put(typeBytes) + for (field in fields) { + buffer.putInt(field.size) + buffer.put(field) + } + return buffer.array() + } +} diff --git a/app/src/test/java/com/brainwallet/wallet/BRPeerManagerTest.kt b/app/src/test/java/com/brainwallet/wallet/BRPeerManagerTest.kt new file mode 100644 index 00000000..200b7249 --- /dev/null +++ b/app/src/test/java/com/brainwallet/wallet/BRPeerManagerTest.kt @@ -0,0 +1,79 @@ +package com.brainwallet.wallet + +import com.google.firebase.crashlytics.FirebaseCrashlytics +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.unmockkStatic +import io.mockk.verify +import org.junit.After +import org.junit.Before +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Covers [BRPeerManager.onIntegrityWarning], the Java-side half of the native integrity-warning + * path added alongside the NULL-`lastBlock` guards in core's `BRPeerManager.c` + * (`BRPeerManagerSetIntegrityWarningCallback`). The native guard itself lives in the `core` + * submodule and isn't covered by this JVM suite. + */ +class BRPeerManagerTest { + + private val mockCrashlytics: FirebaseCrashlytics = mockk(relaxed = true) + + @Before + fun setUp() { + mockkStatic(FirebaseCrashlytics::class) + every { FirebaseCrashlytics.getInstance() } returns mockCrashlytics + } + + @After + fun tearDown() { + unmockkStatic(FirebaseCrashlytics::class) + } + + @Test + fun `onIntegrityWarning reports a RuntimeException to Crashlytics`() { + BRPeerManager.onIntegrityWarning("BRPeerManagerLastBlockHeight: lastBlock is NULL") + + val captured = slot() + verify(exactly = 1) { mockCrashlytics.recordException(capture(captured)) } + assertTrue(captured.captured is RuntimeException) + } + + @Test + fun `onIntegrityWarning includes the native warning text verbatim in the recorded exception message`() { + val warning = "BRPeerManagerRescan: checkpoint block missing from block set, lastBlock left unchanged" + + BRPeerManager.onIntegrityWarning(warning) + + val captured = slot() + verify { mockCrashlytics.recordException(capture(captured)) } + assertEquals("BRPeerManager native integrity warning: $warning", captured.captured.message) + } + + @Test + fun `onIntegrityWarning records once per call for each distinct native warning site`() { + // Mirrors the four call sites added in core's BRPeerManager.c + listOf( + "BRPeerManagerRescan: checkpoint block missing from block set, lastBlock left unchanged", + "BRPeerManagerEstimatedBlockHeight: lastBlock is NULL", + "BRPeerManagerLastBlockHeight: lastBlock is NULL", + "BRPeerManagerLastBlockTimestamp: lastBlock is NULL", + ).forEach { BRPeerManager.onIntegrityWarning(it) } + + verify(exactly = 4) { mockCrashlytics.recordException(any()) } + } + + @Test + fun `onIntegrityWarning does not throw when warning is null`() { + // Defensive: the native side always passes a short static string, but the JNI bridge + // (PeerManager.c's integrityWarning()) technically permits a null jstring โ€” this must + // never crash the calling native thread. + BRPeerManager.onIntegrityWarning(null) + + verify(exactly = 1) { mockCrashlytics.recordException(any()) } + } +} diff --git a/app/src/test/kotlin/com/brainwallet/consants/BWConstantsTest.kt b/app/src/test/kotlin/com/brainwallet/consants/BWConstantsTest.kt index 8353f12d..ed5f5075 100644 --- a/app/src/test/kotlin/com/brainwallet/consants/BWConstantsTest.kt +++ b/app/src/test/kotlin/com/brainwallet/consants/BWConstantsTest.kt @@ -289,7 +289,6 @@ class BWConstantsTest { @Test fun `all analytics event keys are non-empty`() { listOf( - BWConstants._20191105_AL, BWConstants._20191105_VSC, BWConstants._20202116_VRC, BWConstants._20191105_DSL, @@ -301,11 +300,7 @@ class BWConstantsTest { BWConstants._20200112_DSR, BWConstants._20201118_DTGS, BWConstants._20200217_DU, - BWConstants._20250303_DSTU, BWConstants._20250517_WCINFO, - BWConstants._20241006_DRR, - BWConstants._20241006_UCR, - BWConstants._HOME_OPEN, ).forEach { key -> assertTrue("Analytics key should not be empty", key.isNotEmpty()) } @@ -314,7 +309,6 @@ class BWConstantsTest { @Test fun `all analytics event keys are distinct`() { val keys = listOf( - BWConstants._20191105_AL, BWConstants._20191105_VSC, BWConstants._20202116_VRC, BWConstants._20191105_DSL, @@ -326,11 +320,7 @@ class BWConstantsTest { BWConstants._20200112_DSR, BWConstants._20201118_DTGS, BWConstants._20200217_DU, - BWConstants._20250303_DSTU, BWConstants._20250517_WCINFO, - BWConstants._20241006_DRR, - BWConstants._20241006_UCR, - BWConstants._HOME_OPEN, ) assertEquals(keys.size, keys.toSet().size) } diff --git a/app/src/test/kotlin/com/brainwallet/tools/constants/BWConstantTests.kt b/app/src/test/kotlin/com/brainwallet/tools/constants/BWConstantTests.kt index 6bdadc45..e3d9e59d 100644 --- a/app/src/test/kotlin/com/brainwallet/tools/constants/BWConstantTests.kt +++ b/app/src/test/kotlin/com/brainwallet/tools/constants/BWConstantTests.kt @@ -157,7 +157,6 @@ class BWConstantsTests { @Test fun `validate active Firebase analytics event constants`() { - assertSame(BWConstants._20191105_AL, "app_launched") assertSame(BWConstants._20191105_VSC, "visit_send_controller") assertSame(BWConstants._20202116_VRC, "visit_receive_controller") assertSame(BWConstants._20191105_DSL, "did_send_ltc") @@ -173,10 +172,6 @@ class BWConstantsTests { @Test fun `validate recent Firebase analytics event constants`() { - assertSame(BWConstants._20241006_DRR, "did_request_rating") - assertSame(BWConstants._20241006_UCR, "user_completed_rating") - assertSame(BWConstants._HOME_OPEN, "home_open") - assertSame(BWConstants._20250303_DSTU, "did_skip_top_up") assertSame(BWConstants._20250517_WCINFO, "wallet_callback_info") } diff --git a/app/src/test/kotlin/com/brainwallet/tools/manager/AnalyticsEventAuditTest.kt b/app/src/test/kotlin/com/brainwallet/tools/manager/AnalyticsEventAuditTest.kt new file mode 100644 index 00000000..a8b9b8e0 --- /dev/null +++ b/app/src/test/kotlin/com/brainwallet/tools/manager/AnalyticsEventAuditTest.kt @@ -0,0 +1,120 @@ +package com.brainwallet.tools.manager + +import org.junit.Test +import java.io.File + +/** + * Informational test that scans app/src/main for every `AnalyticsManager.log*` call site + * (logCustomEvent / logCustomEventWithParams / logCustomAdHocEvent) and reports: + * - The total number of call sites + * - Each placement (file:line, method, event name/expression) + * - A count of call sites grouped by resolved event name + * - A count of call sites grouped by method + * + * This test NEVER fails on the audit contents โ€” it is purely diagnostic, so adding, removing, + * or renaming an analytics event elsewhere in the app does not break CI. It only fails if the + * scan itself can't run (e.g. the source tree moved and no call sites were found at all), which + * would mean the audit is broken, not that analytics events changed. + * + * Usage: + * ./gradlew :app:testDebugUnitTest --tests "*.AnalyticsEventAuditTest" + */ +class AnalyticsEventAuditTest { + + companion object { + private val SRC_MAIN_JAVA_DIR: File by lazy { + val candidates = listOf( + File("src/main/java"), + File("app/src/main/java"), + ) + candidates.firstOrNull { it.isDirectory } + ?: error( + "Cannot locate app/src/main/java. " + + "Searched: ${candidates.map { it.absolutePath }}" + ) + } + + private val CALL_SITE_PATTERN = Regex( + """AnalyticsManager\.(logCustomEvent|logCustomEventWithParams|logCustomAdHocEvent)\s*\(\s*([^,)]+)""" + ) + } + + private data class EventPlacement( + val displayPath: String, + val line: Int, + val method: String, + val eventExpression: String, + ) + + @Test + fun `print AnalyticsManager log call site audit`() { + val placements = scanForCallSites() + check(placements.isNotEmpty()) { + "No AnalyticsManager.log* call sites found under ${SRC_MAIN_JAVA_DIR.absolutePath} " + + "โ€” the audit scan is likely broken (source layout changed?), not that analytics calls vanished." + } + + println(buildReport(placements)) + } + + private fun scanForCallSites(): List { + val placements = mutableListOf() + + SRC_MAIN_JAVA_DIR.walkTopDown() + .filter { it.isFile && (it.extension == "kt" || it.extension == "java") } + .forEach { file -> + val displayPath = "app/src/main/java/" + + file.relativeTo(SRC_MAIN_JAVA_DIR).path.replace(File.separatorChar, '/') + + file.readLines().forEachIndexed { index, rawLine -> + val trimmed = rawLine.trimStart() + if (trimmed.startsWith("//") || trimmed.startsWith("*")) return@forEachIndexed + + val match = CALL_SITE_PATTERN.find(rawLine) ?: return@forEachIndexed + val (method, eventExpression) = match.destructured + placements += EventPlacement( + displayPath = displayPath, + line = index + 1, + method = method, + eventExpression = eventExpression.trim(), + ) + } + } + + return placements + } + + private fun buildReport(placements: List): String = buildString { + val divider = "=".repeat(100) + + appendLine(divider) + appendLine("AnalyticsManager.log* call-site audit โ€” ${placements.size} total placements") + appendLine(divider) + placements + .sortedWith(compareBy({ it.displayPath }, { it.line })) + .forEach { + appendLine("${it.displayPath}:${it.line} [${it.method}] ${it.eventExpression}") + } + + appendLine() + appendLine("-- By event name/expression " + "-".repeat(70)) + placements.groupBy { it.eventExpression } + .toSortedMap() + .forEach { (event, sites) -> + appendLine(" ${sites.size.toString().padStart(2)}x $event") + sites.forEach { appendLine(" ${it.displayPath}:${it.line}") } + } + + appendLine() + appendLine("-- By method " + "-".repeat(85)) + placements.groupBy { it.method } + .toSortedMap() + .forEach { (method, sites) -> + appendLine(" ${sites.size.toString().padStart(2)}x $method") + } + + appendLine() + appendLine("Distinct event names/expressions: ${placements.map { it.eventExpression }.distinct().size}") + appendLine(divider) + } +} diff --git a/app/src/test/kotlin/com/brainwallet/ui/bentosections/buyreceive/ReceiveDialogViewModelTest.kt b/app/src/test/kotlin/com/brainwallet/ui/bentosections/buyreceive/ReceiveDialogViewModelTest.kt index aaeeff5d..c03891dc 100644 --- a/app/src/test/kotlin/com/brainwallet/ui/bentosections/buyreceive/ReceiveDialogViewModelTest.kt +++ b/app/src/test/kotlin/com/brainwallet/ui/bentosections/buyreceive/ReceiveDialogViewModelTest.kt @@ -13,9 +13,11 @@ import com.brainwallet.ui.bentosections.buyreceivebento.receive.ReceiveDialogVie import com.brainwallet.ui.bentosections.buyreceivebento.receive.ReceiveDialogEvent import com.brainwallet.ui.bentosections.buyreceivebento.receive.getQuickFiatAmountOptions import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic +import io.mockk.slot import io.mockk.unmockkAll import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -43,6 +45,7 @@ class ReceiveDialogViewModelTest { private lateinit var viewModel: ReceiveDialogViewModel private val settingsFlow = MutableStateFlow(AppSetting()) + private val currentSettingsFlow = MutableStateFlow(AppSetting()) private val fakeCurrencyEntity = CurrencyEntity(code = "USD", name = "US Dollar", rate = 1.0f, symbol = "USD") private val fakeFiatCurrencies = listOf(fakeCurrencyEntity) @@ -68,9 +71,11 @@ class ReceiveDialogViewModelTest { every { BRSharedPrefs.getReceiveAddress(any()) } returns "LTC_FAKE_ADDRESS" every { QRUtils.generateQR(any(), any()) } returns null every { settingRepository.settings } returns settingsFlow + every { settingRepository.currentSettings } returns currentSettingsFlow coEvery { ltcRepository.fetchLimits(any()) } returns mockk(relaxed = true) coEvery { ltcRepository.fetchBuyQuote(any()) } returns mockk(relaxed = true) + coEvery { ltcRepository.fetchMoonpaySignedUrl(any()) } returns "https://buy.moonpay.com/signed" viewModel = ReceiveDialogViewModel( settingRepository = settingRepository, @@ -176,6 +181,44 @@ class ReceiveDialogViewModelTest { assertEquals(0, viewModel.state.value.selectedQuickFiatAmountOptionIndex) } + // โ”€โ”€ OnMoonpayButtonClick โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + @Test + fun `OnMoonpayButtonClick sets moonpayBuySignedUrl from ltcRepository`() = runTest { + viewModel.onEvent(ReceiveDialogEvent.OnLoad(context)) + advanceUntilIdle() + + viewModel.onEvent(ReceiveDialogEvent.OnMoonpayButtonClick) + advanceUntilIdle() + + assertEquals("https://buy.moonpay.com/signed", viewModel.state.value.moonpayBuySignedUrl) + } + + @Test + fun `OnMoonpayButtonClick includes walletAddress from state`() = runTest { + val paramsSlot = slot>() + coEvery { ltcRepository.fetchMoonpaySignedUrl(capture(paramsSlot)) } returns "https://buy.moonpay.com/signed" + + viewModel.onEvent(ReceiveDialogEvent.OnLoad(context)) + advanceUntilIdle() + + viewModel.onEvent(ReceiveDialogEvent.OnMoonpayButtonClick) + advanceUntilIdle() + + assertEquals("LTC_FAKE_ADDRESS", paramsSlot.captured["walletAddress"]) + } + + @Test + fun `OnMoonpayButtonClick calls fetchMoonpaySignedUrl exactly once`() = runTest { + viewModel.onEvent(ReceiveDialogEvent.OnLoad(context)) + advanceUntilIdle() + + viewModel.onEvent(ReceiveDialogEvent.OnMoonpayButtonClick) + advanceUntilIdle() + + coVerify(exactly = 1) { ltcRepository.fetchMoonpaySignedUrl(any()) } + } + // โ”€โ”€ OnSignedUrlClear โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @Test diff --git a/bw-gdlib b/bw-gdlib index db543da2..12649408 160000 --- a/bw-gdlib +++ b/bw-gdlib @@ -1 +1 @@ -Subproject commit db543da26e36ce1955fa7c596fe8b85099afe629 +Subproject commit 126494081fd334bc83c7dd2a09ba8df7df7c6ba4