pakstr 0.22.0 → 0.24.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 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
@@ -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
 
@@ -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
11
  return Nip55Callback.callbackPrefix
10
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
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
  /**
@@ -339,7 +339,7 @@ class WebViewController(
339
339
 
340
340
  )
341
341
  webView.addJavascriptInterface(
342
- PakstrBridge(),
342
+ PakstrBridge(RuntimeApiBaseSettings.from(context)),
343
343
  "PakstrBridge"
344
344
  )
345
345
 
@@ -15,13 +15,18 @@ import android.widget.TextView
15
15
  import android.widget.Toast
16
16
  import androidx.activity.enableEdgeToEdge
17
17
  import androidx.appcompat.app.AppCompatActivity
18
+ import androidx.core.view.ViewCompat
19
+ import androidx.core.view.WindowInsetsCompat
20
+ import androidx.core.view.updatePadding
18
21
  import com.google.android.material.appbar.MaterialToolbar
19
22
  import com.google.android.material.button.MaterialButton
20
23
  import com.google.android.material.dialog.MaterialAlertDialogBuilder
21
24
  import com.google.android.material.switchmaterial.SwitchMaterial
25
+ import com.google.android.material.textfield.TextInputEditText
26
+ import com.google.android.material.textfield.TextInputLayout
22
27
  import com.pakstr.app.BuildConfig
23
28
  import com.pakstr.app.R
24
- import com.pakstr.app.RuntimeConfig
29
+ import com.pakstr.app.RuntimeApiBaseSettings
25
30
 
26
31
  class DeveloperToolsActivity : AppCompatActivity() {
27
32
 
@@ -30,8 +35,12 @@ class DeveloperToolsActivity : AppCompatActivity() {
30
35
  private lateinit var uptimeValue: TextView
31
36
  private lateinit var debugModeSwitch: SwitchMaterial
32
37
  private lateinit var apiSwitch: SwitchMaterial
38
+ private lateinit var apiBaseInputLayout: TextInputLayout
39
+ private lateinit var apiBaseInput: TextInputEditText
40
+ private lateinit var apiBaseSource: TextView
33
41
  private lateinit var debugWebViewStatus: TextView
34
42
  private lateinit var webViewStatus: TextView
43
+ private lateinit var runtimeApiBaseSettings: RuntimeApiBaseSettings
35
44
 
36
45
  private val uptimeUpdater = object : Runnable {
37
46
  override fun run() {
@@ -50,11 +59,14 @@ class DeveloperToolsActivity : AppCompatActivity() {
50
59
  }
51
60
 
52
61
  setContentView(R.layout.activity_developer_tools)
62
+ runtimeApiBaseSettings = RuntimeApiBaseSettings.from(this)
53
63
 
64
+ setupWindowInsets()
54
65
  setupToolbar()
55
66
  bindViews()
56
67
  populateRuntimeDetails()
57
68
  setupDebugControls()
69
+ setupApiBaseControls()
58
70
  setupLogActions()
59
71
  setupCrashTesting()
60
72
  setupWebViewActions()
@@ -71,6 +83,18 @@ class DeveloperToolsActivity : AppCompatActivity() {
71
83
  super.onStop()
72
84
  }
73
85
 
86
+ private fun setupWindowInsets() {
87
+ ViewCompat.setOnApplyWindowInsetsListener(
88
+ findViewById(R.id.developerToolsRoot)
89
+ ) { view, insets ->
90
+ val bars = insets.getInsets(
91
+ WindowInsetsCompat.Type.systemBars()
92
+ )
93
+ view.updatePadding(top = bars.top)
94
+ insets
95
+ }
96
+ }
97
+
74
98
  private fun setupToolbar() {
75
99
  findViewById<MaterialToolbar>(R.id.developerToolsToolbar).setNavigationOnClickListener {
76
100
  finish()
@@ -81,6 +105,9 @@ class DeveloperToolsActivity : AppCompatActivity() {
81
105
  uptimeValue = findViewById(R.id.runtimeUptimeValue)
82
106
  debugModeSwitch = findViewById(R.id.debugModeSwitch)
83
107
  apiSwitch = findViewById(R.id.apiEnabledSwitch)
108
+ apiBaseInputLayout = findViewById(R.id.apiBaseInputLayout)
109
+ apiBaseInput = findViewById(R.id.apiBaseInput)
110
+ apiBaseSource = findViewById(R.id.apiBaseSource)
84
111
  debugWebViewStatus = findViewById(R.id.debugWebViewStatus)
85
112
  webViewStatus = findViewById(R.id.webViewStatus)
86
113
  }
@@ -101,8 +128,6 @@ class DeveloperToolsActivity : AppCompatActivity() {
101
128
  }
102
129
 
103
130
  private fun setupDebugControls() {
104
- apiSwitch.isChecked = RuntimeConfig.apiEnabled(this)
105
-
106
131
  debugModeSwitch.setOnCheckedChangeListener { _, isChecked ->
107
132
  if (isChecked) {
108
133
  DeveloperToolsManager.enableDebug(this)
@@ -115,9 +140,50 @@ class DeveloperToolsActivity : AppCompatActivity() {
115
140
  }
116
141
  }
117
142
 
143
+ private fun setupApiBaseControls() {
144
+ findViewById<MaterialButton>(R.id.saveApiBaseButton).setOnClickListener {
145
+ val value = apiBaseInput.text?.toString().orEmpty()
146
+ if (runtimeApiBaseSettings.setOverride(value)) {
147
+ apiBaseInputLayout.error = null
148
+ refreshApiBase()
149
+ showMessage(R.string.developer_tools_api_base_saved)
150
+ } else {
151
+ apiBaseInputLayout.error = getString(
152
+ R.string.developer_tools_api_base_invalid
153
+ )
154
+ }
155
+ }
156
+ findViewById<MaterialButton>(R.id.resetApiBaseButton).setOnClickListener {
157
+ if (runtimeApiBaseSettings.reset()) {
158
+ apiBaseInputLayout.error = null
159
+ refreshApiBase()
160
+ showMessage(R.string.developer_tools_api_base_reset)
161
+ } else {
162
+ showMessage(R.string.developer_tools_api_base_save_failed)
163
+ }
164
+ }
165
+ refreshApiBase()
166
+ }
167
+
168
+ private fun refreshApiBase() {
169
+ val current = runtimeApiBaseSettings.current()
170
+ apiSwitch.isChecked = current.url != null
171
+ apiBaseInput.setText(current.url.orEmpty())
172
+ apiBaseSource.setText(
173
+ when (current.source) {
174
+ RuntimeApiBaseSettings.Source.RUNTIME_OVERRIDE ->
175
+ R.string.developer_tools_api_base_runtime_override
176
+ RuntimeApiBaseSettings.Source.PACKAGED_DEFAULT ->
177
+ R.string.developer_tools_api_base_packaged_default
178
+ RuntimeApiBaseSettings.Source.DISABLED ->
179
+ R.string.developer_tools_api_base_not_configured
180
+ }
181
+ )
182
+ }
183
+
118
184
  private fun refreshStatuses() {
119
185
  debugModeSwitch.isChecked = DeveloperToolsManager.isEnabled(this)
120
- apiSwitch.isChecked = RuntimeConfig.apiEnabled(this)
186
+ refreshApiBase()
121
187
 
122
188
  val status = if (isWebViewDebugEnabled()) {
123
189
  R.string.developer_tools_enabled