Skip to content

Commit 8df212c

Browse files
authored
Merge pull request #979 from synonymdev/fix/samrock-deeplinks
fix: handle samrock bitkit links
2 parents fbf6e5b + 193772d commit 8df212c

8 files changed

Lines changed: 166 additions & 37 deletions

File tree

app/src/main/AndroidManifest.xml

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -127,23 +127,6 @@
127127
<data android:scheme="pubkyauth" />
128128
</intent-filter>
129129

130-
<!-- BTCPay wallet connection links -->
131-
<intent-filter>
132-
<action android:name="android.intent.action.VIEW" />
133-
134-
<category android:name="android.intent.category.DEFAULT" />
135-
<category android:name="android.intent.category.BROWSABLE" />
136-
137-
<data
138-
android:scheme="https"
139-
android:host="*"
140-
android:pathPattern="/plugins/.*/samrock/protocol" />
141-
<data
142-
android:scheme="http"
143-
android:host="*"
144-
android:pathPattern="/plugins/.*/samrock/protocol" />
145-
</intent-filter>
146-
147130
<!-- NFC -->
148131
<intent-filter>
149132
<action android:name="android.nfc.action.NDEF_DISCOVERED" />

app/src/main/java/to/bitkit/models/SamRockSetupRequest.kt

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ data class SamRockSetupRequest(
1919
companion object {
2020
@Suppress("CyclomaticComplexMethod", "ReturnCount")
2121
fun parse(raw: String): SamRockSetupRequest? {
22-
val uri = runCatching { URI(raw.trim()) }.getOrNull() ?: return null
22+
val setupUrl = raw.samRockSetupUrl()
23+
val uri = runCatching { URI(setupUrl.trim()) }.getOrNull() ?: return null
2324
val scheme = uri.scheme?.lowercase(Locale.US) ?: return null
2425
val host = uri.host ?: return null
2526
if (uri.rawUserInfo != null) return null
@@ -48,23 +49,24 @@ data class SamRockSetupRequest(
4849
}
4950

5051
fun sanitizedDescription(raw: String): String? {
51-
val trimmed = raw.trim()
52+
val trimmed = raw.samRockSetupUrl().trim()
5253
val uri = runCatching { URI(trimmed) }.getOrNull()
5354
if (uri?.hasAuthority() == true && uri.isSamRockProtocolPath()) return uri.logDescription()
5455

5556
return trimmed.sanitizedSamRockLikeDescription()
5657
}
5758

5859
fun sanitizedLaunchKey(raw: String): String? {
59-
return sanitizedDescription(raw)?.let { "$it#${raw.sha256Prefix()}" }
60+
val setupUrl = raw.samRockSetupUrl()
61+
return sanitizedDescription(setupUrl)?.let { "$it#${setupUrl.sha256Prefix()}" }
6062
}
6163

6264
fun isProtocolUrl(raw: String): Boolean {
6365
return sanitizedDescription(raw) != null
6466
}
6567

6668
fun isPublicHttpProtocolUrl(raw: String): Boolean {
67-
val uri = runCatching { URI(raw.trim()) }.getOrNull() ?: return false
69+
val uri = runCatching { URI(raw.samRockSetupUrl().trim()) }.getOrNull() ?: return false
6870
val scheme = uri.scheme?.lowercase(Locale.US) ?: return false
6971
val host = uri.host ?: return false
7072

@@ -73,6 +75,22 @@ data class SamRockSetupRequest(
7375
!isLocalOrPrivateHost(host)
7476
}
7577

78+
fun setupUrlFromDeeplink(raw: String): String? {
79+
val uri = runCatching { URI(raw.trim()) }.getOrNull()
80+
val pathComponents = uri?.decodedPathComponents().orEmpty()
81+
val queryItems = uri?.rawQuery
82+
?.let { runCatching { parseQuery(it) }.getOrNull() }
83+
.orEmpty()
84+
val isSamRockDeeplink = uri?.scheme?.lowercase(Locale.US) == BITKIT_SCHEME &&
85+
uri.host.equals(BTCPAY_HOST, ignoreCase = true) &&
86+
pathComponents.singleOrNull()?.equals(SAMROCK_PATH_COMPONENT, ignoreCase = true) == true
87+
88+
return queryItems.firstValue(URL_QUERY_KEY)
89+
?.takeIf { isSamRockDeeplink }
90+
?.takeIf { it.isNotBlank() }
91+
?.takeIf { isProtocolUrl(it) }
92+
}
93+
7694
private fun parseMethods(setup: String?): ParsedSamRockMethods {
7795
val values = setup
7896
?.split(SETUP_METHOD_SEPARATOR)
@@ -249,6 +267,12 @@ data class SamRockSetupRequest(
249267

250268
private fun String.isIpv6Literal(): Boolean = ":" in this
251269

270+
private fun String.samRockSetupUrl(): String {
271+
return setupUrlFromDeeplink(this) ?: this
272+
}
273+
274+
private const val BITKIT_SCHEME = "bitkit"
275+
private const val BTCPAY_HOST = "btcpay"
252276
private const val HTTP_SCHEME = "http"
253277
private const val HTTPS_SCHEME = "https"
254278
private const val LOCALHOST = "localhost"
@@ -277,6 +301,7 @@ data class SamRockSetupRequest(
277301
private const val SAMROCK_PROTOCOL_PATH_MARKER = "/samrock/protocol"
278302
private const val OTP_QUERY_KEY = "otp"
279303
private const val SETUP_QUERY_KEY = "setup"
304+
private const val URL_QUERY_KEY = "url"
280305
private const val SETUP_METHOD_SEPARATOR = ","
281306
private const val SHA_256_ALGORITHM = "SHA-256"
282307
private const val LAUNCH_KEY_HASH_BYTES = 8

app/src/main/java/to/bitkit/repositories/SamRockRepo.kt

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,14 +85,19 @@ class SamRockRepo @Inject constructor(
8585
throw AppError(context.getString(R.string.btcpay__invalid_response))
8686
}
8787

88+
val btcResult = envelope.result?.results?.get(BITCOIN_METHOD)
89+
8890
when (envelope.success) {
8991
true -> Unit
90-
false -> throw AppError(envelope.message ?: context.getString(R.string.btcpay__setup_failed))
92+
false -> throw AppError(
93+
btcResult?.failureMessage() ?: envelope.message ?: context.getString(R.string.btcpay__setup_failed)
94+
)
9195
null -> throw AppError(context.getString(R.string.btcpay__invalid_response))
9296
}
9397

94-
val btcResult = envelope.result?.results?.get(BITCOIN_METHOD)
95-
?: throw AppError(context.getString(R.string.btcpay__missing_result))
98+
if (btcResult == null) {
99+
throw AppError(context.getString(R.string.btcpay__missing_result))
100+
}
96101

97102
if (!btcResult.success) {
98103
throw AppError(btcResult.message ?: context.getString(R.string.btcpay__rejected_descriptor))
@@ -223,7 +228,13 @@ internal data class SamRockSetupResponse(
223228
internal data class SamRockMethodResponse(
224229
val success: Boolean,
225230
val message: String?,
226-
)
231+
) {
232+
fun failureMessage(): String? {
233+
if (success) return null
234+
235+
return message
236+
}
237+
}
227238

228239
internal fun String?.toSamRockAccountType(): AccountType {
229240
return (this?.toAddressType() ?: DEFAULT_ADDRESS_TYPE).let {

app/src/test/java/to/bitkit/models/SamRockSetupRequestTest.kt

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package to.bitkit.models
22

3+
import java.net.URLEncoder
4+
import java.nio.charset.StandardCharsets
35
import kotlin.test.Test
46
import kotlin.test.assertEquals
57
import kotlin.test.assertFalse
@@ -49,6 +51,24 @@ class SamRockSetupRequestTest {
4951
)
5052
}
5153

54+
@Test
55+
fun `parse accepts Bitkit SamRock deeplink wrapper`() {
56+
val setup = assertNotNull(
57+
SamRockSetupRequest.parse(
58+
samRockDeepLink(
59+
"https://btcpay.example.com/btcpay/plugins/store-1/samrock/protocol?setup=btc-chain&otp=secret"
60+
)
61+
)
62+
)
63+
64+
assertEquals("store-1", setup.storeId)
65+
assertEquals("secret", setup.otp)
66+
assertEquals(
67+
"https://btcpay.example.com/btcpay/plugins/store-1/samrock/protocol?setup=btc-chain&otp=secret",
68+
setup.postUrl,
69+
)
70+
}
71+
5272
@Test
5373
fun `parse defaults missing setup to all`() {
5474
val setup = assertNotNull(
@@ -158,6 +178,13 @@ class SamRockSetupRequestTest {
158178
"http://btcpay.example.com/plugins/store/samrock/protocol?setup=btc-chain&otp=secret"
159179
)
160180
)
181+
assertTrue(
182+
SamRockSetupRequest.isPublicHttpProtocolUrl(
183+
samRockDeepLink(
184+
"http://btcpay.example.com/plugins/store/samrock/protocol?setup=btc-chain&otp=secret"
185+
)
186+
)
187+
)
161188
}
162189

163190
@Test
@@ -277,6 +304,12 @@ class SamRockSetupRequestTest {
277304
"bitkit://pubky-auth/success",
278305
"bitkit://pubky-auth/success?nonce=secret".sanitizedDeeplinkLogValue(),
279306
)
307+
assertEquals(
308+
"https://btcpay.example.com/plugins/store/samrock/protocol",
309+
samRockDeepLink(
310+
"https://btcpay.example.com/plugins/store/samrock/protocol?setup=btc-chain&otp=secret"
311+
).sanitizedDeeplinkLogValue(),
312+
)
280313
}
281314

282315
@Test
@@ -299,6 +332,22 @@ class SamRockSetupRequestTest {
299332
assertFalse(first == second)
300333
}
301334

335+
@Test
336+
fun `sanitized launch key redacts Bitkit SamRock deeplink wrapper`() {
337+
val key = assertNotNull(
338+
SamRockSetupRequest.sanitizedLaunchKey(
339+
samRockDeepLink(
340+
"https://btcpay.example.com/plugins/store/samrock/protocol?setup=btc-chain&otp=secret"
341+
)
342+
)
343+
)
344+
345+
assertTrue(key.startsWith("https://btcpay.example.com/plugins/store/samrock/protocol#"))
346+
assertFalse(key.contains("otp"))
347+
assertFalse(key.contains("secret"))
348+
assertFalse(key.contains("setup"))
349+
}
350+
302351
@Test
303352
fun `sanitized description drops userinfo and malformed paths`() {
304353
assertEquals(
@@ -320,4 +369,12 @@ class SamRockSetupRequestTest {
320369
),
321370
)
322371
}
372+
373+
private fun samRockDeepLink(setupUrl: String): String {
374+
return "bitkit://btcpay/samrock?url=${setupUrl.urlEncode()}"
375+
}
376+
377+
private fun String.urlEncode(): String {
378+
return URLEncoder.encode(this, StandardCharsets.UTF_8.name()).replace("+", "%20")
379+
}
323380
}

app/src/test/java/to/bitkit/repositories/SamRockRepoTest.kt

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -211,13 +211,23 @@ class SamRockRepoTest : BaseUnitTest() {
211211
.thenReturn(
212212
SamRockHttpResponse(
213213
HttpStatusCode.OK,
214-
"""{"Success":true,"Result":{"Results":{"BTC":{"Success":false,"Message":"rejected"}}}}"""
214+
"""
215+
{
216+
"Success": false,
217+
"Message": "Wallet setup failed.",
218+
"Result": {
219+
"Results": {
220+
"BTC": { "Success": false, "Message": "descriptor rejected" }
221+
}
222+
}
223+
}
224+
""".trimIndent()
215225
)
216226
)
217227

218228
val error = assertNotNull(sut.registerBitcoinOnchain(setupRequest()).exceptionOrNull())
219229

220-
assertEquals("rejected", error.message)
230+
assertEquals("descriptor rejected", error.message)
221231
}
222232

223233
@Test

app/src/test/java/to/bitkit/ui/MainActivityLaunchKeyTest.kt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import android.content.Intent
44
import android.net.Uri
55
import org.junit.Test
66
import org.mockito.kotlin.mock
7+
import java.net.URLEncoder
8+
import java.nio.charset.StandardCharsets
79
import kotlin.test.assertFalse
810
import kotlin.test.assertNotEquals
911
import kotlin.test.assertNotNull
@@ -37,6 +39,16 @@ class MainActivityLaunchKeyTest {
3739
assertNotEquals(first, second)
3840
}
3941

42+
@Test
43+
fun `launch key redacts Bitkit SamRock deeplink query values`() {
44+
val key = assertNotNull(viewIntent(samRockDeepLink(SAMROCK_SETUP_URL)).launchKey())
45+
46+
assertTrue(key.startsWith("https://btcpay.example.com/plugins/store/samrock/protocol#"))
47+
assertFalse(key.contains("otp"))
48+
assertFalse(key.contains("secret"))
49+
assertFalse(key.contains("setup"))
50+
}
51+
4052
@Test
4153
fun `launch key ignores non view intents`() {
4254
val intent = mock<Intent> {
@@ -55,4 +67,12 @@ class MainActivityLaunchKeyTest {
5567
on { data }.thenReturn(uri)
5668
}
5769
}
70+
71+
private fun samRockDeepLink(setupUrl: String): String {
72+
return "bitkit://btcpay/samrock?url=${setupUrl.urlEncode()}"
73+
}
74+
75+
private fun String.urlEncode(): String {
76+
return URLEncoder.encode(this, StandardCharsets.UTF_8.name()).replace("+", "%20")
77+
}
5878
}

0 commit comments

Comments
 (0)