pakstr 0.21.1 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/android-template/app/src/androidTest/java/com/pakstr/app/Nip55CallbackTest.kt +1 -1
- package/android-template/app/src/main/java/com/pakstr/app/LocalServer.kt +12 -28
- package/android-template/app/src/main/java/com/pakstr/app/Nip55Callback.kt +3 -0
- package/android-template/app/src/main/java/com/pakstr/app/Nip55SignEventRequest.kt +303 -0
- package/android-template/app/src/main/java/com/pakstr/app/PakstrBridge.kt +14 -2
- package/android-template/app/src/main/java/com/pakstr/app/RuntimeApiBaseSettings.kt +98 -0
- package/android-template/app/src/main/java/com/pakstr/app/RuntimeApiBaseValidator.kt +30 -0
- package/android-template/app/src/main/java/com/pakstr/app/RuntimeConfig.kt +7 -12
- package/android-template/app/src/main/java/com/pakstr/app/WebViewController.kt +67 -17
- package/android-template/app/src/main/java/com/pakstr/app/debug/DeveloperToolsActivity.kt +70 -4
- package/android-template/app/src/main/res/layout/activity_developer_tools.xml +257 -66
- package/android-template/app/src/main/res/values/strings.xml +24 -5
- package/android-template/app/src/test/java/com/pakstr/app/Nip55SignEventDispatchTest.kt +115 -0
- package/android-template/app/src/test/java/com/pakstr/app/Nip55SignEventRequestTest.kt +90 -0
- package/android-template/app/src/test/java/com/pakstr/app/PakstrBridgeRuntimeApiBaseTest.kt +52 -0
- package/android-template/app/src/test/java/com/pakstr/app/RuntimeApiBaseProxyTest.kt +49 -0
- package/android-template/app/src/test/java/com/pakstr/app/RuntimeApiBaseSettingsTest.kt +107 -0
- package/android-template/app/src/test/java/com/pakstr/app/RuntimeApiBaseValidatorTest.kt +50 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -49,7 +49,7 @@ build:
|
|
|
49
49
|
# apiBase: https://api.example.com # Optional packaged /api/* proxy target.
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
-
`runtime.apiBase` must be an absolute HTTPS URL. It is public APK metadata, not a secret.
|
|
52
|
+
`runtime.apiBase` must be an absolute HTTPS URL. It is the immutable packaged/default proxy target and public APK metadata, not a secret. The effective target precedence is persistent runtime override, then packaged default, then disabled. A web app may call `window.PakstrBridge.setApiBaseUrl(url)` to validate and synchronously persist an HTTPS override, and `window.PakstrBridge.getApiBaseUrl()` to read the effective URL. Developer Tools uses the same setting. Reset removes the override and restores the packaged default; without a packaged default it disables `/api/*`. Changes affect the next proxy request without an app restart, LocalServer restart, or WebView reload.
|
|
53
53
|
|
|
54
54
|
### 3. Build, sign, and publish
|
|
55
55
|
```bash
|
|
@@ -10,7 +10,7 @@ class Nip55CallbackTest {
|
|
|
10
10
|
|
|
11
11
|
@Test
|
|
12
12
|
fun accepts_fragment_callback_and_preserves_logical_event() {
|
|
13
|
-
val event = "{\"kind\":27235,\"content\":\"hello & goodbye / 100%\"}"
|
|
13
|
+
val event = "{\"id\":\"${"a".repeat(64)}\",\"pubkey\":\"${"b".repeat(64)}\",\"created_at\":1789552517,\"kind\":27235,\"tags\":[],\"content\":\"hello & goodbye / 100%\",\"sig\":\"${"c".repeat(128)}\"}"
|
|
14
14
|
val intent = callbackIntent(
|
|
15
15
|
"${Nip55Callback.PARAM}=${Uri.encode(event)}"
|
|
16
16
|
)
|
|
@@ -80,6 +80,11 @@ internal fun localRequestUrl(
|
|
|
80
80
|
if (query.isNullOrEmpty()) "" else "?$query"
|
|
81
81
|
)
|
|
82
82
|
|
|
83
|
+
internal fun proxyApiUrl(
|
|
84
|
+
apiBase: String,
|
|
85
|
+
uri: String
|
|
86
|
+
): URL = URL(apiBase + uri)
|
|
87
|
+
|
|
83
88
|
internal data class ProxyHttpResponse(
|
|
84
89
|
val code: Int,
|
|
85
90
|
val contentType: String?,
|
|
@@ -258,7 +263,8 @@ class LocalServer(
|
|
|
258
263
|
)
|
|
259
264
|
if (uri.startsWith("/api/")) {
|
|
260
265
|
|
|
261
|
-
|
|
266
|
+
val apiBase = RuntimeConfig.apiBase(context)
|
|
267
|
+
if (apiBase == null) {
|
|
262
268
|
|
|
263
269
|
AppDebugLogger.network(
|
|
264
270
|
|
|
@@ -283,7 +289,7 @@ class LocalServer(
|
|
|
283
289
|
)
|
|
284
290
|
}
|
|
285
291
|
|
|
286
|
-
return proxyApi(session, uri)
|
|
292
|
+
return proxyApi(session, uri, apiBase)
|
|
287
293
|
|
|
288
294
|
}
|
|
289
295
|
|
|
@@ -370,7 +376,7 @@ class LocalServer(
|
|
|
370
376
|
}
|
|
371
377
|
|
|
372
378
|
private fun proxyApi(
|
|
373
|
-
session: IHTTPSession?, uri: String
|
|
379
|
+
session: IHTTPSession?, uri: String, apiBase: String
|
|
374
380
|
): Response {
|
|
375
381
|
|
|
376
382
|
if (AppDebugLogger.isNetworkLoggingEnabled(context)) {
|
|
@@ -386,29 +392,7 @@ class LocalServer(
|
|
|
386
392
|
|
|
387
393
|
return try {
|
|
388
394
|
|
|
389
|
-
val
|
|
390
|
-
|
|
391
|
-
val apiBase = config.optString("apiBase")
|
|
392
|
-
|
|
393
|
-
if (apiBase.isBlank()) {
|
|
394
|
-
AppDebugLogger.error(
|
|
395
|
-
|
|
396
|
-
context,
|
|
397
|
-
|
|
398
|
-
"API",
|
|
399
|
-
|
|
400
|
-
"apiBase missing"
|
|
401
|
-
|
|
402
|
-
)
|
|
403
|
-
return newFixedLengthResponse(
|
|
404
|
-
Response.Status.INTERNAL_ERROR,
|
|
405
|
-
"application/json",
|
|
406
|
-
"""{"error":"apiBase missing"}"""
|
|
407
|
-
)
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
val finalUrl = apiBase.removeSuffix("/") + uri
|
|
411
|
-
val url = URL(finalUrl)
|
|
395
|
+
val url = proxyApiUrl(apiBase, uri)
|
|
412
396
|
val method = session?.method?.name ?: "GET"
|
|
413
397
|
val requestHeaders = session?.headers ?: emptyMap()
|
|
414
398
|
val localRequestUrl = localRequestUrl(
|
|
@@ -438,7 +422,7 @@ class LocalServer(
|
|
|
438
422
|
if (AppDebugLogger.isNetworkLoggingEnabled(context)) {
|
|
439
423
|
AppDebugLogger.network(
|
|
440
424
|
context,
|
|
441
|
-
"
|
|
425
|
+
"Proxy response: ${response.code}"
|
|
442
426
|
)
|
|
443
427
|
}
|
|
444
428
|
return newFixedLengthResponse(
|
|
@@ -498,7 +482,7 @@ class LocalServer(
|
|
|
498
482
|
AppDebugLogger.network(
|
|
499
483
|
|
|
500
484
|
context,
|
|
501
|
-
"
|
|
485
|
+
"Proxy response: $code"
|
|
502
486
|
|
|
503
487
|
)
|
|
504
488
|
|
|
@@ -10,6 +10,9 @@ internal object Nip55Callback {
|
|
|
10
10
|
private const val FRAGMENT_PREFIX = "$PARAM="
|
|
11
11
|
private const val MAX_RESULT_LENGTH = 128 * 1024
|
|
12
12
|
|
|
13
|
+
val callbackPrefix: String
|
|
14
|
+
get() = "${BuildConfig.PAKSTR_NIP55_CALLBACK_SCHEME}://$HOST#$FRAGMENT_PREFIX"
|
|
15
|
+
|
|
13
16
|
fun result(intent: Intent?): String? {
|
|
14
17
|
if (intent?.action != Intent.ACTION_VIEW) {
|
|
15
18
|
return null
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
package com.pakstr.app
|
|
2
|
+
|
|
3
|
+
import com.pakstr.app.crypto.NostrEventHasher
|
|
4
|
+
import com.pakstr.app.signer.SignerType
|
|
5
|
+
import org.json.JSONArray
|
|
6
|
+
import org.json.JSONObject
|
|
7
|
+
import java.io.ByteArrayOutputStream
|
|
8
|
+
import java.nio.ByteBuffer
|
|
9
|
+
import java.nio.charset.CodingErrorAction
|
|
10
|
+
|
|
11
|
+
internal sealed interface Nip55SignEventRequest {
|
|
12
|
+
data object External : Nip55SignEventRequest
|
|
13
|
+
|
|
14
|
+
data class Rejected(
|
|
15
|
+
val reason: String
|
|
16
|
+
) : Nip55SignEventRequest
|
|
17
|
+
|
|
18
|
+
data class Parsed(
|
|
19
|
+
val event: JSONObject,
|
|
20
|
+
val callbackUrl: String
|
|
21
|
+
) : Nip55SignEventRequest
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
internal sealed interface Nip55NormalizedEvent {
|
|
25
|
+
data class Rejected(
|
|
26
|
+
val reason: String
|
|
27
|
+
) : Nip55NormalizedEvent
|
|
28
|
+
|
|
29
|
+
data class Ready(
|
|
30
|
+
val eventJson: String
|
|
31
|
+
) : Nip55NormalizedEvent
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
internal object Nip55SignEventParser {
|
|
35
|
+
private const val SCHEME_PREFIX = "nostrsigner:"
|
|
36
|
+
private const val MAX_REQUEST_BYTES = 128 * 1024
|
|
37
|
+
private val RELEVANT_PARAMETERS = setOf(
|
|
38
|
+
"type",
|
|
39
|
+
"returnType",
|
|
40
|
+
"compressionType",
|
|
41
|
+
"callbackUrl"
|
|
42
|
+
)
|
|
43
|
+
private val EVENT_FIELDS = setOf(
|
|
44
|
+
"kind",
|
|
45
|
+
"created_at",
|
|
46
|
+
"tags",
|
|
47
|
+
"content",
|
|
48
|
+
"pubkey",
|
|
49
|
+
"id",
|
|
50
|
+
"sig"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
fun parse(
|
|
54
|
+
rawUrl: String,
|
|
55
|
+
expectedCallbackUrl: String
|
|
56
|
+
): Nip55SignEventRequest {
|
|
57
|
+
if (rawUrl.toByteArray(Charsets.UTF_8).size > MAX_REQUEST_BYTES) {
|
|
58
|
+
return rejected("request too large")
|
|
59
|
+
}
|
|
60
|
+
if (!rawUrl.startsWith(SCHEME_PREFIX, ignoreCase = true)) {
|
|
61
|
+
return Nip55SignEventRequest.External
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
val schemeSpecificPart = rawUrl.substring(SCHEME_PREFIX.length)
|
|
65
|
+
if ('#' in schemeSpecificPart) {
|
|
66
|
+
return rejected("malformed request")
|
|
67
|
+
}
|
|
68
|
+
val separator = schemeSpecificPart.indexOf('?')
|
|
69
|
+
val encodedEvent = if (separator >= 0) {
|
|
70
|
+
schemeSpecificPart.substring(0, separator)
|
|
71
|
+
} else {
|
|
72
|
+
schemeSpecificPart
|
|
73
|
+
}
|
|
74
|
+
val rawQuery = if (separator >= 0) {
|
|
75
|
+
schemeSpecificPart.substring(separator + 1)
|
|
76
|
+
} else {
|
|
77
|
+
""
|
|
78
|
+
}
|
|
79
|
+
if (encodedEvent.isEmpty()) {
|
|
80
|
+
return rejected("missing event")
|
|
81
|
+
}
|
|
82
|
+
if (!hasOpaquePayloadEncoding(encodedEvent)) {
|
|
83
|
+
return rejected("malformed event encoding")
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
val parameters = parseParameters(rawQuery)
|
|
87
|
+
?: return rejected("malformed parameters")
|
|
88
|
+
val type = parameters["type"]
|
|
89
|
+
if (type != "sign_event") {
|
|
90
|
+
return Nip55SignEventRequest.External
|
|
91
|
+
}
|
|
92
|
+
if (parameters["returnType"] != "event") {
|
|
93
|
+
return rejected("unsupported return type")
|
|
94
|
+
}
|
|
95
|
+
val compressionType = parameters["compressionType"]
|
|
96
|
+
if (compressionType != null && compressionType != "none") {
|
|
97
|
+
return rejected("unsupported compression")
|
|
98
|
+
}
|
|
99
|
+
val callbackUrl = parameters["callbackUrl"]
|
|
100
|
+
?: return rejected("missing callback")
|
|
101
|
+
if (callbackUrl != expectedCallbackUrl) {
|
|
102
|
+
return rejected("unexpected callback")
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
val eventText = percentDecode(encodedEvent)
|
|
106
|
+
?: return rejected("malformed event encoding")
|
|
107
|
+
val event = try {
|
|
108
|
+
JSONObject(eventText)
|
|
109
|
+
} catch (_: Exception) {
|
|
110
|
+
return rejected("invalid event")
|
|
111
|
+
}
|
|
112
|
+
if (event.keys().asSequence().any { it !in EVENT_FIELDS }) {
|
|
113
|
+
return rejected("unsupported event field")
|
|
114
|
+
}
|
|
115
|
+
if (!hasIntegral(event, "kind") ||
|
|
116
|
+
event.getLong("kind") !in 0L..65535L ||
|
|
117
|
+
!hasIntegral(event, "created_at") ||
|
|
118
|
+
event.optJSONArray("tags") == null ||
|
|
119
|
+
!hasString(event, "content") ||
|
|
120
|
+
!hasValidTags(event.getJSONArray("tags"))
|
|
121
|
+
) {
|
|
122
|
+
return rejected("invalid event fields")
|
|
123
|
+
}
|
|
124
|
+
if (event.has("sig") &&
|
|
125
|
+
(!hasString(event, "sig") || event.getString("sig").isNotEmpty())
|
|
126
|
+
) {
|
|
127
|
+
return rejected("event already signed")
|
|
128
|
+
}
|
|
129
|
+
if (event.has("pubkey") && !hasString(event, "pubkey")) {
|
|
130
|
+
return rejected("invalid pubkey")
|
|
131
|
+
}
|
|
132
|
+
if (event.has("id") && !hasString(event, "id")) {
|
|
133
|
+
return rejected("invalid event id")
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return Nip55SignEventRequest.Parsed(event, callbackUrl)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
fun normalize(
|
|
140
|
+
request: Nip55SignEventRequest.Parsed,
|
|
141
|
+
activePubkey: String
|
|
142
|
+
): Nip55NormalizedEvent {
|
|
143
|
+
if (activePubkey.isBlank()) {
|
|
144
|
+
return normalizedRejected("signer unavailable")
|
|
145
|
+
}
|
|
146
|
+
val event = JSONObject(request.event.toString())
|
|
147
|
+
if (event.has("pubkey") && event.getString("pubkey") != activePubkey) {
|
|
148
|
+
return normalizedRejected("pubkey mismatch")
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (event.has("id")) {
|
|
152
|
+
val expectedId = event.getString("id")
|
|
153
|
+
event.put("pubkey", activePubkey)
|
|
154
|
+
val actualId = try {
|
|
155
|
+
NostrEventHasher.hashEvent(event)
|
|
156
|
+
} catch (_: Exception) {
|
|
157
|
+
return normalizedRejected("invalid event id")
|
|
158
|
+
}
|
|
159
|
+
if (actualId != expectedId) {
|
|
160
|
+
return normalizedRejected("event id mismatch")
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return Nip55NormalizedEvent.Ready(
|
|
165
|
+
JSONObject()
|
|
166
|
+
.put("kind", event.getInt("kind"))
|
|
167
|
+
.put("created_at", event.getLong("created_at"))
|
|
168
|
+
.put("tags", event.getJSONArray("tags"))
|
|
169
|
+
.put("content", event.getString("content"))
|
|
170
|
+
.toString()
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private fun hasOpaquePayloadEncoding(value: String): Boolean {
|
|
175
|
+
var index = 0
|
|
176
|
+
while (index < value.length) {
|
|
177
|
+
if (value[index] == '%') {
|
|
178
|
+
if (index + 2 >= value.length ||
|
|
179
|
+
value[index + 1].digitToIntOrNull(16) == null ||
|
|
180
|
+
value[index + 2].digitToIntOrNull(16) == null
|
|
181
|
+
) {
|
|
182
|
+
return false
|
|
183
|
+
}
|
|
184
|
+
index += 3
|
|
185
|
+
} else {
|
|
186
|
+
val character = value[index]
|
|
187
|
+
if (!character.isLetterOrDigit() && character !in "-._~") {
|
|
188
|
+
return false
|
|
189
|
+
}
|
|
190
|
+
index++
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return true
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private fun parseParameters(rawQuery: String): Map<String, String>? {
|
|
197
|
+
val parameters = mutableMapOf<String, String>()
|
|
198
|
+
if (rawQuery.isEmpty()) {
|
|
199
|
+
return parameters
|
|
200
|
+
}
|
|
201
|
+
for (part in rawQuery.split('&')) {
|
|
202
|
+
val separator = part.indexOf('=')
|
|
203
|
+
if (separator < 0) {
|
|
204
|
+
return null
|
|
205
|
+
}
|
|
206
|
+
val name = percentDecode(part.substring(0, separator)) ?: return null
|
|
207
|
+
val value = percentDecode(part.substring(separator + 1)) ?: return null
|
|
208
|
+
if (name in RELEVANT_PARAMETERS && parameters.containsKey(name)) {
|
|
209
|
+
return null
|
|
210
|
+
}
|
|
211
|
+
parameters[name] = value
|
|
212
|
+
}
|
|
213
|
+
return parameters
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private fun percentDecode(value: String): String? {
|
|
217
|
+
val bytes = ByteArrayOutputStream(value.length)
|
|
218
|
+
var index = 0
|
|
219
|
+
while (index < value.length) {
|
|
220
|
+
val character = value[index]
|
|
221
|
+
if (character == '%') {
|
|
222
|
+
if (index + 2 >= value.length) return null
|
|
223
|
+
val high = value[index + 1].digitToIntOrNull(16) ?: return null
|
|
224
|
+
val low = value[index + 2].digitToIntOrNull(16) ?: return null
|
|
225
|
+
bytes.write((high shl 4) or low)
|
|
226
|
+
index += 3
|
|
227
|
+
} else {
|
|
228
|
+
val codePoint = value.codePointAt(index)
|
|
229
|
+
bytes.write(String(Character.toChars(codePoint)).toByteArray(Charsets.UTF_8))
|
|
230
|
+
index += Character.charCount(codePoint)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return try {
|
|
234
|
+
Charsets.UTF_8.newDecoder()
|
|
235
|
+
.onMalformedInput(CodingErrorAction.REPORT)
|
|
236
|
+
.onUnmappableCharacter(CodingErrorAction.REPORT)
|
|
237
|
+
.decode(ByteBuffer.wrap(bytes.toByteArray()))
|
|
238
|
+
.toString()
|
|
239
|
+
} catch (_: Exception) {
|
|
240
|
+
null
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private fun hasIntegral(event: JSONObject, name: String): Boolean {
|
|
245
|
+
if (!event.has(name) || event.isNull(name)) return false
|
|
246
|
+
return event.get(name) is Byte ||
|
|
247
|
+
event.get(name) is Short ||
|
|
248
|
+
event.get(name) is Int ||
|
|
249
|
+
event.get(name) is Long
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private fun hasString(event: JSONObject, name: String): Boolean {
|
|
253
|
+
return event.has(name) && !event.isNull(name) && event.get(name) is String
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private fun hasValidTags(tags: JSONArray): Boolean {
|
|
257
|
+
for (tagIndex in 0 until tags.length()) {
|
|
258
|
+
val tag = tags.optJSONArray(tagIndex) ?: return false
|
|
259
|
+
if (tag.length() == 0) return false
|
|
260
|
+
for (valueIndex in 0 until tag.length()) {
|
|
261
|
+
if (tag.get(valueIndex) !is String) return false
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return true
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private fun rejected(reason: String) = Nip55SignEventRequest.Rejected(reason)
|
|
268
|
+
|
|
269
|
+
private fun normalizedRejected(reason: String) = Nip55NormalizedEvent.Rejected(reason)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
internal fun dispatchNip55SignEvent(
|
|
273
|
+
rawUrl: String,
|
|
274
|
+
activeSignerType: SignerType?,
|
|
275
|
+
expectedCallbackUrl: String,
|
|
276
|
+
forwardExternally: () -> Unit,
|
|
277
|
+
launchSigning: (suspend () -> Unit) -> Unit,
|
|
278
|
+
getActivePubkey: suspend () -> String,
|
|
279
|
+
signEvent: suspend (String) -> String,
|
|
280
|
+
deliverSignedEvent: suspend (String) -> Unit,
|
|
281
|
+
logRejection: (String) -> Unit
|
|
282
|
+
) {
|
|
283
|
+
if (activeSignerType != SignerType.BUNKER) {
|
|
284
|
+
forwardExternally()
|
|
285
|
+
return
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
when (val request = Nip55SignEventParser.parse(rawUrl, expectedCallbackUrl)) {
|
|
289
|
+
Nip55SignEventRequest.External -> forwardExternally()
|
|
290
|
+
is Nip55SignEventRequest.Rejected -> logRejection(request.reason)
|
|
291
|
+
is Nip55SignEventRequest.Parsed -> launchSigning {
|
|
292
|
+
when (val normalized = Nip55SignEventParser.normalize(
|
|
293
|
+
request,
|
|
294
|
+
getActivePubkey()
|
|
295
|
+
)) {
|
|
296
|
+
is Nip55NormalizedEvent.Rejected -> logRejection(normalized.reason)
|
|
297
|
+
is Nip55NormalizedEvent.Ready -> deliverSignedEvent(
|
|
298
|
+
signEvent(normalized.eventJson)
|
|
299
|
+
)
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
@@ -2,10 +2,22 @@ package com.pakstr.app
|
|
|
2
2
|
|
|
3
3
|
import android.webkit.JavascriptInterface
|
|
4
4
|
|
|
5
|
-
class PakstrBridge
|
|
5
|
+
class PakstrBridge(
|
|
6
|
+
private val runtimeApiBaseSettings: RuntimeApiBaseSettings
|
|
7
|
+
) {
|
|
6
8
|
|
|
7
9
|
@JavascriptInterface
|
|
8
10
|
fun getNip55CallbackUrl(): String {
|
|
9
|
-
return
|
|
11
|
+
return Nip55Callback.callbackPrefix
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
@JavascriptInterface
|
|
15
|
+
fun setApiBaseUrl(url: String): Boolean {
|
|
16
|
+
return runtimeApiBaseSettings.setOverride(url)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
@JavascriptInterface
|
|
20
|
+
fun getApiBaseUrl(): String? {
|
|
21
|
+
return runtimeApiBaseSettings.current().url
|
|
10
22
|
}
|
|
11
23
|
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
package com.pakstr.app
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.content.SharedPreferences
|
|
5
|
+
|
|
6
|
+
class RuntimeApiBaseSettings private constructor(
|
|
7
|
+
private val preferences: Preferences,
|
|
8
|
+
private val packagedValueProvider: () -> String?
|
|
9
|
+
) {
|
|
10
|
+
enum class Source {
|
|
11
|
+
RUNTIME_OVERRIDE,
|
|
12
|
+
PACKAGED_DEFAULT,
|
|
13
|
+
DISABLED
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
data class Value(
|
|
17
|
+
val url: String?,
|
|
18
|
+
val source: Source
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
fun current(): Value = resolveEffectiveValue(
|
|
22
|
+
persistedOverride = preferences.getString(API_BASE_OVERRIDE_KEY),
|
|
23
|
+
packagedDefault = packagedValueProvider()
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
fun setOverride(value: String): Boolean {
|
|
27
|
+
val canonical = RuntimeApiBaseValidator.validateAndCanonicalize(value)
|
|
28
|
+
?: return false
|
|
29
|
+
return preferences.putString(API_BASE_OVERRIDE_KEY, canonical)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
fun reset(): Boolean = preferences.remove(API_BASE_OVERRIDE_KEY)
|
|
33
|
+
|
|
34
|
+
internal interface Preferences {
|
|
35
|
+
fun getString(key: String): String?
|
|
36
|
+
fun putString(key: String, value: String): Boolean
|
|
37
|
+
fun remove(key: String): Boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private class SharedPreferencesAdapter(
|
|
41
|
+
private val preferences: SharedPreferences
|
|
42
|
+
) : Preferences {
|
|
43
|
+
override fun getString(key: String): String? = preferences.getString(key, null)
|
|
44
|
+
|
|
45
|
+
override fun putString(key: String, value: String): Boolean =
|
|
46
|
+
preferences.edit().putString(key, value).commit()
|
|
47
|
+
|
|
48
|
+
override fun remove(key: String): Boolean =
|
|
49
|
+
preferences.edit().remove(key).commit()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
companion object {
|
|
53
|
+
internal const val PREFERENCES_FILE = "pakstr_runtime_api_base"
|
|
54
|
+
internal const val API_BASE_OVERRIDE_KEY = "api_base_override"
|
|
55
|
+
|
|
56
|
+
fun from(context: Context): RuntimeApiBaseSettings {
|
|
57
|
+
val applicationContext = context.applicationContext
|
|
58
|
+
val preferences = applicationContext.getSharedPreferences(
|
|
59
|
+
PREFERENCES_FILE,
|
|
60
|
+
Context.MODE_PRIVATE
|
|
61
|
+
)
|
|
62
|
+
return RuntimeApiBaseSettings(
|
|
63
|
+
SharedPreferencesAdapter(preferences)
|
|
64
|
+
) {
|
|
65
|
+
RuntimeConfig.packagedApiBase(applicationContext)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
internal fun resolveEffectiveValue(
|
|
70
|
+
persistedOverride: String?,
|
|
71
|
+
packagedDefault: String?
|
|
72
|
+
): Value {
|
|
73
|
+
val override = RuntimeApiBaseValidator.validateAndCanonicalize(
|
|
74
|
+
persistedOverride
|
|
75
|
+
)
|
|
76
|
+
if (override != null) {
|
|
77
|
+
return Value(override, Source.RUNTIME_OVERRIDE)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
val packaged = RuntimeApiBaseValidator.validateAndCanonicalize(
|
|
81
|
+
packagedDefault
|
|
82
|
+
)
|
|
83
|
+
return if (packaged != null) {
|
|
84
|
+
Value(packaged, Source.PACKAGED_DEFAULT)
|
|
85
|
+
} else {
|
|
86
|
+
Value(null, Source.DISABLED)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
internal fun createForTesting(
|
|
91
|
+
preferences: Preferences,
|
|
92
|
+
packagedValueProvider: () -> String?
|
|
93
|
+
): RuntimeApiBaseSettings = RuntimeApiBaseSettings(
|
|
94
|
+
preferences,
|
|
95
|
+
packagedValueProvider
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
package com.pakstr.app
|
|
2
|
+
|
|
3
|
+
import java.net.URI
|
|
4
|
+
|
|
5
|
+
object RuntimeApiBaseValidator {
|
|
6
|
+
const val MAX_LENGTH = 2048
|
|
7
|
+
|
|
8
|
+
fun validateAndCanonicalize(value: String?): String? {
|
|
9
|
+
if (
|
|
10
|
+
value == null ||
|
|
11
|
+
value.isEmpty() ||
|
|
12
|
+
value.length > MAX_LENGTH ||
|
|
13
|
+
value != value.trim()
|
|
14
|
+
) {
|
|
15
|
+
return null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
val uri = runCatching { URI(value) }.getOrNull() ?: return null
|
|
19
|
+
if (!uri.isAbsolute || !uri.scheme.equals("https", ignoreCase = true)) return null
|
|
20
|
+
if (uri.host.isNullOrBlank()) return null
|
|
21
|
+
if (uri.rawUserInfo != null || uri.rawQuery != null || uri.rawFragment != null) return null
|
|
22
|
+
|
|
23
|
+
val host = uri.host.lowercase().let {
|
|
24
|
+
if (it.contains(':') && !it.startsWith("[")) "[$it]" else it
|
|
25
|
+
}
|
|
26
|
+
val port = if (uri.port == -1) "" else ":${uri.port}"
|
|
27
|
+
val path = uri.rawPath.orEmpty().trimEnd('/')
|
|
28
|
+
return "https://$host$port$path"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -58,19 +58,14 @@ object RuntimeConfig {
|
|
|
58
58
|
*/
|
|
59
59
|
fun apiBase(
|
|
60
60
|
context: Context
|
|
61
|
-
): String?
|
|
61
|
+
): String? = RuntimeApiBaseSettings.from(context).current().url
|
|
62
62
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
return value.takeIf {
|
|
72
|
-
it.isNotBlank()
|
|
73
|
-
}
|
|
63
|
+
internal fun packagedApiBase(
|
|
64
|
+
context: Context
|
|
65
|
+
): String? = load(context).optString(
|
|
66
|
+
"apiBase", ""
|
|
67
|
+
).takeIf {
|
|
68
|
+
it.isNotBlank()
|
|
74
69
|
}
|
|
75
70
|
|
|
76
71
|
/**
|