diff --git a/app/src/androidTest/java/to/bitkit/services/OnchainServiceTests.kt b/app/src/androidTest/java/to/bitkit/services/OnchainServiceTests.kt
index aaabf6b96..a766dcbfc 100644
--- a/app/src/androidTest/java/to/bitkit/services/OnchainServiceTests.kt
+++ b/app/src/androidTest/java/to/bitkit/services/OnchainServiceTests.kt
@@ -1,6 +1,7 @@
package to.bitkit.services
import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.synonym.bitkitcore.AccountType
import com.synonym.bitkitcore.AddressType
import kotlinx.coroutines.runBlocking
import org.junit.Before
@@ -11,6 +12,7 @@ import to.bitkit.models.toDerivationPath
import to.bitkit.test.annotations.CoreServiceIntegration
import to.bitkit.test.annotations.DeviceIntegration
import kotlin.test.assertEquals
+import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
@@ -146,6 +148,86 @@ class OnchainServiceTests {
assertNotNull(result.publicKey)
}
+ @Test
+ fun testDeriveBitcoinDescriptorsForSupportedAccountTypes(): Unit = runBlocking {
+ val cases = listOf(
+ DescriptorCase(AccountType.LEGACY, "pkh([", "44"),
+ DescriptorCase(AccountType.WRAPPED_SEGWIT, "sh(wpkh([", "49"),
+ DescriptorCase(AccountType.NATIVE_SEGWIT, "wpkh([", "84"),
+ DescriptorCase(AccountType.TAPROOT, "tr([", "86"),
+ )
+
+ cases.forEach { descriptorCase ->
+ assertDescriptorShape(
+ descriptorCase = descriptorCase,
+ network = Network.BITCOIN,
+ coinType = "0",
+ publicKeyPrefix = "xpub",
+ )
+ }
+ }
+
+ @Test
+ fun testDeriveRegtestDescriptorsForSupportedAccountTypes(): Unit = runBlocking {
+ val cases = listOf(
+ DescriptorCase(AccountType.LEGACY, "pkh([", "44"),
+ DescriptorCase(AccountType.WRAPPED_SEGWIT, "sh(wpkh([", "49"),
+ DescriptorCase(AccountType.NATIVE_SEGWIT, "wpkh([", "84"),
+ DescriptorCase(AccountType.TAPROOT, "tr([", "86"),
+ )
+
+ cases.forEach { descriptorCase ->
+ assertDescriptorShape(
+ descriptorCase = descriptorCase,
+ network = Network.REGTEST,
+ coinType = "1",
+ publicKeyPrefix = "tpub",
+ )
+ }
+ }
+
+ @Test
+ fun testDeriveBitcoinNativeSegwitDescriptorWithPassphrase(): Unit = runBlocking {
+ val descriptor = onchainService.deriveOnchainDescriptor(
+ mnemonicPhrase = mnemonic,
+ network = Network.BITCOIN,
+ bip39Passphrase = "TREZOR",
+ accountType = AccountType.NATIVE_SEGWIT,
+ accountIndex = 0u,
+ )
+ val descriptorWithoutPassphrase = onchainService.deriveOnchainDescriptor(
+ mnemonicPhrase = mnemonic,
+ network = Network.BITCOIN,
+ bip39Passphrase = null,
+ accountType = AccountType.NATIVE_SEGWIT,
+ accountIndex = 0u,
+ )
+
+ assertTrue(descriptor.startsWith("wpkh(["))
+ assertTrue(descriptor.contains("/84'/0'/0']xpub"))
+ assertTrue(descriptor.contains("/0/*"))
+ assertFalse(descriptor == descriptorWithoutPassphrase)
+ }
+
+ private suspend fun assertDescriptorShape(
+ descriptorCase: DescriptorCase,
+ network: Network,
+ coinType: String,
+ publicKeyPrefix: String,
+ ) {
+ val descriptor = onchainService.deriveOnchainDescriptor(
+ mnemonicPhrase = mnemonic,
+ network = network,
+ bip39Passphrase = null,
+ accountType = descriptorCase.accountType,
+ accountIndex = 0u,
+ )
+
+ assertTrue(descriptor.startsWith(descriptorCase.prefix))
+ assertTrue(descriptor.contains("/${descriptorCase.purpose}'/$coinType'/0']$publicKeyPrefix"))
+ assertTrue(descriptor.contains("/0/*"))
+ }
+
@Test
fun testDeriveAddresses(): Unit = runBlocking {
val derivationPath = AddressType.P2WPKH.toDerivationPath(network = Network.BITCOIN)
@@ -233,3 +315,9 @@ class OnchainServiceTests {
}
}
+
+private data class DescriptorCase(
+ val accountType: AccountType,
+ val prefix: String,
+ val purpose: String,
+)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 78b629540..075d512b3 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -127,6 +127,23 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/to/bitkit/models/SamRockSetupRequest.kt b/app/src/main/java/to/bitkit/models/SamRockSetupRequest.kt
new file mode 100644
index 000000000..28f097d66
--- /dev/null
+++ b/app/src/main/java/to/bitkit/models/SamRockSetupRequest.kt
@@ -0,0 +1,351 @@
+package to.bitkit.models
+
+import java.net.URI
+import java.net.URLDecoder
+import java.net.URLEncoder
+import java.nio.charset.StandardCharsets
+import java.security.MessageDigest
+import java.util.Locale
+
+data class SamRockSetupRequest(
+ val postUrl: String,
+ val storeId: String,
+ val otp: String,
+ val requestedMethods: Set,
+ val hasUnknownMethods: Boolean,
+ val hostDisplayName: String,
+ val logDescription: String,
+) {
+ companion object {
+ @Suppress("CyclomaticComplexMethod", "ReturnCount")
+ fun parse(raw: String): SamRockSetupRequest? {
+ val uri = runCatching { URI(raw.trim()) }.getOrNull() ?: return null
+ val scheme = uri.scheme?.lowercase(Locale.US) ?: return null
+ val host = uri.host ?: return null
+ if (uri.rawUserInfo != null) return null
+
+ val isAllowedScheme = scheme == HTTPS_SCHEME || scheme == HTTP_SCHEME && isLocalOrPrivateHost(host)
+ if (!isAllowedScheme) return null
+
+ val pathComponents = uri.decodedPathComponents()
+ ?: return null
+ val storeId = pathComponents.samRockStoreId() ?: return null
+
+ val queryItems = runCatching { parseQuery(uri.rawQuery ?: return null) }.getOrNull() ?: return null
+ val otp = queryItems.firstValue(OTP_QUERY_KEY)?.takeIf { it.isNotBlank() } ?: return null
+ val setup = queryItems.firstValue(SETUP_QUERY_KEY)
+ val parsedMethods = parseMethods(setup)
+
+ return SamRockSetupRequest(
+ postUrl = buildPostUrl(uri, setup, otp),
+ storeId = storeId,
+ otp = otp,
+ requestedMethods = parsedMethods.methods,
+ hasUnknownMethods = parsedMethods.hasUnknownMethods,
+ hostDisplayName = uri.hostDisplayName(),
+ logDescription = uri.logDescription(),
+ )
+ }
+
+ fun sanitizedDescription(raw: String): String? {
+ val trimmed = raw.trim()
+ val uri = runCatching { URI(trimmed) }.getOrNull()
+ if (uri?.hasAuthority() == true && uri.isSamRockProtocolPath()) return uri.logDescription()
+
+ return trimmed.sanitizedSamRockLikeDescription()
+ }
+
+ fun sanitizedLaunchKey(raw: String): String? {
+ return sanitizedDescription(raw)?.let { "$it#${raw.sha256Prefix()}" }
+ }
+
+ fun isProtocolUrl(raw: String): Boolean {
+ return sanitizedDescription(raw) != null
+ }
+
+ fun isPublicHttpProtocolUrl(raw: String): Boolean {
+ val uri = runCatching { URI(raw.trim()) }.getOrNull() ?: return false
+ val scheme = uri.scheme?.lowercase(Locale.US) ?: return false
+ val host = uri.host ?: return false
+
+ return scheme == HTTP_SCHEME &&
+ uri.isSamRockProtocolPath() &&
+ !isLocalOrPrivateHost(host)
+ }
+
+ private fun parseMethods(setup: String?): ParsedSamRockMethods {
+ val values = setup
+ ?.split(SETUP_METHOD_SEPARATOR)
+ ?.map { it.trim().lowercase(Locale.US) }
+ ?.filter { it.isNotEmpty() }
+ .orEmpty()
+
+ if (values.isEmpty()) {
+ return ParsedSamRockMethods(
+ methods = setOf(SamRockPaymentMethod.ALL),
+ hasUnknownMethods = false,
+ )
+ }
+
+ val methods = values.mapNotNull(SamRockPaymentMethod::fromProtocolValue).toSet()
+ return ParsedSamRockMethods(
+ methods = methods,
+ hasUnknownMethods = values.any { SamRockPaymentMethod.fromProtocolValue(it) == null },
+ )
+ }
+
+ private fun buildPostUrl(
+ uri: URI,
+ setup: String?,
+ otp: String,
+ ): String {
+ val query = buildList {
+ setup?.let { add("$SETUP_QUERY_KEY=${encodeQueryValue(it)}") }
+ add("$OTP_QUERY_KEY=${encodeQueryValue(otp)}")
+ }.joinToString("&")
+
+ val authority = uri.rawAuthority ?: uri.host
+ return "${uri.scheme}://$authority${uri.rawPath}?$query"
+ }
+
+ private fun parseQuery(rawQuery: String): List> {
+ return rawQuery
+ .split("&")
+ .filter { it.isNotEmpty() }
+ .map {
+ val parts = it.split("=", limit = 2)
+ decode(parts[0]) to decode(parts.getOrElse(1) { "" })
+ }
+ }
+
+ private fun List>.firstValue(key: String): String? {
+ return firstOrNull { it.first.equals(key, ignoreCase = true) }?.second
+ }
+
+ private fun URI.hostDisplayName(): String {
+ val portSuffix = port.takeIf { it != NO_PORT }?.let { ":$it" }.orEmpty()
+ return "$host$portSuffix"
+ }
+
+ private fun URI.logDescription(): String {
+ return "$scheme://${hostWithPort()}$rawPath"
+ }
+
+ private fun URI.hostWithPort(): String {
+ val formattedHost = host?.takeIf { ":" in it }?.let { "[$it]" } ?: host.orEmpty()
+ val portSuffix = port.takeIf { it != NO_PORT }?.let { ":$it" }.orEmpty()
+ return "$formattedHost$portSuffix"
+ }
+
+ private fun URI.isSamRockProtocolPath(): Boolean {
+ val pathComponents = decodedPathComponents()
+ ?: return false
+
+ return pathComponents.samRockStoreId() != null
+ }
+
+ private fun List.samRockStoreId(): String? {
+ if (size < SAMROCK_PROTOCOL_COMPONENT_COUNT) return null
+
+ val pluginsIndex = size - SAMROCK_PROTOCOL_COMPONENT_COUNT
+ if (this[pluginsIndex] != PLUGINS_PATH_COMPONENT) return null
+ if (!this[pluginsIndex + 2].equals(SAMROCK_PATH_COMPONENT, ignoreCase = true)) return null
+ if (!this[pluginsIndex + 3].equals(PROTOCOL_PATH_COMPONENT, ignoreCase = true)) return null
+
+ return this[pluginsIndex + 1]
+ }
+
+ private fun URI.hasAuthority(): Boolean {
+ return scheme != null && host != null
+ }
+
+ private fun String.sanitizedSamRockLikeDescription(): String? {
+ val queryStart = indexOfAny(SENSITIVE_URL_DELIMITERS)
+ val withoutQuery = if (queryStart == NOT_FOUND) this else substring(0, queryStart)
+ val normalized = withoutQuery.lowercase(Locale.US)
+ val isSamRockLikePath = PLUGINS_PATH_MARKER in normalized &&
+ SAMROCK_PROTOCOL_PATH_MARKER in normalized
+
+ if (!isSamRockLikePath) return null
+
+ return runCatching { URI(withoutQuery) }
+ .getOrNull()
+ ?.takeIf { it.hasAuthority() }
+ ?.logDescription()
+ ?: withoutQuery.stripUserInfoFromAuthority()
+ }
+
+ private fun String.stripUserInfoFromAuthority(): String {
+ val schemeEnd = indexOf(SCHEME_SEPARATOR)
+ if (schemeEnd == NOT_FOUND) return this
+
+ val authorityStart = schemeEnd + SCHEME_SEPARATOR.length
+ val pathStart = indexOf(PATH_SEPARATOR, startIndex = authorityStart)
+ .takeUnless { it == NOT_FOUND }
+ ?: length
+ val authority = substring(authorityStart, pathStart)
+ val safeAuthority = authority.substringAfterLast(USER_INFO_SEPARATOR)
+
+ return substring(0, authorityStart) + safeAuthority + substring(pathStart)
+ }
+
+ private fun URI.decodedPathComponents(): List? {
+ return runCatching {
+ rawPath
+ ?.split(PATH_SEPARATOR)
+ ?.filter { it.isNotBlank() }
+ ?.map(::decode)
+ }.getOrNull()
+ }
+
+ private fun decode(value: String): String {
+ return URLDecoder.decode(value.replace("+", "%2B"), StandardCharsets.UTF_8.name())
+ }
+
+ private fun encodeQueryValue(value: String): String {
+ return URLEncoder.encode(value, StandardCharsets.UTF_8.name()).replace("+", "%20")
+ }
+
+ private fun String.sha256Prefix(): String {
+ val bytes = MessageDigest.getInstance(SHA_256_ALGORITHM).digest(trim().toByteArray(StandardCharsets.UTF_8))
+ return bytes.take(LAUNCH_KEY_HASH_BYTES).joinToString("") { "%02x".format(it) }
+ }
+
+ private fun isLocalOrPrivateHost(host: String): Boolean {
+ val normalized = host
+ .removePrefix("[")
+ .removeSuffix("]")
+ .lowercase(Locale.US)
+
+ return normalized.isLocalHost() ||
+ normalized.isPrivateIpv6Literal() ||
+ normalized.isPrivateIpv4Literal()
+ }
+
+ private fun String.isLocalHost(): Boolean {
+ return this == LOCALHOST || endsWith(LOCAL_HOST_SUFFIX) || this == IPV6_LOOPBACK
+ }
+
+ private fun String.isPrivateIpv6Literal(): Boolean {
+ return isIpv6Literal() && IPV6_PRIVATE_PREFIXES.any(::startsWith)
+ }
+
+ @Suppress("MagicNumber")
+ private fun String.isPrivateIpv4Literal(): Boolean {
+ val octets = split(".").map {
+ it.toIntOrNull() ?: return false
+ }
+ if (octets.size != IPV4_OCTET_COUNT) return false
+ if (octets.any { it !in IPV4_OCTET_RANGE }) return false
+
+ return when (octets[0]) {
+ 10, 127 -> true
+ 172 -> octets[1] in 16..31
+ 192 -> octets[1] == 168
+ 169 -> octets[1] == 254
+ else -> false
+ }
+ }
+
+ private fun String.isIpv6Literal(): Boolean = ":" in this
+
+ private const val HTTP_SCHEME = "http"
+ private const val HTTPS_SCHEME = "https"
+ private const val LOCALHOST = "localhost"
+ private const val LOCAL_HOST_SUFFIX = ".local"
+ private const val IPV6_LOOPBACK = "::1"
+ private const val IPV6_LINK_LOCAL_PREFIX = "fe80:"
+ private const val IPV6_UNIQUE_LOCAL_FC_PREFIX = "fc"
+ private const val IPV6_UNIQUE_LOCAL_FD_PREFIX = "fd"
+ private val IPV6_PRIVATE_PREFIXES = listOf(
+ IPV6_LINK_LOCAL_PREFIX,
+ IPV6_UNIQUE_LOCAL_FC_PREFIX,
+ IPV6_UNIQUE_LOCAL_FD_PREFIX,
+ )
+ private const val IPV4_OCTET_COUNT = 4
+ private val IPV4_OCTET_RANGE = 0..255
+ private const val NO_PORT = -1
+ private const val NOT_FOUND = -1
+ private const val PATH_SEPARATOR = '/'
+ private const val SCHEME_SEPARATOR = "://"
+ private const val USER_INFO_SEPARATOR = '@'
+ private const val SAMROCK_PROTOCOL_COMPONENT_COUNT = 4
+ private const val PLUGINS_PATH_COMPONENT = "plugins"
+ private const val SAMROCK_PATH_COMPONENT = "samrock"
+ private const val PROTOCOL_PATH_COMPONENT = "protocol"
+ private const val PLUGINS_PATH_MARKER = "/plugins/"
+ private const val SAMROCK_PROTOCOL_PATH_MARKER = "/samrock/protocol"
+ private const val OTP_QUERY_KEY = "otp"
+ private const val SETUP_QUERY_KEY = "setup"
+ private const val SETUP_METHOD_SEPARATOR = ","
+ private const val SHA_256_ALGORITHM = "SHA-256"
+ private const val LAUNCH_KEY_HASH_BYTES = 8
+ private val SENSITIVE_URL_DELIMITERS = charArrayOf('?', '#')
+ }
+
+ val requestsBitcoinOnchain: Boolean
+ get() = requestedMethods.any {
+ it == SamRockPaymentMethod.ALL ||
+ it == SamRockPaymentMethod.BTC ||
+ it == SamRockPaymentMethod.BTC_ONCHAIN
+ }
+
+ val requestsUnsupportedMethods: Boolean
+ get() = hasUnknownMethods || requestedMethods.any {
+ it == SamRockPaymentMethod.ALL ||
+ it == SamRockPaymentMethod.LIQUID ||
+ it == SamRockPaymentMethod.LIQUID_ONCHAIN ||
+ it == SamRockPaymentMethod.BTC_LIGHTNING
+ }
+}
+
+enum class SamRockPaymentMethod(
+ private val protocolValues: Set,
+) {
+ ALL(setOf("all")),
+ BTC(setOf("btc")),
+ BTC_ONCHAIN(setOf("btc-chain")),
+ LIQUID(setOf("lbtc")),
+ LIQUID_ONCHAIN(setOf("liquid-chain")),
+ BTC_LIGHTNING(setOf("btcln", "btc-ln"));
+
+ companion object {
+ fun fromProtocolValue(value: String): SamRockPaymentMethod? {
+ return entries.firstOrNull { value in it.protocolValues }
+ }
+ }
+}
+
+private data class ParsedSamRockMethods(
+ val methods: Set,
+ val hasUnknownMethods: Boolean,
+)
+
+fun String.sanitizedQrLogValue(): String {
+ return SamRockSetupRequest.sanitizedDescription(this) ?: redactedLogValue()
+}
+
+fun String.sanitizedDeeplinkLogValue(): String {
+ SamRockSetupRequest.sanitizedDescription(this)?.let { return it }
+
+ val uri = runCatching { URI(trim()) }.getOrNull() ?: return redactedLogValue()
+ val scheme = uri.scheme ?: return redactedLogValue()
+ val host = uri.host ?: return scheme
+ val formattedHost = if (":" in host) "[$host]" else host
+ val portSuffix = uri.port.takeIf { it != LOG_NO_PORT }?.let { ":$it" }.orEmpty()
+
+ return "$scheme://$formattedHost$portSuffix${uri.rawPath.orEmpty()}"
+}
+
+private fun String.redactedLogValue(): String {
+ return "redacted#${sha256LogPrefix()}"
+}
+
+private fun String.sha256LogPrefix(): String {
+ val bytes = MessageDigest.getInstance(LOG_SHA_256_ALGORITHM).digest(trim().toByteArray(StandardCharsets.UTF_8))
+ return bytes.take(LOG_HASH_BYTES).joinToString("") { "%02x".format(it) }
+}
+
+private const val LOG_HASH_BYTES = 8
+private const val LOG_NO_PORT = -1
+private const val LOG_SHA_256_ALGORITHM = "SHA-256"
diff --git a/app/src/main/java/to/bitkit/repositories/SamRockRepo.kt b/app/src/main/java/to/bitkit/repositories/SamRockRepo.kt
new file mode 100644
index 000000000..087f7708e
--- /dev/null
+++ b/app/src/main/java/to/bitkit/repositories/SamRockRepo.kt
@@ -0,0 +1,251 @@
+package to.bitkit.repositories
+
+import android.content.Context
+import com.synonym.bitkitcore.AccountType
+import com.synonym.bitkitcore.AddressType
+import dagger.hilt.android.qualifiers.ApplicationContext
+import io.ktor.client.HttpClient
+import io.ktor.client.request.accept
+import io.ktor.client.request.forms.FormDataContent
+import io.ktor.client.request.post
+import io.ktor.client.request.setBody
+import io.ktor.client.statement.bodyAsText
+import io.ktor.http.ContentType
+import io.ktor.http.HttpStatusCode
+import io.ktor.http.Parameters
+import io.ktor.http.contentType
+import io.ktor.http.isSuccess
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.booleanOrNull
+import kotlinx.serialization.json.contentOrNull
+import to.bitkit.R
+import to.bitkit.data.SettingsStore
+import to.bitkit.data.keychain.Keychain
+import to.bitkit.di.IoDispatcher
+import to.bitkit.env.Env
+import to.bitkit.models.DEFAULT_ADDRESS_TYPE
+import to.bitkit.models.SamRockSetupRequest
+import to.bitkit.models.toAddressType
+import to.bitkit.services.CoreService
+import to.bitkit.utils.AppError
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+@Suppress("LongParameterList")
+class SamRockRepo @Inject constructor(
+ @ApplicationContext private val context: Context,
+ @IoDispatcher private val ioDispatcher: CoroutineDispatcher,
+ private val json: Json,
+ private val keychain: Keychain,
+ private val settingsStore: SettingsStore,
+ private val coreService: CoreService,
+ private val samRockHttpClient: SamRockHttpClient,
+) {
+ companion object {
+ private const val BITCOIN_METHOD = "BTC"
+ }
+
+ suspend fun registerBitcoinOnchain(setup: SamRockSetupRequest): Result = withContext(ioDispatcher) {
+ runCatching {
+ if (!setup.requestsBitcoinOnchain) {
+ throw AppError(context.getString(R.string.btcpay__unsupported_text))
+ }
+
+ val descriptor = derivePrimaryAddressDescriptor()
+ val payload = json.encodeToString(
+ SamRockDescriptorPayload(
+ btc = SamRockBitcoinDescriptor(descriptor = descriptor),
+ )
+ )
+
+ val response = runCatching { samRockHttpClient.postDescriptorSetup(setup.postUrl, payload) }
+ .getOrElse {
+ throw SamRockTransportError(
+ message = context.getString(R.string.btcpay__request_error),
+ cause = it,
+ )
+ }
+ val envelope = SamRockResponseParser.decode(response.body)
+
+ if (!response.status.isSuccess()) {
+ throw AppError(envelope?.message ?: response.status.description)
+ }
+
+ if (envelope == null) {
+ throw AppError(context.getString(R.string.btcpay__invalid_response))
+ }
+
+ when (envelope.success) {
+ true -> Unit
+ false -> throw AppError(envelope.message ?: context.getString(R.string.btcpay__setup_failed))
+ null -> throw AppError(context.getString(R.string.btcpay__invalid_response))
+ }
+
+ val btcResult = envelope.result?.results?.get(BITCOIN_METHOD)
+ ?: throw AppError(context.getString(R.string.btcpay__missing_result))
+
+ if (!btcResult.success) {
+ throw AppError(btcResult.message ?: context.getString(R.string.btcpay__rejected_descriptor))
+ }
+ }
+ }
+
+ private suspend fun derivePrimaryAddressDescriptor(): String {
+ val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)
+ ?: throw AppError(context.getString(R.string.btcpay__missing_mnemonic))
+ val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name)?.takeIf { it.isNotEmpty() }
+ val selectedAddressType = settingsStore.data.first().selectedAddressType
+
+ return coreService.onchain.deriveOnchainDescriptor(
+ mnemonicPhrase = mnemonic,
+ network = Env.network,
+ bip39Passphrase = passphrase,
+ accountType = selectedAddressType.toSamRockAccountType(),
+ accountIndex = 0u,
+ )
+ }
+}
+
+private class SamRockTransportError(
+ message: String,
+ cause: Throwable?,
+) : AppError(message, cause)
+
+@Singleton
+class SamRockHttpClient @Inject constructor(
+ private val httpClient: HttpClient,
+) {
+ companion object {
+ private const val CHARSET_PARAMETER = "charset"
+ private const val UTF8_PARAMETER = "utf-8"
+ }
+
+ suspend fun postDescriptorSetup(
+ postUrl: String,
+ payload: String,
+ ): SamRockHttpResponse {
+ val response = httpClient.post(postUrl) {
+ accept(ContentType.Application.Json)
+ contentType(ContentType.Application.FormUrlEncoded.withParameter(CHARSET_PARAMETER, UTF8_PARAMETER))
+ setBody(samRockFormBody(payload))
+ }
+
+ return SamRockHttpResponse(
+ status = response.status,
+ body = response.bodyAsText(),
+ )
+ }
+}
+
+data class SamRockHttpResponse(
+ val status: HttpStatusCode,
+ val body: String,
+)
+
+internal fun samRockFormBody(payload: String): FormDataContent {
+ return FormDataContent(
+ Parameters.build {
+ append(JSON_FORM_FIELD, payload)
+ }
+ )
+}
+
+internal object SamRockResponseParser {
+ private val parser = Json { ignoreUnknownKeys = true }
+
+ fun decode(body: String): SamRockResponseEnvelope? {
+ val root = runCatching { parser.parseToJsonElement(body) as? JsonObject }.getOrNull() ?: return null
+ return SamRockResponseEnvelope(
+ success = root.booleanFor(RESPONSE_SUCCESS_KEYS),
+ message = root.stringFor(RESPONSE_MESSAGE_KEYS),
+ result = root.objectFor(RESPONSE_RESULT_KEYS)?.toSetupResponse(),
+ )
+ }
+
+ private fun JsonObject.toSetupResponse(): SamRockSetupResponse {
+ val resultsObject = objectFor(RESPONSE_RESULTS_KEYS) ?: this
+ return SamRockSetupResponse(
+ results = resultsObject.mapNotNull { (key, value) ->
+ val methodObject = value as? JsonObject ?: return@mapNotNull null
+ val success = methodObject.booleanFor(RESPONSE_SUCCESS_KEYS) ?: return@mapNotNull null
+ key to SamRockMethodResponse(
+ success = success,
+ message = methodObject.stringFor(RESPONSE_MESSAGE_KEYS),
+ )
+ }.toMap()
+ )
+ }
+
+ private fun JsonObject.objectFor(names: List): JsonObject? {
+ return elementFor(names) as? JsonObject
+ }
+
+ private fun JsonObject.booleanFor(names: List): Boolean? {
+ val primitive = elementFor(names) as? JsonPrimitive ?: return null
+ if (primitive.isString) return null
+ return primitive.booleanOrNull
+ }
+
+ private fun JsonObject.stringFor(names: List): String? {
+ return (elementFor(names) as? JsonPrimitive)?.contentOrNull
+ }
+
+ private fun JsonObject.elementFor(names: List): JsonElement? {
+ return names.firstNotNullOfOrNull { this[it] }
+ }
+
+ private val RESPONSE_SUCCESS_KEYS = listOf("Success", "success")
+ private val RESPONSE_MESSAGE_KEYS = listOf("Message", "message")
+ private val RESPONSE_RESULT_KEYS = listOf("Result", "result")
+ private val RESPONSE_RESULTS_KEYS = listOf("Results", "results")
+}
+
+internal data class SamRockResponseEnvelope(
+ val success: Boolean?,
+ val message: String?,
+ val result: SamRockSetupResponse?,
+)
+
+internal data class SamRockSetupResponse(
+ val results: Map,
+)
+
+internal data class SamRockMethodResponse(
+ val success: Boolean,
+ val message: String?,
+)
+
+internal fun String?.toSamRockAccountType(): AccountType {
+ return (this?.toAddressType() ?: DEFAULT_ADDRESS_TYPE).let {
+ when (it) {
+ AddressType.P2PKH -> AccountType.LEGACY
+ AddressType.P2SH -> AccountType.WRAPPED_SEGWIT
+ AddressType.P2TR -> AccountType.TAPROOT
+ else -> AccountType.NATIVE_SEGWIT
+ }
+ }
+}
+
+@Serializable
+private data class SamRockDescriptorPayload(
+ @SerialName("BTC")
+ val btc: SamRockBitcoinDescriptor,
+)
+
+@Serializable
+private data class SamRockBitcoinDescriptor(
+ @SerialName("Descriptor")
+ val descriptor: String,
+)
+
+private const val JSON_FORM_FIELD = "json"
diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt
index a9a8cd23d..083df7346 100644
--- a/app/src/main/java/to/bitkit/services/CoreService.kt
+++ b/app/src/main/java/to/bitkit/services/CoreService.kt
@@ -1,5 +1,6 @@
package to.bitkit.services
+import com.synonym.bitkitcore.AccountType
import com.synonym.bitkitcore.Activity
import com.synonym.bitkitcore.ActivityFilter
import com.synonym.bitkitcore.ActivityTags
@@ -28,6 +29,7 @@ import com.synonym.bitkitcore.addTags
import com.synonym.bitkitcore.createCjitEntry
import com.synonym.bitkitcore.createOrder
import com.synonym.bitkitcore.deleteActivityById
+import com.synonym.bitkitcore.deriveOnchainDescriptor
import com.synonym.bitkitcore.estimateOrderFeeFull
import com.synonym.bitkitcore.getActivities
import com.synonym.bitkitcore.getActivityById
@@ -1825,6 +1827,24 @@ class OnchainService {
)
}
}
+
+ suspend fun deriveOnchainDescriptor(
+ mnemonicPhrase: String,
+ network: Network,
+ bip39Passphrase: String?,
+ accountType: AccountType,
+ accountIndex: UInt,
+ ): String {
+ return ServiceQueue.CORE.background {
+ deriveOnchainDescriptor(
+ mnemonicPhrase = mnemonicPhrase,
+ network = network.toCoreNetwork(),
+ bip39Passphrase = bip39Passphrase,
+ accountType = accountType,
+ accountIndex = accountIndex,
+ )
+ }
+ }
}
// endregion
diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt
index 167ea4f32..e0a0a2efc 100644
--- a/app/src/main/java/to/bitkit/ui/ContentView.kt
+++ b/app/src/main/java/to/bitkit/ui/ContentView.kt
@@ -186,6 +186,7 @@ import to.bitkit.ui.settings.support.ReportIssueScreen
import to.bitkit.ui.settings.support.SupportScreen
import to.bitkit.ui.settings.transactionSpeed.CustomFeeSettingsScreen
import to.bitkit.ui.settings.transactionSpeed.TransactionSpeedSettingsScreen
+import to.bitkit.ui.sheets.BTCPayConnectionSheet
import to.bitkit.ui.sheets.BackgroundPaymentsIntroSheet
import to.bitkit.ui.sheets.BackupRoute
import to.bitkit.ui.sheets.BackupSheet
@@ -442,6 +443,7 @@ fun ContentView(
onDismiss = { appViewModel.hideSheet() },
)
+ is Sheet.BTCPayConnection -> BTCPayConnectionSheet(sheet, appViewModel)
is Sheet.Gift -> GiftSheet(sheet, appViewModel)
Sheet.QrScanner -> QrScanningSheet(appViewModel)
is Sheet.PubkyAuth -> PubkyAuthApprovalSheet(
diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt
index e8d60f020..4511072fd 100644
--- a/app/src/main/java/to/bitkit/ui/MainActivity.kt
+++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt
@@ -34,6 +34,7 @@ import to.bitkit.R
import to.bitkit.androidServices.LightningNodeService
import to.bitkit.androidServices.LightningNodeService.Companion.CHANNEL_ID_NODE
import to.bitkit.models.NewTransactionSheetDetails
+import to.bitkit.models.SamRockSetupRequest
import to.bitkit.ui.components.AuthCheckView
import to.bitkit.ui.components.IsOnlineTracker
import to.bitkit.ui.components.ToastOverlay
@@ -237,10 +238,12 @@ class MainActivity : FragmentActivity() {
}
}
-private fun Intent?.launchKey(): String? {
+internal fun Intent?.launchKey(): String? {
this ?: return null
return when (action) {
- Intent.ACTION_VIEW -> data?.toString()
+ Intent.ACTION_VIEW -> data?.toString()?.let {
+ SamRockSetupRequest.sanitizedLaunchKey(it) ?: it
+ }
else -> null
}
}
diff --git a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt
index 9bfe7edd1..172e8c9c1 100644
--- a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt
+++ b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt
@@ -23,6 +23,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
+import to.bitkit.models.SamRockSetupRequest
import to.bitkit.ui.screens.wallets.receive.ReceiveRoute
import to.bitkit.ui.shared.modifiers.clickableAlpha
import to.bitkit.ui.sheets.BackupRoute
@@ -49,6 +50,7 @@ sealed interface Sheet {
data object ForceTransfer : Sheet
data class Gift(val code: String, val amount: ULong) : Sheet
data object ConnectionClosed : Sheet
+ data class BTCPayConnection(val setup: SamRockSetupRequest) : Sheet
data object QrScanner : Sheet
data class PubkyAuth(val authUrl: String) : Sheet
diff --git a/app/src/main/java/to/bitkit/ui/screens/scanner/QrScanningScreen.kt b/app/src/main/java/to/bitkit/ui/screens/scanner/QrScanningScreen.kt
index 754b9f8ff..f8d09a169 100644
--- a/app/src/main/java/to/bitkit/ui/screens/scanner/QrScanningScreen.kt
+++ b/app/src/main/java/to/bitkit/ui/screens/scanner/QrScanningScreen.kt
@@ -62,6 +62,7 @@ import to.bitkit.env.Env
import to.bitkit.ext.getClipboardText
import to.bitkit.ext.startActivityAppSettings
import to.bitkit.models.Toast
+import to.bitkit.models.sanitizedQrLogValue
import to.bitkit.ui.appViewModel
import to.bitkit.ui.components.PrimaryButton
import to.bitkit.ui.components.SecondaryButton
@@ -123,7 +124,7 @@ fun QrScanningScreen(
QrCodeAnalyzer { result ->
if (result.isSuccess) {
val qrCode = result.getOrThrow()
- Logger.debug("QR code scanned: $qrCode")
+ Logger.debug("Scanned QR code '${qrCode.sanitizedQrLogValue()}'", context = TAG)
setScanResult(qrCode)
} else {
val error = requireNotNull(result.exceptionOrNull())
@@ -359,7 +360,7 @@ private fun processImageFromGallery(
for (barcode in barcodes) {
barcode.rawValue?.let { qrCode ->
onScanSuccess(qrCode)
- Logger.info("QR code found $qrCode")
+ Logger.info("Found QR code '${qrCode.sanitizedQrLogValue()}'", context = TAG)
return@addOnSuccessListener
}
}
diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendRecipientScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendRecipientScreen.kt
index 26bff26df..ff9819bbf 100644
--- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendRecipientScreen.kt
+++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendRecipientScreen.kt
@@ -60,6 +60,7 @@ import kotlinx.coroutines.withContext
import to.bitkit.R
import to.bitkit.ext.startActivityAppSettings
import to.bitkit.models.Toast
+import to.bitkit.models.sanitizedQrLogValue
import to.bitkit.ui.appViewModel
import to.bitkit.ui.components.BodyM
import to.bitkit.ui.components.BodyMSB
@@ -136,7 +137,7 @@ fun SendRecipientScreen(
QrCodeAnalyzer { result ->
if (result.isSuccess) {
val qrCode = result.getOrThrow()
- Logger.debug("QR scanned: '$qrCode'", context = TAG)
+ Logger.debug("Scanned QR code '${qrCode.sanitizedQrLogValue()}'", context = TAG)
onEvent(SendEvent.AddressContinue(qrCode))
} else {
val error = requireNotNull(result.exceptionOrNull())
@@ -205,7 +206,7 @@ fun SendRecipientScreen(
// Gallery picker launchers
val handleGalleryScanSuccess = { qrCode: String ->
- Logger.debug("QR from gallery: $qrCode", context = TAG)
+ Logger.debug("Found gallery QR code '${qrCode.sanitizedQrLogValue()}'", context = TAG)
onEvent(SendEvent.AddressContinue(qrCode))
}
@@ -469,7 +470,7 @@ private fun processImageFromGallery(
for (barcode in barcodes) {
barcode.rawValue?.let { qrCode ->
onScanSuccess(qrCode)
- Logger.info("QR from gallery: $qrCode", context = TAG)
+ Logger.info("Found gallery QR code '${qrCode.sanitizedQrLogValue()}'", context = TAG)
return@addOnSuccessListener
}
}
diff --git a/app/src/main/java/to/bitkit/ui/sheets/BTCPayConnectionSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/BTCPayConnectionSheet.kt
new file mode 100644
index 000000000..c6cf89064
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ui/sheets/BTCPayConnectionSheet.kt
@@ -0,0 +1,260 @@
+package to.bitkit.ui.sheets
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.launch
+import to.bitkit.R
+import to.bitkit.models.SamRockPaymentMethod
+import to.bitkit.models.SamRockSetupRequest
+import to.bitkit.ui.components.BodyM
+import to.bitkit.ui.components.BodyMSB
+import to.bitkit.ui.components.BodyS
+import to.bitkit.ui.components.BottomSheetPreview
+import to.bitkit.ui.components.Caption
+import to.bitkit.ui.components.HorizontalSpacer
+import to.bitkit.ui.components.PrimaryButton
+import to.bitkit.ui.components.SecondaryButton
+import to.bitkit.ui.components.Sheet
+import to.bitkit.ui.components.SheetSize
+import to.bitkit.ui.components.Title
+import to.bitkit.ui.components.VerticalSpacer
+import to.bitkit.ui.scaffold.SheetTopBar
+import to.bitkit.ui.shared.modifiers.sheetHeight
+import to.bitkit.ui.shared.util.gradientBackground
+import to.bitkit.ui.theme.AppThemeSurface
+import to.bitkit.ui.theme.Colors
+import to.bitkit.viewmodels.AppViewModel
+
+@Composable
+fun BTCPayConnectionSheet(
+ sheet: Sheet.BTCPayConnection,
+ app: AppViewModel,
+) {
+ Content(
+ setup = sheet.setup,
+ onCancel = { app.hideSheet() },
+ onConnect = { app.connectBTCPay(sheet.setup) },
+ )
+}
+
+@Composable
+private fun Content(
+ setup: SamRockSetupRequest,
+ modifier: Modifier = Modifier,
+ onCancel: () -> Unit = {},
+ onConnect: suspend () -> Result = { Result.success(Unit) },
+) {
+ val scope = rememberCoroutineScope()
+ val fallbackError = stringResource(R.string.btcpay__request_error)
+ var isConnecting by remember { mutableStateOf(false) }
+ var errorText by remember { mutableStateOf(null) }
+
+ Column(
+ modifier = modifier
+ .sheetHeight(SheetSize.MEDIUM)
+ .gradientBackground()
+ .navigationBarsPadding()
+ .padding(horizontal = 16.dp)
+ .testTag("BTCPayConnectionSheet")
+ ) {
+ SheetTopBar(titleText = stringResource(R.string.btcpay__sheet_title))
+ VerticalSpacer(16.dp)
+
+ Column(
+ modifier = Modifier.weight(1f)
+ ) {
+ StoreHeader(setup = setup)
+ VerticalSpacer(24.dp)
+
+ BodyM(
+ text = stringResource(R.string.btcpay__sheet_description),
+ color = Colors.White64,
+ )
+ VerticalSpacer(20.dp)
+
+ Column(
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ ConnectionRow(
+ iconRes = R.drawable.ic_btc_circle,
+ title = stringResource(R.string.btcpay__onchain_label),
+ subtitle = stringResource(R.string.btcpay__descriptor_label),
+ )
+
+ if (setup.requestsUnsupportedMethods) {
+ ConnectionRow(
+ iconRes = R.drawable.ic_warning,
+ title = stringResource(R.string.btcpay__limited_support_label),
+ subtitle = stringResource(R.string.btcpay__unsupported_note),
+ )
+ }
+ }
+
+ errorText?.let {
+ VerticalSpacer(16.dp)
+ BodyS(
+ text = it,
+ color = Colors.Red,
+ modifier = Modifier.testTag("BTCPayConnectionError")
+ )
+ }
+ }
+
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ SecondaryButton(
+ text = stringResource(R.string.common__cancel),
+ onClick = onCancel,
+ fullWidth = false,
+ enabled = !isConnecting,
+ modifier = Modifier
+ .weight(1f)
+ .testTag("BTCPayConnectionCancel")
+ )
+ PrimaryButton(
+ text = stringResource(R.string.common__connect),
+ onClick = {
+ scope.launch {
+ isConnecting = true
+ errorText = null
+ onConnect()
+ .onFailure { errorText = it.message ?: fallbackError }
+ isConnecting = false
+ }
+ },
+ isLoading = isConnecting,
+ fullWidth = false,
+ modifier = Modifier
+ .weight(1f)
+ .testTag("BTCPayConnectionConnect")
+ )
+ }
+ VerticalSpacer(16.dp)
+ }
+}
+
+@Composable
+private fun StoreHeader(
+ setup: SamRockSetupRequest,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = modifier.fillMaxWidth()
+ ) {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .size(84.dp)
+ .clip(CircleShape)
+ .background(Colors.White08)
+ ) {
+ Image(
+ painter = painterResource(R.drawable.ic_store_front),
+ contentDescription = null,
+ modifier = Modifier.size(56.dp)
+ )
+ }
+ HorizontalSpacer(16.dp)
+ Column(
+ modifier = Modifier.weight(1f)
+ ) {
+ Caption(
+ text = stringResource(R.string.btcpay__store_label),
+ color = Colors.White64,
+ )
+ VerticalSpacer(6.dp)
+ Title(
+ text = setup.hostDisplayName,
+ color = Colors.White,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+}
+
+@Composable
+private fun ConnectionRow(
+ iconRes: Int,
+ title: String,
+ subtitle: String,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ verticalAlignment = Alignment.Top,
+ modifier = modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(8.dp))
+ .background(Colors.White08)
+ .padding(16.dp)
+ ) {
+ Image(
+ painter = painterResource(iconRes),
+ contentDescription = null,
+ modifier = Modifier.size(24.dp)
+ )
+ HorizontalSpacer(12.dp)
+ Column(
+ modifier = Modifier.weight(1f)
+ ) {
+ BodyMSB(
+ text = title,
+ color = Colors.White,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ VerticalSpacer(4.dp)
+ BodyS(
+ text = subtitle,
+ color = Colors.White64,
+ )
+ }
+ }
+}
+
+@Preview(showSystemUi = true)
+@Composable
+private fun Preview() {
+ AppThemeSurface {
+ BottomSheetPreview {
+ Content(
+ setup = SamRockSetupRequest(
+ postUrl = "https://example.com/plugins/store/samrock/protocol?setup=btc-chain&otp=abc",
+ storeId = "store",
+ otp = "abc",
+ requestedMethods = setOf(SamRockPaymentMethod.BTC_ONCHAIN, SamRockPaymentMethod.BTC_LIGHTNING),
+ hasUnknownMethods = false,
+ hostDisplayName = "example.com",
+ logDescription = "https://example.com/plugins/store/samrock/protocol",
+ )
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
index 83ac9750a..553e0e948 100644
--- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
+++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
@@ -105,12 +105,14 @@ import to.bitkit.models.PubkyProfile
import to.bitkit.models.PubkyPublicKeyFormat
import to.bitkit.models.PubkyRingAuthCallback
import to.bitkit.models.PubkyRingAuthCallbackHandlingResult
+import to.bitkit.models.SamRockSetupRequest
import to.bitkit.models.Suggestion
import to.bitkit.models.Toast
import to.bitkit.models.TransactionSpeed
import to.bitkit.models.TransferType
import to.bitkit.models.msatFloorOf
import to.bitkit.models.safe
+import to.bitkit.models.sanitizedDeeplinkLogValue
import to.bitkit.models.toActivityFilter
import to.bitkit.models.toLdkNetwork
import to.bitkit.models.toTxType
@@ -130,6 +132,7 @@ import to.bitkit.repositories.PreActivityMetadataRepo
import to.bitkit.repositories.PrivatePaykitRepo
import to.bitkit.repositories.PubkyRepo
import to.bitkit.repositories.PublicPaykitRepo
+import to.bitkit.repositories.SamRockRepo
import to.bitkit.repositories.TransferRepo
import to.bitkit.repositories.WalletRepo
import to.bitkit.repositories.WidgetsRepo
@@ -193,6 +196,7 @@ class AppViewModel @Inject constructor(
private val pubkyRepo: PubkyRepo,
private val publicPaykitRepo: PublicPaykitRepo,
private val privatePaykitRepo: PrivatePaykitRepo,
+ private val samRockRepo: SamRockRepo,
private val appUpdateSheet: AppUpdateTimedSheet,
private val backupSheet: BackupTimedSheet,
private val notificationsSheet: NotificationsTimedSheet,
@@ -321,6 +325,7 @@ class AppViewModel @Inject constructor(
val currentSheet = _currentSheet.value
val isHighPrioritySheetShowing = currentSheet is Sheet.Gift ||
currentSheet is Sheet.Send ||
+ currentSheet is Sheet.BTCPayConnection ||
currentSheet is Sheet.LnurlAuth ||
currentSheet is Sheet.Pin ||
currentSheet is Sheet.PubkyAuth
@@ -1318,7 +1323,12 @@ class AppViewModel @Inject constructor(
routePubkyKeys: Boolean = false,
) {
val normalized = data.removeLightningSchemes()
- val scanId = if (data.length > 24) "${data.take(11)}…${data.takeLast(11)}" else data
+ val scanLogInput = SamRockSetupRequest.sanitizedDescription(normalized) ?: data
+ val scanId = if (scanLogInput.length > 24) {
+ "${scanLogInput.take(11)}…${scanLogInput.takeLast(11)}"
+ } else {
+ scanLogInput
+ }
if (normalized == activeScanInput && activeScanJob?.isActive == true) {
Logger.info("Skipping duplicate scan from '${source.label}': '$scanId'", context = TAG)
@@ -1611,6 +1621,16 @@ class AppViewModel @Inject constructor(
return@withContext
}
+ SamRockSetupRequest.parse(input)?.let {
+ handleSamRockSetup(it)
+ return@withContext
+ }
+
+ if (SamRockSetupRequest.isProtocolUrl(input)) {
+ handleInvalidSamRockSetup(input)
+ return@withContext
+ }
+
if (input.startsWith("$PUBKYAUTH_SCHEME://")) {
clearActiveContactPaymentContext()
if (isPaykitEnabled.value) {
@@ -1642,8 +1662,9 @@ class AppViewModel @Inject constructor(
}
}
+ val safeLogInput = SamRockSetupRequest.sanitizedDescription(input) ?: input
val scan = runCatching { coreService.decode(input) }
- .onFailure { Logger.error("Failed to decode scan data: '$input'", it, context = TAG) }
+ .onFailure { Logger.error("Failed to decode scan data: '$safeLogInput'", it, context = TAG) }
.onSuccess { Logger.info("Handling decoded scan data: $it", context = TAG) }
.getOrNull()
@@ -1679,6 +1700,38 @@ class AppViewModel @Inject constructor(
}
}
+ private suspend fun handleSamRockSetup(setup: SamRockSetupRequest) {
+ clearActiveContactPaymentContext()
+
+ if (!setup.requestsBitcoinOnchain) {
+ hideSheet()
+ toast(
+ type = Toast.ToastType.WARNING,
+ title = context.getString(R.string.btcpay__unsupported_title),
+ description = context.getString(R.string.btcpay__unsupported_text),
+ testTag = "BTCPayUnsupportedToast",
+ )
+ return
+ }
+
+ showSheet(Sheet.BTCPayConnection(setup))
+ }
+
+ private suspend fun handleInvalidSamRockSetup(input: String) {
+ clearActiveContactPaymentContext()
+ hideSheet()
+ val descriptionRes = when {
+ SamRockSetupRequest.isPublicHttpProtocolUrl(input) -> R.string.btcpay__unsupported_http_text
+ else -> R.string.btcpay__invalid_link_text
+ }
+ toast(
+ type = Toast.ToastType.WARNING,
+ title = context.getString(R.string.btcpay__unsupported_title),
+ description = context.getString(descriptionRes),
+ testTag = "BTCPayInvalidSetupToast",
+ )
+ }
+
private suspend fun handleNonPaymentScan(action: suspend () -> Unit) {
clearActiveContactPaymentContext()
action()
@@ -2662,9 +2715,10 @@ class AppViewModel @Inject constructor(
fun onScannerSheetResult(data: String) {
val handler = scanResultHandler
+ val shouldHandleAsProtocol = SamRockSetupRequest.isProtocolUrl(data.removeLightningSchemes())
scanResultHandler = null
hideSheet()
- if (handler != null) {
+ if (handler != null && !shouldHandleAsProtocol) {
viewModelScope.launch {
delay(SCREEN_TRANSITION_DELAY)
handler(data)
@@ -2684,6 +2738,30 @@ class AppViewModel @Inject constructor(
hideSheet()
}
+ suspend fun connectBTCPay(setup: SamRockSetupRequest): Result {
+ val result = samRockRepo.registerBitcoinOnchain(setup)
+ result
+ .onSuccess {
+ hideSheet()
+ toast(
+ type = Toast.ToastType.SUCCESS,
+ title = context.getString(R.string.btcpay__success_title),
+ description = context.getString(R.string.btcpay__success_description),
+ testTag = "BTCPayConnectedToast",
+ )
+ }
+ .onFailure {
+ toast(
+ type = Toast.ToastType.ERROR,
+ title = context.getString(R.string.btcpay__error_title),
+ description = it.message ?: context.getString(R.string.btcpay__request_error),
+ testTag = "BTCPayConnectionErrorToast",
+ )
+ }
+
+ return result
+ }
+
fun showSheet(sheetType: Sheet) {
viewModelScope.launch {
_currentSheet.value?.let {
@@ -2832,7 +2910,7 @@ class AppViewModel @Inject constructor(
// endregion
suspend fun canDecodeClipboard(text: String): Boolean = withContext(bgDispatcher) {
- runCatching { coreService.decode(text) }.isSuccess
+ SamRockSetupRequest.isProtocolUrl(text) || runCatching { coreService.decode(text) }.isSuccess
}
fun onClipboardAutoRead(data: String) {
@@ -2926,7 +3004,7 @@ class AppViewModel @Inject constructor(
fun handleDeeplinkIntent(intent: Intent) {
if (intent.action != Intent.ACTION_VIEW) return
intent.data?.let { uri ->
- Logger.debug("Received deeplink: $uri")
+ Logger.debug("Received deeplink '${uri.toString().sanitizedDeeplinkLogValue()}'", context = TAG)
processDeeplink(uri)
}
}
@@ -2938,7 +3016,15 @@ class AppViewModel @Inject constructor(
}
private fun processDeeplink(uri: Uri) = viewModelScope.launch {
- if (uri.toString().contains("recovery-mode")) {
+ val value = uri.toString()
+ if (SamRockSetupRequest.isProtocolUrl(value)) {
+ if (!walletRepo.walletExists()) return@launch
+
+ launchScan(source = ScanSource.DEEPLINK, data = value, startDelay = SCREEN_TRANSITION_DELAY)
+ return@launch
+ }
+
+ if (uri.isRecoveryModeDeeplink()) {
lightningRepo.setRecoveryMode(enabled = true)
delay(SCREEN_TRANSITION_DELAY)
mainScreenEffect(
@@ -2964,7 +3050,15 @@ class AppViewModel @Inject constructor(
if (!walletRepo.walletExists()) return@launch
- launchScan(source = ScanSource.DEEPLINK, data = uri.toString(), startDelay = SCREEN_TRANSITION_DELAY)
+ launchScan(source = ScanSource.DEEPLINK, data = value, startDelay = SCREEN_TRANSITION_DELAY)
+ }
+
+ private fun Uri.isRecoveryModeDeeplink(): Boolean {
+ val normalizedScheme = scheme?.lowercase()
+ if (normalizedScheme != BITKIT_SCHEME) return false
+
+ return host == RECOVERY_MODE_DEEPLINK ||
+ pathSegments.singleOrNull() == RECOVERY_MODE_DEEPLINK
}
private suspend fun handlePubkyAuth(authUrl: String) {
@@ -3055,7 +3149,9 @@ class AppViewModel @Inject constructor(
private const val PAYKIT_CHANNEL_USABILITY_REFRESH_DELAY_MS = 5_000L
private val PUBLIC_PAYKIT_SYNC_DEBOUNCE = 1.seconds
private val PUBLIC_PAYKIT_BOLT11_REFRESH_WINDOW = 30.minutes
+ private const val BITKIT_SCHEME = "bitkit"
private const val PUBKYAUTH_SCHEME = "pubkyauth"
+ private const val RECOVERY_MODE_DEEPLINK = "recovery-mode"
private val LNURL_WITHDRAW_EXPIRY_SEC = 1.hours.inWholeSeconds.toUInt()
}
}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index d08b19881..569d62ef2 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -8,6 +8,26 @@
Timeframe
Week
Year
+ Primary address type descriptor
+ BTCPay setup failed
+ This BTCPay setup link is invalid.
+ Invalid setup response.
+ Limited support
+ Bitkit could not read your recovery phrase.
+ The store did not return a Bitcoin setup result.
+ Bitcoin on-chain
+ The store rejected the Bitcoin descriptor.
+ Could not prepare the setup request.
+ Setup failed.
+ Bitkit will share a Bitcoin on-chain receive descriptor so this BTCPay store can generate addresses for your wallet.
+ Connect BTCPay
+ BTCPay store
+ Bitcoin on-chain receiving is ready for this store.
+ BTCPay connected
+ BTCPay setup links must use HTTPS unless they point to a local or private server.
+ Only Bitcoin on-chain will be connected. Other methods from this QR are not supported in Bitkit yet.
+ This QR does not request Bitcoin on-chain setup.
+ Setup not supported
Store your bitcoin
Back up
Buy some bitcoin
diff --git a/app/src/test/java/to/bitkit/models/SamRockSetupRequestTest.kt b/app/src/test/java/to/bitkit/models/SamRockSetupRequestTest.kt
new file mode 100644
index 000000000..9680e0bcc
--- /dev/null
+++ b/app/src/test/java/to/bitkit/models/SamRockSetupRequestTest.kt
@@ -0,0 +1,323 @@
+package to.bitkit.models
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class SamRockSetupRequestTest {
+ @Test
+ fun `parse accepts modern setup URL`() {
+ val setup = assertNotNull(
+ SamRockSetupRequest.parse(
+ "https://btcpay.example.com/plugins/store-1/samrock/protocol?setup=btc-chain,btc-ln&otp=secret"
+ )
+ )
+
+ assertEquals("store-1", setup.storeId)
+ assertEquals("secret", setup.otp)
+ assertEquals("btcpay.example.com", setup.hostDisplayName)
+ assertEquals(
+ "https://btcpay.example.com/plugins/store-1/samrock/protocol?setup=btc-chain%2Cbtc-ln&otp=secret",
+ setup.postUrl,
+ )
+ assertTrue(setup.requestsBitcoinOnchain)
+ assertTrue(setup.requestsUnsupportedMethods)
+ }
+
+ @Test
+ fun `parse accepts setup URL under base path`() {
+ val setup = assertNotNull(
+ SamRockSetupRequest.parse(
+ "https://btcpay.example.com/btcpay/plugins/store-1/samrock/protocol?setup=btc-chain&otp=secret"
+ )
+ )
+
+ assertEquals("store-1", setup.storeId)
+ assertEquals("btcpay.example.com", setup.hostDisplayName)
+ assertEquals(
+ "https://btcpay.example.com/btcpay/plugins/store-1/samrock/protocol?setup=btc-chain&otp=secret",
+ setup.postUrl,
+ )
+ assertEquals(
+ "https://btcpay.example.com/btcpay/plugins/store-1/samrock/protocol",
+ SamRockSetupRequest.sanitizedDescription(
+ "https://btcpay.example.com/btcpay/plugins/store-1/samrock/protocol?setup=btc-chain&otp=secret"
+ ),
+ )
+ }
+
+ @Test
+ fun `parse defaults missing setup to all`() {
+ val setup = assertNotNull(
+ SamRockSetupRequest.parse(
+ "https://btcpay.example.com/plugins/store/samrock/protocol?otp=secret&ignored=true"
+ )
+ )
+
+ assertEquals(setOf(SamRockPaymentMethod.ALL), setup.requestedMethods)
+ assertEquals(
+ "https://btcpay.example.com/plugins/store/samrock/protocol?otp=secret",
+ setup.postUrl,
+ )
+ assertTrue(setup.requestsBitcoinOnchain)
+ assertTrue(setup.requestsUnsupportedMethods)
+ }
+
+ @Test
+ fun `parse does not default unknown setup methods`() {
+ val setup = assertNotNull(
+ SamRockSetupRequest.parse(
+ "https://btcpay.example.com/plugins/store/samrock/protocol?setup=custom&otp=secret"
+ )
+ )
+
+ assertTrue(setup.requestedMethods.isEmpty())
+ assertTrue(setup.hasUnknownMethods)
+ assertFalse(setup.requestsBitcoinOnchain)
+ assertTrue(setup.requestsUnsupportedMethods)
+ }
+
+ @Test
+ fun `parse flags unknown methods alongside bitcoin onchain`() {
+ val setup = assertNotNull(
+ SamRockSetupRequest.parse(
+ "https://btcpay.example.com/plugins/store/samrock/protocol?setup=btc-chain,custom&otp=secret"
+ )
+ )
+
+ assertEquals(setOf(SamRockPaymentMethod.BTC_ONCHAIN), setup.requestedMethods)
+ assertTrue(setup.hasUnknownMethods)
+ assertTrue(setup.requestsBitcoinOnchain)
+ assertTrue(setup.requestsUnsupportedMethods)
+ }
+
+ @Test
+ fun `parse recognizes lightning only as unsupported for this flow`() {
+ val setup = assertNotNull(
+ SamRockSetupRequest.parse(
+ "https://btcpay.example.com/plugins/store/samrock/protocol?setup=btc-ln&otp=secret"
+ )
+ )
+
+ assertEquals(setOf(SamRockPaymentMethod.BTC_LIGHTNING), setup.requestedMethods)
+ assertFalse(setup.requestsBitcoinOnchain)
+ assertTrue(setup.requestsUnsupportedMethods)
+ }
+
+ @Test
+ fun `parse recognizes every setup method value`() {
+ val cases = listOf(
+ "all" to SamRockPaymentMethod.ALL,
+ "btc" to SamRockPaymentMethod.BTC,
+ "btc-chain" to SamRockPaymentMethod.BTC_ONCHAIN,
+ "lbtc" to SamRockPaymentMethod.LIQUID,
+ "liquid-chain" to SamRockPaymentMethod.LIQUID_ONCHAIN,
+ "btcln" to SamRockPaymentMethod.BTC_LIGHTNING,
+ "btc-ln" to SamRockPaymentMethod.BTC_LIGHTNING,
+ )
+
+ cases.forEach { (setupValue, method) ->
+ val setup = assertNotNull(
+ SamRockSetupRequest.parse(
+ "https://btcpay.example.com/plugins/store/samrock/protocol?setup=$setupValue&otp=secret"
+ ),
+ setupValue,
+ )
+
+ assertEquals(setOf(method), setup.requestedMethods, setupValue)
+ }
+ }
+
+ @Test
+ fun `parse preserves encoded query values in post URL`() {
+ val setup = assertNotNull(
+ SamRockSetupRequest.parse(
+ "https://btcpay.example.com/plugins/store/samrock/protocol?setup=btc-chain&otp=a%2Bb%20c%26d%3De"
+ )
+ )
+
+ assertEquals("a+b c&d=e", setup.otp)
+ assertEquals(
+ "https://btcpay.example.com/plugins/store/samrock/protocol?setup=btc-chain&otp=a%2Bb%20c%26d%3De",
+ setup.postUrl,
+ )
+ }
+
+ @Test
+ fun `parse rejects public http`() {
+ assertNull(
+ SamRockSetupRequest.parse(
+ "http://btcpay.example.com/plugins/store/samrock/protocol?setup=btc-chain&otp=secret"
+ )
+ )
+ assertTrue(
+ SamRockSetupRequest.isPublicHttpProtocolUrl(
+ "http://btcpay.example.com/plugins/store/samrock/protocol?setup=btc-chain&otp=secret"
+ )
+ )
+ }
+
+ @Test
+ fun `parse allows local and private http`() {
+ val urls = listOf(
+ "http://localhost/plugins/store/samrock/protocol?setup=btc-chain&otp=secret",
+ "http://127.0.0.1/plugins/store/samrock/protocol?setup=btc-chain&otp=secret",
+ "http://192.168.1.20/plugins/store/samrock/protocol?setup=btc-chain&otp=secret",
+ "http://172.16.1.20/plugins/store/samrock/protocol?setup=btc-chain&otp=secret",
+ "http://10.0.2.2/plugins/store/samrock/protocol?setup=btc-chain&otp=secret",
+ "http://[::1]/plugins/store/samrock/protocol?setup=btc-chain&otp=secret",
+ "http://[fc00::1]/plugins/store/samrock/protocol?setup=btc-chain&otp=secret",
+ "http://[fd00::1]/plugins/store/samrock/protocol?setup=btc-chain&otp=secret",
+ "http://[fe80::1]/plugins/store/samrock/protocol?setup=btc-chain&otp=secret",
+ "http://merchant.local/plugins/store/samrock/protocol?setup=btc-chain&otp=secret",
+ )
+
+ urls.forEach {
+ assertNotNull(SamRockSetupRequest.parse(it), it)
+ }
+ }
+
+ @Test
+ fun `parse rejects public and invalid http lookalikes`() {
+ val urls = listOf(
+ "http://fc.example.com/plugins/store/samrock/protocol?setup=btc-chain&otp=secret",
+ "http://fd.example.com/plugins/store/samrock/protocol?setup=btc-chain&otp=secret",
+ "http://172.15.1.20/plugins/store/samrock/protocol?setup=btc-chain&otp=secret",
+ "http://172.32.1.20/plugins/store/samrock/protocol?setup=btc-chain&otp=secret",
+ "http://192.167.1.20/plugins/store/samrock/protocol?setup=btc-chain&otp=secret",
+ "http://8.8.8.8/plugins/store/samrock/protocol?setup=btc-chain&otp=secret",
+ "http://10.0.0.999/plugins/store/samrock/protocol?setup=btc-chain&otp=secret",
+ "http://[2001:db8::1]/plugins/store/samrock/protocol?setup=btc-chain&otp=secret",
+ )
+
+ urls.forEach {
+ assertNull(SamRockSetupRequest.parse(it), it)
+ }
+ }
+
+ @Test
+ fun `parse rejects non samrock URLs`() {
+ assertNull(SamRockSetupRequest.parse("https://btcpay.example.com/plugins/store/other/protocol?otp=secret"))
+ assertNull(SamRockSetupRequest.parse("https://btcpay.example.com/plugins/store/samrock?otp=secret"))
+ assertNull(SamRockSetupRequest.parse("https://btcpay.example.com/plugins/store/samrock/protocol"))
+ assertNull(SamRockSetupRequest.parse("https://btcpay.example.com/plugins/store/samrock/protocol?otp="))
+ assertNull(
+ SamRockSetupRequest.parse(
+ "https://btcpay.example.com/plugins/store/samrock/protocol/extra?otp=secret"
+ )
+ )
+ assertNull(
+ SamRockSetupRequest.parse(
+ "https://user:pass@btcpay.example.com/plugins/store/samrock/protocol?otp=secret"
+ )
+ )
+ }
+
+ @Test
+ fun `parse rejects malformed percent escapes`() {
+ assertNull(SamRockSetupRequest.parse("https://btcpay.example.com/plugins/%zz/samrock/protocol?otp=secret"))
+ assertNull(SamRockSetupRequest.parse("https://btcpay.example.com/plugins/store/samrock/protocol?otp=%zz"))
+ }
+
+ @Test
+ fun `sanitized description strips query and fragment`() {
+ assertEquals(
+ "https://btcpay.example.com/plugins/store/samrock/protocol",
+ SamRockSetupRequest.sanitizedDescription(
+ "https://btcpay.example.com/plugins/store/samrock/protocol?setup=btc-chain&otp=secret#frag"
+ ),
+ )
+ }
+
+ @Test
+ fun `sanitized QR log value redacts SamRock query values`() {
+ val result = "https://btcpay.example.com/plugins/store/samrock/protocol?setup=btc-chain&otp=secret"
+ .sanitizedQrLogValue()
+
+ assertEquals("https://btcpay.example.com/plugins/store/samrock/protocol", result)
+ assertFalse(result.contains("otp"))
+ assertFalse(result.contains("secret"))
+ }
+
+ @Test
+ fun `sanitized QR log value redacts malformed SamRock-looking query values`() {
+ val result = "https://btcpay.example.com/plugins/%zz/samrock/protocol?setup=btc-chain&otp=secret"
+ .sanitizedQrLogValue()
+
+ assertEquals("https://btcpay.example.com/plugins/%zz/samrock/protocol", result)
+ assertFalse(result.contains("otp"))
+ assertFalse(result.contains("secret"))
+ }
+
+ @Test
+ fun `sanitized QR log value redacts ordinary QR values`() {
+ val result = "bitcoin:bcrt1qexample?amount=1".sanitizedQrLogValue()
+
+ assertTrue(result.startsWith("redacted#"))
+ assertFalse(result.contains("bcrt1qexample"))
+ assertFalse(result.contains("amount"))
+ }
+
+ @Test
+ fun `sanitized deeplink log value strips sensitive URL parts`() {
+ assertEquals(
+ "https://btcpay.example.com/plugins/store/samrock/protocol",
+ "https://user:pass@btcpay.example.com/plugins/store/samrock/protocol?setup=btc-chain&otp=secret"
+ .sanitizedDeeplinkLogValue(),
+ )
+ assertEquals(
+ "https://example.com/path",
+ "https://user:pass@example.com/path?token=secret#fragment".sanitizedDeeplinkLogValue(),
+ )
+ assertEquals("bitcoin", "bitcoin:bcrt1qexample?amount=1".sanitizedDeeplinkLogValue())
+ assertEquals(
+ "bitkit://pubky-auth/success",
+ "bitkit://pubky-auth/success?nonce=secret".sanitizedDeeplinkLogValue(),
+ )
+ }
+
+ @Test
+ fun `sanitized launch key keeps setup links distinct without exposing query`() {
+ val first = assertNotNull(
+ SamRockSetupRequest.sanitizedLaunchKey(
+ "https://btcpay.example.com/plugins/store/samrock/protocol?setup=btc-chain&otp=secret"
+ )
+ )
+ val second = assertNotNull(
+ SamRockSetupRequest.sanitizedLaunchKey(
+ "https://btcpay.example.com/plugins/store/samrock/protocol?setup=btc-chain&otp=other"
+ )
+ )
+
+ assertTrue(first.startsWith("https://btcpay.example.com/plugins/store/samrock/protocol#"))
+ assertFalse(first.contains("otp"))
+ assertFalse(first.contains("secret"))
+ assertFalse(first.contains("setup"))
+ assertFalse(first == second)
+ }
+
+ @Test
+ fun `sanitized description drops userinfo and malformed paths`() {
+ assertEquals(
+ "https://btcpay.example.com/plugins/store/samrock/protocol",
+ SamRockSetupRequest.sanitizedDescription(
+ "https://user:pass@btcpay.example.com/plugins/store/samrock/protocol?otp=secret"
+ ),
+ )
+ assertEquals(
+ "https://btcpay.example.com/plugins/%zz/samrock/protocol",
+ SamRockSetupRequest.sanitizedDescription(
+ "https://btcpay.example.com/plugins/%zz/samrock/protocol?otp=secret"
+ ),
+ )
+ assertEquals(
+ "https://btcpay.example.com/plugins/store/samrock/protocol",
+ SamRockSetupRequest.sanitizedDescription(
+ "https://btcpay.example.com/plugins/store/samrock/protocol?otp=%zz"
+ ),
+ )
+ }
+}
diff --git a/app/src/test/java/to/bitkit/repositories/SamRockRepoTest.kt b/app/src/test/java/to/bitkit/repositories/SamRockRepoTest.kt
new file mode 100644
index 000000000..34f61cb6a
--- /dev/null
+++ b/app/src/test/java/to/bitkit/repositories/SamRockRepoTest.kt
@@ -0,0 +1,452 @@
+package to.bitkit.repositories
+
+import android.content.Context
+import com.synonym.bitkitcore.AccountType
+import io.ktor.http.HttpStatusCode
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.serialization.json.Json
+import org.junit.Before
+import org.mockito.kotlin.any
+import org.mockito.kotlin.argumentCaptor
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.never
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+import to.bitkit.R
+import to.bitkit.data.SettingsData
+import to.bitkit.data.SettingsStore
+import to.bitkit.data.keychain.Keychain
+import to.bitkit.env.Env
+import to.bitkit.models.SamRockPaymentMethod
+import to.bitkit.models.SamRockSetupRequest
+import to.bitkit.services.CoreService
+import to.bitkit.services.OnchainService
+import to.bitkit.test.BaseUnitTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class SamRockRepoTest : BaseUnitTest() {
+ companion object {
+ private const val DESCRIPTOR = "wpkh([f23f9fd2/86'/1'/0']tpub.../0/*)"
+ private const val INVALID_RESPONSE = "Invalid setup response."
+ private const val MISSING_MNEMONIC = "Bitkit could not read your recovery phrase."
+ private const val MISSING_RESULT = "The store did not return a Bitcoin setup result."
+ private const val MNEMONIC =
+ "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
+ private const val PASSPHRASE = "wallet passphrase"
+ private const val POST_URL =
+ "https://btcpay.example.com/plugins/store/samrock/protocol?setup=btc-chain&otp=secret"
+ private const val REJECTED_DESCRIPTOR = "The store rejected the Bitcoin descriptor."
+ private const val REQUEST_ERROR = "Could not prepare the setup request."
+ private const val SETUP_FAILED = "Setup failed."
+ private const val SUCCESS_RESPONSE = """{"Success":true,"Result":{"Results":{"BTC":{"Success":true}}}}"""
+ private const val UNSUPPORTED_SETUP = "This QR does not request Bitcoin on-chain setup."
+ }
+
+ private val context = mock()
+ private val keychain = mock()
+ private val settingsStore = mock()
+ private val coreService = mock()
+ private val onchainService = mock()
+ private val samRockHttpClient = mock()
+ private lateinit var sut: SamRockRepo
+
+ @Before
+ fun setUp() {
+ whenever(context.getString(R.string.btcpay__invalid_response)).thenReturn(INVALID_RESPONSE)
+ whenever(context.getString(R.string.btcpay__missing_mnemonic)).thenReturn(MISSING_MNEMONIC)
+ whenever(context.getString(R.string.btcpay__missing_result)).thenReturn(MISSING_RESULT)
+ whenever(context.getString(R.string.btcpay__rejected_descriptor)).thenReturn(REJECTED_DESCRIPTOR)
+ whenever(context.getString(R.string.btcpay__request_error)).thenReturn(REQUEST_ERROR)
+ whenever(context.getString(R.string.btcpay__setup_failed)).thenReturn(SETUP_FAILED)
+ whenever(context.getString(R.string.btcpay__unsupported_text)).thenReturn(UNSUPPORTED_SETUP)
+ whenever(coreService.onchain).thenReturn(onchainService)
+ whenever(settingsStore.data).thenReturn(flowOf(SettingsData(selectedAddressType = "taproot")))
+ sut = SamRockRepo(
+ context = context,
+ ioDispatcher = testDispatcher,
+ json = Json,
+ keychain = keychain,
+ settingsStore = settingsStore,
+ coreService = coreService,
+ samRockHttpClient = samRockHttpClient,
+ )
+ }
+
+ @Test
+ fun `selected address type maps to descriptor account type`() {
+ assertEquals(AccountType.LEGACY, "legacy".toSamRockAccountType())
+ assertEquals(AccountType.WRAPPED_SEGWIT, "nestedSegwit".toSamRockAccountType())
+ assertEquals(AccountType.NATIVE_SEGWIT, "nativeSegwit".toSamRockAccountType())
+ assertEquals(AccountType.TAPROOT, "taproot".toSamRockAccountType())
+ assertEquals(AccountType.NATIVE_SEGWIT, null.toSamRockAccountType())
+ assertEquals(AccountType.NATIVE_SEGWIT, "unknown".toSamRockAccountType())
+ }
+
+ @Test
+ fun `registerBitcoinOnchain derives descriptor and posts form payload`() = test {
+ whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(MNEMONIC)
+ whenever(keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name)).thenReturn("")
+ whenever {
+ onchainService.deriveOnchainDescriptor(
+ mnemonicPhrase = MNEMONIC,
+ network = Env.network,
+ bip39Passphrase = null,
+ accountType = AccountType.TAPROOT,
+ accountIndex = 0u,
+ )
+ }.thenReturn(DESCRIPTOR)
+ whenever(samRockHttpClient.postDescriptorSetup(any(), any()))
+ .thenReturn(SamRockHttpResponse(HttpStatusCode.OK, SUCCESS_RESPONSE))
+
+ val result = sut.registerBitcoinOnchain(setupRequest())
+
+ assertTrue(result.isSuccess)
+ val payloadCaptor = argumentCaptor()
+ verify(samRockHttpClient).postDescriptorSetup(
+ eq(setupRequest().postUrl),
+ payloadCaptor.capture(),
+ )
+ assertEquals(
+ """{"BTC":{"Descriptor":"$DESCRIPTOR"}}""",
+ payloadCaptor.firstValue,
+ )
+ }
+
+ @Test
+ fun `registerBitcoinOnchain forwards non-empty passphrase`() = test {
+ whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(MNEMONIC)
+ whenever(keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name)).thenReturn(PASSPHRASE)
+ whenever {
+ onchainService.deriveOnchainDescriptor(
+ mnemonicPhrase = MNEMONIC,
+ network = Env.network,
+ bip39Passphrase = PASSPHRASE,
+ accountType = AccountType.TAPROOT,
+ accountIndex = 0u,
+ )
+ }.thenReturn(DESCRIPTOR)
+ whenever(samRockHttpClient.postDescriptorSetup(any(), any()))
+ .thenReturn(SamRockHttpResponse(HttpStatusCode.OK, SUCCESS_RESPONSE))
+
+ val result = sut.registerBitcoinOnchain(setupRequest())
+
+ assertTrue(result.isSuccess)
+ verify(onchainService).deriveOnchainDescriptor(
+ mnemonicPhrase = MNEMONIC,
+ network = Env.network,
+ bip39Passphrase = PASSPHRASE,
+ accountType = AccountType.TAPROOT,
+ accountIndex = 0u,
+ )
+ }
+
+ @Test
+ fun `registerBitcoinOnchain rejects unsupported setup before deriving descriptor`() = test {
+ val error = assertNotNull(sut.registerBitcoinOnchain(unsupportedSetupRequest()).exceptionOrNull())
+
+ assertEquals(UNSUPPORTED_SETUP, error.message)
+ verify(onchainService, never()).deriveOnchainDescriptor(
+ mnemonicPhrase = any(),
+ network = any(),
+ bip39Passphrase = any(),
+ accountType = any(),
+ accountIndex = any(),
+ )
+ verify(samRockHttpClient, never()).postDescriptorSetup(any(), any())
+ }
+
+ @Test
+ fun `registerBitcoinOnchain fails without mnemonic before posting`() = test {
+ whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(null)
+
+ val error = assertNotNull(sut.registerBitcoinOnchain(setupRequest()).exceptionOrNull())
+
+ assertEquals(MISSING_MNEMONIC, error.message)
+ verify(samRockHttpClient, never()).postDescriptorSetup(any(), any())
+ }
+
+ @Test
+ fun `registerBitcoinOnchain uses non success response message`() = test {
+ stubDescriptor()
+ whenever(samRockHttpClient.postDescriptorSetup(any(), any()))
+ .thenReturn(SamRockHttpResponse(HttpStatusCode.BadRequest, """{"Success":false,"Message":"bad otp"}"""))
+
+ val error = assertNotNull(sut.registerBitcoinOnchain(setupRequest()).exceptionOrNull())
+
+ assertEquals("bad otp", error.message)
+ }
+
+ @Test
+ fun `registerBitcoinOnchain rejects missing top-level success`() = test {
+ stubDescriptor()
+ whenever(samRockHttpClient.postDescriptorSetup(any(), any()))
+ .thenReturn(SamRockHttpResponse(HttpStatusCode.OK, """{"Result":{"BTC":{"Success":true}}}"""))
+
+ val error = assertNotNull(sut.registerBitcoinOnchain(setupRequest()).exceptionOrNull())
+
+ assertEquals(INVALID_RESPONSE, error.message)
+ }
+
+ @Test
+ fun `registerBitcoinOnchain requires BTC result`() = test {
+ stubDescriptor()
+ whenever(samRockHttpClient.postDescriptorSetup(any(), any()))
+ .thenReturn(SamRockHttpResponse(HttpStatusCode.OK, """{"Success":true,"Result":{"Results":{}}}"""))
+
+ val error = assertNotNull(sut.registerBitcoinOnchain(setupRequest()).exceptionOrNull())
+
+ assertEquals(MISSING_RESULT, error.message)
+ }
+
+ @Test
+ fun `registerBitcoinOnchain propagates rejected BTC result message`() = test {
+ stubDescriptor()
+ whenever(samRockHttpClient.postDescriptorSetup(any(), any()))
+ .thenReturn(
+ SamRockHttpResponse(
+ HttpStatusCode.OK,
+ """{"Success":true,"Result":{"Results":{"BTC":{"Success":false,"Message":"rejected"}}}}"""
+ )
+ )
+
+ val error = assertNotNull(sut.registerBitcoinOnchain(setupRequest()).exceptionOrNull())
+
+ assertEquals("rejected", error.message)
+ }
+
+ @Test
+ fun `registerBitcoinOnchain accepts lowercase envelope and direct result map`() = test {
+ stubDescriptor()
+ whenever(samRockHttpClient.postDescriptorSetup(any(), any()))
+ .thenReturn(
+ SamRockHttpResponse(
+ HttpStatusCode.OK,
+ """{"success":true,"result":{"BTC":{"success":true}}}"""
+ )
+ )
+
+ val result = sut.registerBitcoinOnchain(setupRequest())
+
+ assertTrue(result.isSuccess)
+ }
+
+ @Test
+ fun `registerBitcoinOnchain uses lowercase non success response message`() = test {
+ stubDescriptor()
+ whenever(samRockHttpClient.postDescriptorSetup(any(), any()))
+ .thenReturn(SamRockHttpResponse(HttpStatusCode.BadRequest, """{"success":false,"message":"bad otp"}"""))
+
+ val error = assertNotNull(sut.registerBitcoinOnchain(setupRequest()).exceptionOrNull())
+
+ assertEquals("bad otp", error.message)
+ }
+
+ @Test
+ fun `registerBitcoinOnchain uses setup failed fallback`() = test {
+ stubDescriptor()
+ whenever(samRockHttpClient.postDescriptorSetup(any(), any()))
+ .thenReturn(SamRockHttpResponse(HttpStatusCode.OK, """{"Success":false}"""))
+
+ val error = assertNotNull(sut.registerBitcoinOnchain(setupRequest()).exceptionOrNull())
+
+ assertEquals(SETUP_FAILED, error.message)
+ }
+
+ @Test
+ fun `registerBitcoinOnchain uses rejected descriptor fallback`() = test {
+ stubDescriptor()
+ whenever(samRockHttpClient.postDescriptorSetup(any(), any()))
+ .thenReturn(
+ SamRockHttpResponse(
+ HttpStatusCode.OK,
+ """{"Success":true,"Result":{"BTC":{"Success":false}}}"""
+ )
+ )
+
+ val error = assertNotNull(sut.registerBitcoinOnchain(setupRequest()).exceptionOrNull())
+
+ assertEquals(REJECTED_DESCRIPTOR, error.message)
+ }
+
+ @Test
+ fun `registerBitcoinOnchain rejects nested non boolean BTC success`() = test {
+ stubDescriptor()
+ whenever(samRockHttpClient.postDescriptorSetup(any(), any()))
+ .thenReturn(
+ SamRockHttpResponse(
+ HttpStatusCode.OK,
+ """{"Success":true,"Result":{"BTC":{"Success":"true"}}}"""
+ )
+ )
+
+ val error = assertNotNull(sut.registerBitcoinOnchain(setupRequest()).exceptionOrNull())
+
+ assertEquals(MISSING_RESULT, error.message)
+ }
+
+ @Test
+ fun `registerBitcoinOnchain rejects malformed response bodies`() = test {
+ val cases = listOf(
+ "" to INVALID_RESPONSE,
+ """{"Success":true}""" to MISSING_RESULT,
+ """{"Success":true,"Result":"bad"}""" to MISSING_RESULT,
+ """{"Success":true,"Result":{"Results":{"BTC":{}}}}""" to MISSING_RESULT,
+ """{"Success":"true","Result":{"Results":{"BTC":{"Success":true}}}}""" to INVALID_RESPONSE,
+ """{"Success":{},"Result":{"Results":{"BTC":{"Success":true}}}}""" to INVALID_RESPONSE,
+ """{"Success":true,"Result":{"Results":{"BTC":{"Success":[]}}}}""" to MISSING_RESULT,
+ """{"Success":true,"Result":{"Results":{"btc":{"Success":true}}}}""" to MISSING_RESULT,
+ )
+
+ cases.forEach { (body, expectedMessage) ->
+ stubDescriptor()
+ whenever(samRockHttpClient.postDescriptorSetup(any(), any()))
+ .thenReturn(SamRockHttpResponse(HttpStatusCode.OK, body))
+
+ val error = assertNotNull(sut.registerBitcoinOnchain(setupRequest()).exceptionOrNull(), body)
+
+ assertEquals(expectedMessage, error.message, body)
+ }
+ }
+
+ @Test
+ fun `registerBitcoinOnchain uses status description for non success malformed body`() = test {
+ stubDescriptor()
+ whenever(samRockHttpClient.postDescriptorSetup(any(), any()))
+ .thenReturn(SamRockHttpResponse(HttpStatusCode.InternalServerError, ""))
+
+ val error = assertNotNull(sut.registerBitcoinOnchain(setupRequest()).exceptionOrNull())
+
+ assertEquals(HttpStatusCode.InternalServerError.description, error.message)
+ }
+
+ @Test
+ fun `registerBitcoinOnchain wraps transport errors without leaking URL`() = test {
+ stubDescriptor()
+ whenever(samRockHttpClient.postDescriptorSetup(any(), any()))
+ .thenThrow(IllegalStateException("Failed request to ${setupRequest().postUrl}"))
+
+ val error = assertNotNull(sut.registerBitcoinOnchain(setupRequest()).exceptionOrNull())
+
+ assertEquals(REQUEST_ERROR, error.message)
+ assertFalse(error.message.orEmpty().contains("secret"))
+ }
+
+ @Test
+ fun `samRockFormBody uses json form field`() {
+ val body = samRockFormBody("""{"BTC":{"Descriptor":"descriptor"}}""")
+
+ assertEquals("""{"BTC":{"Descriptor":"descriptor"}}""", body.formData["json"])
+ assertEquals("application/x-www-form-urlencoded; charset=UTF-8", body.contentType.toString())
+ }
+
+ @Test
+ fun `response parser accepts uppercase envelope`() {
+ val envelope = assertNotNull(
+ SamRockResponseParser.decode(
+ """
+ {
+ "Success": true,
+ "Message": "ok",
+ "Result": {
+ "Results": {
+ "BTC": { "Success": true, "Message": "registered" }
+ }
+ }
+ }
+ """.trimIndent()
+ )
+ )
+
+ assertTrue(envelope.success == true)
+ assertEquals("ok", envelope.message)
+ assertTrue(envelope.result?.results?.get("BTC")?.success == true)
+ assertEquals("registered", envelope.result?.results?.get("BTC")?.message)
+ }
+
+ @Test
+ fun `response parser accepts lowercase envelope and direct result map`() {
+ val envelope = assertNotNull(
+ SamRockResponseParser.decode(
+ """
+ {
+ "success": true,
+ "result": {
+ "BTC": { "success": true }
+ }
+ }
+ """.trimIndent()
+ )
+ )
+
+ assertTrue(envelope.success == true)
+ assertTrue(envelope.result?.results?.get("BTC")?.success == true)
+ }
+
+ @Test
+ fun `response parser preserves failed method message`() {
+ val envelope = assertNotNull(
+ SamRockResponseParser.decode(
+ """
+ {
+ "success": true,
+ "result": {
+ "results": {
+ "BTC": { "success": false, "message": "descriptor rejected" }
+ }
+ }
+ }
+ """.trimIndent()
+ )
+ )
+ val btcResult = assertNotNull(envelope.result?.results?.get("BTC"))
+
+ assertFalse(btcResult.success)
+ assertEquals("descriptor rejected", btcResult.message)
+ }
+
+ @Test
+ fun `response parser rejects invalid or non-object json`() {
+ assertNull(SamRockResponseParser.decode(""))
+ assertNull(SamRockResponseParser.decode("[]"))
+ }
+
+ private suspend fun stubDescriptor() {
+ whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(MNEMONIC)
+ whenever(keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name)).thenReturn(null)
+ whenever {
+ onchainService.deriveOnchainDescriptor(
+ mnemonicPhrase = MNEMONIC,
+ network = Env.network,
+ bip39Passphrase = null,
+ accountType = AccountType.TAPROOT,
+ accountIndex = 0u,
+ )
+ }.thenReturn(DESCRIPTOR)
+ }
+
+ private fun setupRequest() = SamRockSetupRequest(
+ postUrl = POST_URL,
+ storeId = "store",
+ otp = "secret",
+ requestedMethods = setOf(SamRockPaymentMethod.BTC_ONCHAIN),
+ hasUnknownMethods = false,
+ hostDisplayName = "btcpay.example.com",
+ logDescription = "https://btcpay.example.com/plugins/store/samrock/protocol",
+ )
+
+ private fun unsupportedSetupRequest() = SamRockSetupRequest(
+ postUrl = POST_URL,
+ storeId = "store",
+ otp = "secret",
+ requestedMethods = setOf(SamRockPaymentMethod.BTC_LIGHTNING),
+ hasUnknownMethods = false,
+ hostDisplayName = "btcpay.example.com",
+ logDescription = "https://btcpay.example.com/plugins/store/samrock/protocol",
+ )
+}
diff --git a/app/src/test/java/to/bitkit/ui/MainActivityLaunchKeyTest.kt b/app/src/test/java/to/bitkit/ui/MainActivityLaunchKeyTest.kt
new file mode 100644
index 000000000..aebb4f014
--- /dev/null
+++ b/app/src/test/java/to/bitkit/ui/MainActivityLaunchKeyTest.kt
@@ -0,0 +1,58 @@
+package to.bitkit.ui
+
+import android.content.Intent
+import android.net.Uri
+import org.junit.Test
+import org.mockito.kotlin.mock
+import kotlin.test.assertFalse
+import kotlin.test.assertNotEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class MainActivityLaunchKeyTest {
+ private companion object {
+ private const val SAMROCK_SETUP_URL =
+ "https://btcpay.example.com/plugins/store/samrock/protocol?setup=btc-chain&otp=secret"
+ }
+
+ @Test
+ fun `launch key redacts SamRock query values`() {
+ val key = assertNotNull(viewIntent(SAMROCK_SETUP_URL).launchKey())
+
+ assertTrue(key.startsWith("https://btcpay.example.com/plugins/store/samrock/protocol#"))
+ assertFalse(key.contains("otp"))
+ assertFalse(key.contains("secret"))
+ assertFalse(key.contains("setup"))
+ }
+
+ @Test
+ fun `launch key keeps SamRock setup links with different OTPs distinct`() {
+ val first = assertNotNull(viewIntent(SAMROCK_SETUP_URL).launchKey())
+ val second = assertNotNull(
+ viewIntent("https://btcpay.example.com/plugins/store/samrock/protocol?setup=btc-chain&otp=other")
+ .launchKey()
+ )
+
+ assertNotEquals(first, second)
+ }
+
+ @Test
+ fun `launch key ignores non view intents`() {
+ val intent = mock {
+ on { action }.thenReturn(Intent.ACTION_SEND)
+ }
+
+ assertNull(intent.launchKey())
+ }
+
+ private fun viewIntent(url: String): Intent {
+ val uri = mock {
+ on { toString() }.thenReturn(url)
+ }
+ return mock {
+ on { action }.thenReturn(Intent.ACTION_VIEW)
+ on { data }.thenReturn(uri)
+ }
+ }
+}
diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt
index e0f099398..aeac2d210 100644
--- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt
+++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt
@@ -1,7 +1,10 @@
package to.bitkit.viewmodels
+import android.content.ClipData
+import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
+import android.net.Uri
import androidx.core.net.toUri
import app.cash.turbine.test
import com.synonym.bitkitcore.LightningInvoice
@@ -20,6 +23,7 @@ import org.junit.runner.RunWith
import org.lightningdevkit.ldknode.Event
import org.mockito.kotlin.any
import org.mockito.kotlin.anyOrNull
+import org.mockito.kotlin.check
import org.mockito.kotlin.clearInvocations
import org.mockito.kotlin.mock
import org.mockito.kotlin.never
@@ -35,6 +39,8 @@ import to.bitkit.data.keychain.Keychain
import to.bitkit.domain.commands.NotifyPaymentReceivedHandler
import to.bitkit.models.BalanceState
import to.bitkit.models.PubkyProfile
+import to.bitkit.models.SamRockPaymentMethod
+import to.bitkit.models.SamRockSetupRequest
import to.bitkit.models.TransactionSpeed
import to.bitkit.repositories.ActivityRepo
import to.bitkit.repositories.BackupRepo
@@ -53,6 +59,7 @@ import to.bitkit.repositories.PreActivityMetadataRepo
import to.bitkit.repositories.PrivatePaykitRepo
import to.bitkit.repositories.PubkyRepo
import to.bitkit.repositories.PublicPaykitRepo
+import to.bitkit.repositories.SamRockRepo
import to.bitkit.repositories.TransferRepo
import to.bitkit.repositories.WalletRepo
import to.bitkit.repositories.WalletState
@@ -105,8 +112,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() {
private val pubkyRepo = mock()
private val publicPaykitRepo = mock()
private val privatePaykitRepo = mock()
+ private val samRockRepo = mock()
private val widgetsRepo = mock()
private val formatMoneyValue = mock()
+ private val clipboardManager = mock()
+ private val toastManager = mock()
private val balanceState = MutableStateFlow(BalanceState())
private val settingsData = MutableStateFlow(SettingsData())
@@ -128,6 +138,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() {
private fun stubRepositories() {
whenever(context.getString(any())).thenReturn("")
+ whenever(context.getSystemService(Context.CLIPBOARD_SERVICE)).thenReturn(clipboardManager)
whenever(connectivityRepo.isOnline).thenReturn(MutableStateFlow(ConnectivityState.CONNECTED))
whenever(healthRepo.healthState).thenReturn(MutableStateFlow(mock()))
whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState()))
@@ -182,6 +193,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() {
whenever { lightningRepo.getFeeRateForSpeed(any(), anyOrNull()) }
.thenReturn(Result.success(2u))
whenever(lightningRepo.canSend(any())).thenReturn(true)
+ whenever(toastManager.currentToast).thenReturn(MutableStateFlow(null))
}
private fun stubSettingsStore() {
@@ -197,7 +209,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() {
private fun createViewModel() = AppViewModel(
connectivityRepo = connectivityRepo,
healthRepo = healthRepo,
- toastManagerProvider = { mock() },
+ toastManagerProvider = { toastManager },
timedSheetManagerProvider = { timedSheetManager },
context = context,
bgDispatcher = testDispatcher,
@@ -219,6 +231,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() {
coreService = coreService,
publicPaykitRepo = publicPaykitRepo,
privatePaykitRepo = privatePaykitRepo,
+ samRockRepo = samRockRepo,
appUpdateSheet = mock(),
backupSheet = mock(),
notificationsSheet = mock(),
@@ -245,6 +258,236 @@ class AppViewModelSendFlowTest : BaseUnitTest() {
assertFalse(sut.sendUiState.value.canSwitchWallet)
}
+ @Test
+ fun `scan SamRock setup opens BTCPay connection sheet without core decode`() = test {
+ sut.onScanResult(
+ "https://btcpay.example.com/plugins/store/samrock/protocol?setup=btc-chain&otp=secret"
+ )
+ advanceUntilIdle()
+
+ val sheet = sut.currentSheet.value
+ assertTrue(sheet is Sheet.BTCPayConnection)
+ assertEquals("secret", sheet.setup.otp)
+ verify(coreService, never()).decode(any())
+ }
+
+ @Test
+ fun `scan lightning-only SamRock setup shows unsupported toast without sheet`() = test {
+ sut.onScanResult(
+ "https://btcpay.example.com/plugins/store/samrock/protocol?setup=btc-ln&otp=secret"
+ )
+ advanceUntilIdle()
+
+ assertNull(sut.currentSheet.value)
+ verify(toastManager).enqueue(
+ check {
+ assertEquals("BTCPayUnsupportedToast", it.testTag)
+ }
+ )
+ verify(coreService, never()).decode(any())
+ }
+
+ @Test
+ fun `SamRock deeplink opens BTCPay connection sheet without core decode`() = test {
+ sut.handleDeeplinkIntent(samRockIntent(SAMROCK_SETUP_URL))
+ advanceUntilIdle()
+
+ val sheet = sut.currentSheet.value
+ assertTrue(sheet is Sheet.BTCPayConnection)
+ assertEquals("secret", sheet.setup.otp)
+ verify(coreService, never()).decode(any())
+ }
+
+ @Test
+ fun `SamRock deeplink containing recovery mode text opens BTCPay sheet`() = test {
+ sut.handleDeeplinkIntent(
+ samRockIntent("https://btcpay.example.com/plugins/store/samrock/protocol?setup=btc-chain&otp=recovery-mode")
+ )
+ advanceUntilIdle()
+
+ val sheet = sut.currentSheet.value
+ assertTrue(sheet is Sheet.BTCPayConnection)
+ assertEquals("recovery-mode", sheet.setup.otp)
+ verify(lightningRepo, never()).setRecoveryMode(true)
+ verify(coreService, never()).decode(any())
+ }
+
+ @Test
+ fun `public http SamRock deeplink shows setup error without core decode`() = test {
+ sut.handleDeeplinkIntent(
+ samRockIntent("http://btcpay.example.com/plugins/store/samrock/protocol?setup=btc-chain&otp=secret")
+ )
+ advanceUntilIdle()
+
+ assertNull(sut.currentSheet.value)
+ verify(toastManager).enqueue(
+ check {
+ assertEquals("BTCPayInvalidSetupToast", it.testTag)
+ }
+ )
+ verify(coreService, never()).decode(any())
+ }
+
+ @Test
+ fun `unsupported SamRock deeplink shows unsupported toast without sheet`() = test {
+ sut.handleDeeplinkIntent(
+ samRockIntent("https://btcpay.example.com/plugins/store/samrock/protocol?setup=btcln&otp=secret")
+ )
+ advanceUntilIdle()
+
+ assertNull(sut.currentSheet.value)
+ verify(toastManager).enqueue(
+ check {
+ assertEquals("BTCPayUnsupportedToast", it.testTag)
+ }
+ )
+ verify(coreService, never()).decode(any())
+ }
+
+ @Test
+ fun `SamRock deeplink is ignored when wallet does not exist`() = test {
+ whenever(walletRepo.walletExists()).thenReturn(false)
+
+ sut.handleDeeplinkIntent(samRockIntent(SAMROCK_SETUP_URL))
+ advanceUntilIdle()
+
+ assertNull(sut.currentSheet.value)
+ verify(coreService, never()).decode(any())
+ verify(toastManager, never()).enqueue(any())
+ }
+
+ @Test
+ fun `recovery mode deeplink enables recovery mode`() = test {
+ sut.handleDeeplinkIntent(recoveryModeIntent())
+ advanceUntilIdle()
+
+ verify(lightningRepo).setRecoveryMode(true)
+ }
+
+ @Test
+ fun `connectBTCPay hides sheet and shows success toast`() = test {
+ val setup = samRockSetupRequest()
+ whenever(samRockRepo.registerBitcoinOnchain(setup)).thenReturn(Result.success(Unit))
+ sut.showSheet(Sheet.BTCPayConnection(setup))
+ advanceUntilIdle()
+
+ val result = sut.connectBTCPay(setup)
+ advanceUntilIdle()
+
+ assertTrue(result.isSuccess)
+ assertNull(sut.currentSheet.value)
+ verify(toastManager).enqueue(
+ check {
+ assertEquals("BTCPayConnectedToast", it.testTag)
+ }
+ )
+ }
+
+ @Test
+ fun `connectBTCPay failure keeps sheet and shows error toast`() = test {
+ val setup = samRockSetupRequest()
+ whenever(samRockRepo.registerBitcoinOnchain(setup)).thenReturn(Result.failure(AppError("failed")))
+ sut.showSheet(Sheet.BTCPayConnection(setup))
+ advanceUntilIdle()
+
+ val result = sut.connectBTCPay(setup)
+ advanceUntilIdle()
+
+ assertTrue(result.isFailure)
+ assertTrue(sut.currentSheet.value is Sheet.BTCPayConnection)
+ verify(toastManager).enqueue(
+ check {
+ assertEquals("BTCPayConnectionErrorToast", it.testTag)
+ }
+ )
+ }
+
+ @Test
+ fun `canDecodeClipboard accepts SamRock setup without core decode`() = test {
+ val setupUrl = "https://btcpay.example.com/plugins/store/samrock/protocol?setup=btc-chain&otp=secret"
+ clearInvocations(coreService)
+
+ assertTrue(sut.canDecodeClipboard(setupUrl))
+ verify(coreService, never()).decode(any())
+ }
+
+ @Test
+ fun `canDecodeClipboard accepts invalid SamRock setup without core decode`() = test {
+ val setupUrl = "http://btcpay.example.com/plugins/store/samrock/protocol?setup=btc-chain&otp=secret"
+ clearInvocations(coreService)
+
+ assertTrue(sut.canDecodeClipboard(setupUrl))
+ verify(coreService, never()).decode(any())
+ }
+
+ @Test
+ fun `paste SamRock setup opens BTCPay connection sheet without core decode`() = test {
+ val clipData = mock()
+ val item = mock()
+ whenever(item.text).thenReturn(SAMROCK_SETUP_URL)
+ whenever(clipData.getItemAt(0)).thenReturn(item)
+ whenever(clipboardManager.primaryClip).thenReturn(clipData)
+ clearInvocations(coreService)
+
+ sut.setSendEvent(SendEvent.Paste)
+ advanceUntilIdle()
+
+ val sheet = sut.currentSheet.value
+ assertTrue(sheet is Sheet.BTCPayConnection)
+ assertEquals("secret", sheet.setup.otp)
+ verify(coreService, never()).decode(any())
+ }
+
+ @Test
+ fun `paste empty clipboard shows warning without core decode`() = test {
+ whenever(clipboardManager.primaryClip).thenReturn(null)
+ clearInvocations(coreService)
+
+ sut.setSendEvent(SendEvent.Paste)
+ advanceUntilIdle()
+
+ assertNull(sut.currentSheet.value)
+ verify(toastManager).enqueue(any())
+ verify(coreService, never()).decode(any())
+ }
+
+ @Test
+ fun `scanner sheet SamRock result opens BTCPay connection sheet without core decode`() = test {
+ clearInvocations(coreService)
+
+ sut.showScannerSheet()
+ advanceUntilIdle()
+ assertTrue(sut.currentSheet.value is Sheet.QrScanner)
+
+ sut.onScannerSheetResult(SAMROCK_SETUP_URL)
+ advanceUntilIdle()
+
+ val sheet = sut.currentSheet.value
+ assertTrue(sheet is Sheet.BTCPayConnection)
+ assertEquals("secret", sheet.setup.otp)
+ verify(coreService, never()).decode(any())
+ }
+
+ @Test
+ fun `scanner sheet handler is bypassed for SamRock result`() = test {
+ var handled = false
+ clearInvocations(coreService)
+
+ sut.showScannerSheet {
+ handled = true
+ }
+ advanceUntilIdle()
+ assertTrue(sut.currentSheet.value is Sheet.QrScanner)
+
+ sut.onScannerSheetResult(SAMROCK_SETUP_URL)
+ advanceUntilIdle()
+
+ val sheet = sut.currentSheet.value
+ assertTrue(sheet is Sheet.BTCPayConnection)
+ assertFalse(handled)
+ verify(coreService, never()).decode(any())
+ }
+
@Test
fun `canSwitchWallet is false when amount equals dust limit`() = test {
balanceState.value = BalanceState(
@@ -992,6 +1235,42 @@ class AppViewModelSendFlowTest : BaseUnitTest() {
isPaykitEnabled.value = true
}
+ private fun samRockSetupRequest() = SamRockSetupRequest(
+ postUrl = "https://btcpay.example.com/plugins/store/samrock/protocol?setup=btc-chain&otp=secret",
+ storeId = "store",
+ otp = "secret",
+ requestedMethods = setOf(SamRockPaymentMethod.BTC_ONCHAIN),
+ hasUnknownMethods = false,
+ hostDisplayName = "btcpay.example.com",
+ logDescription = "https://btcpay.example.com/plugins/store/samrock/protocol",
+ )
+
+ private fun samRockIntent(url: String): Intent {
+ val uri = mock {
+ on { toString() }.thenReturn(url)
+ on { scheme }.thenReturn("https")
+ on { host }.thenReturn("btcpay.example.com")
+ on { path }.thenReturn("/plugins/store/samrock/protocol")
+ }
+ return mock {
+ on { action }.thenReturn(Intent.ACTION_VIEW)
+ on { data }.thenReturn(uri)
+ }
+ }
+
+ private fun recoveryModeIntent(): Intent {
+ val uri = mock {
+ on { toString() }.thenReturn("bitkit://recovery-mode")
+ on { scheme }.thenReturn("bitkit")
+ on { host }.thenReturn("recovery-mode")
+ on { pathSegments }.thenReturn(emptyList())
+ }
+ return mock {
+ on { action }.thenReturn(Intent.ACTION_VIEW)
+ on { data }.thenReturn(uri)
+ }
+ }
+
@Suppress("UNCHECKED_CAST")
private fun setPendingContactPaymentContext(paymentHash: String, publicKey: String) {
val field = AppViewModel::class.java.getDeclaredField("pendingContactPaymentContexts")
@@ -1051,3 +1330,6 @@ class AppViewModelSendFlowTest : BaseUnitTest() {
method.invoke(sut)
}
}
+
+private const val SAMROCK_SETUP_URL =
+ "https://btcpay.example.com/plugins/store/samrock/protocol?setup=btc-chain&otp=secret"
diff --git a/changelog.d/next/961.added.md b/changelog.d/next/961.added.md
new file mode 100644
index 000000000..f6b98fdbb
--- /dev/null
+++ b/changelog.d/next/961.added.md
@@ -0,0 +1 @@
+Added BTCPay wallet connection support for sharing Bitcoin receive descriptors.
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 3e2c02fda..e4644f9ad 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -21,7 +21,7 @@ activity-compose = { module = "androidx.activity:activity-compose", version = "1
appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" }
barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version = "17.3.0" }
biometric = { module = "androidx.biometric:biometric", version = "1.4.0-alpha05" }
-bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.1.58" }
+bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.1.60" }
paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc8" }
bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" }
camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" }