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.
Files changed (20) hide show
  1. package/README.md +1 -1
  2. package/android-template/app/src/androidTest/java/com/pakstr/app/Nip55CallbackTest.kt +1 -1
  3. package/android-template/app/src/main/java/com/pakstr/app/LocalServer.kt +12 -28
  4. package/android-template/app/src/main/java/com/pakstr/app/Nip55Callback.kt +3 -0
  5. package/android-template/app/src/main/java/com/pakstr/app/Nip55SignEventRequest.kt +303 -0
  6. package/android-template/app/src/main/java/com/pakstr/app/PakstrBridge.kt +14 -2
  7. package/android-template/app/src/main/java/com/pakstr/app/RuntimeApiBaseSettings.kt +98 -0
  8. package/android-template/app/src/main/java/com/pakstr/app/RuntimeApiBaseValidator.kt +30 -0
  9. package/android-template/app/src/main/java/com/pakstr/app/RuntimeConfig.kt +7 -12
  10. package/android-template/app/src/main/java/com/pakstr/app/WebViewController.kt +67 -17
  11. package/android-template/app/src/main/java/com/pakstr/app/debug/DeveloperToolsActivity.kt +70 -4
  12. package/android-template/app/src/main/res/layout/activity_developer_tools.xml +257 -66
  13. package/android-template/app/src/main/res/values/strings.xml +24 -5
  14. package/android-template/app/src/test/java/com/pakstr/app/Nip55SignEventDispatchTest.kt +115 -0
  15. package/android-template/app/src/test/java/com/pakstr/app/Nip55SignEventRequestTest.kt +90 -0
  16. package/android-template/app/src/test/java/com/pakstr/app/PakstrBridgeRuntimeApiBaseTest.kt +52 -0
  17. package/android-template/app/src/test/java/com/pakstr/app/RuntimeApiBaseProxyTest.kt +49 -0
  18. package/android-template/app/src/test/java/com/pakstr/app/RuntimeApiBaseSettingsTest.kt +107 -0
  19. package/android-template/app/src/test/java/com/pakstr/app/RuntimeApiBaseValidatorTest.kt +50 -0
  20. 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. When omitted, Pakstr generates an empty runtime config and keeps `/api/*` disabled; when set, `/api/x` forwards to `<apiBase>/api/x`.
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
- if (!RuntimeConfig.apiEnabled(context)) {
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 config = RuntimeConfig.load(context)
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
- "Response: ${response.code} $finalUrl"
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
- "Response: $code $finalUrl"
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 "${BuildConfig.PAKSTR_NIP55_CALLBACK_SCHEME}://nip55-callback#nip55_event="
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
- val value = load(context).optString(
64
- "apiBase", ""
65
- )
66
-
67
-
68
- debug("apiBase=$value")
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
  /**