pakstr 0.22.0 → 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 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
@@ -1,18 +1,18 @@
1
1
  <?xml version="1.0" encoding="utf-8"?>
2
2
  <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
3
3
  xmlns:app="http://schemas.android.com/apk/res-auto"
4
+ android:id="@+id/developerToolsRoot"
4
5
  android:layout_width="match_parent"
5
6
  android:layout_height="match_parent"
6
- android:paddingBottom="56dp"
7
7
  android:background="?android:attr/colorBackground"
8
- android:orientation="vertical">
8
+ android:orientation="vertical"
9
+ android:paddingBottom="56dp">
9
10
 
10
11
  <com.google.android.material.appbar.MaterialToolbar
11
12
  android:id="@+id/developerToolsToolbar"
12
13
  android:layout_width="match_parent"
13
14
  android:layout_height="?attr/actionBarSize"
14
15
  android:background="?attr/colorSurface"
15
- android:elevation="4dp"
16
16
  app:navigationIcon="?attr/homeAsUpIndicator"
17
17
  app:title="@string/developer_tools_title" />
18
18
 
@@ -20,98 +20,132 @@
20
20
  android:layout_width="match_parent"
21
21
  android:layout_height="0dp"
22
22
  android:layout_weight="1"
23
+ android:clipToPadding="false"
23
24
  android:fillViewport="true">
24
25
 
25
26
  <LinearLayout
26
27
  android:layout_width="match_parent"
27
28
  android:layout_height="wrap_content"
28
29
  android:orientation="vertical"
29
- android:padding="16dp">
30
+ android:paddingStart="16dp"
31
+ android:paddingTop="12dp"
32
+ android:paddingEnd="16dp"
33
+ android:paddingBottom="24dp">
34
+
35
+ <TextView
36
+ style="@style/TextAppearance.MaterialComponents.Body2"
37
+ android:layout_width="match_parent"
38
+ android:layout_height="wrap_content"
39
+ android:layout_marginStart="4dp"
40
+ android:layout_marginEnd="4dp"
41
+ android:layout_marginBottom="20dp"
42
+ android:text="@string/developer_tools_description"
43
+ android:textColor="?android:attr/textColorSecondary" />
30
44
 
31
45
  <com.google.android.material.card.MaterialCardView
32
46
  android:layout_width="match_parent"
33
47
  android:layout_height="wrap_content"
34
48
  android:layout_marginBottom="16dp"
35
- app:cardCornerRadius="12dp"
36
- app:cardElevation="1dp">
49
+ app:cardCornerRadius="16dp"
50
+ app:cardElevation="0dp"
51
+ app:strokeColor="?attr/colorControlNormal"
52
+ app:strokeWidth="1dp">
37
53
 
38
54
  <LinearLayout
39
55
  android:layout_width="match_parent"
40
56
  android:layout_height="wrap_content"
41
57
  android:orientation="vertical"
42
- android:padding="16dp">
58
+ android:padding="20dp">
43
59
 
44
60
  <TextView
45
61
  style="@style/TextAppearance.MaterialComponents.Headline6"
46
62
  android:layout_width="wrap_content"
47
63
  android:layout_height="wrap_content"
48
- android:layout_marginBottom="16dp"
49
64
  android:text="@string/developer_tools_runtime" />
50
65
 
66
+ <TextView
67
+ style="@style/TextAppearance.MaterialComponents.Caption"
68
+ android:layout_width="match_parent"
69
+ android:layout_height="wrap_content"
70
+ android:layout_marginTop="2dp"
71
+ android:layout_marginBottom="20dp"
72
+ android:text="@string/developer_tools_runtime_description"
73
+ android:textColor="?android:attr/textColorSecondary" />
74
+
51
75
  <TextView
52
76
  style="@style/TextAppearance.MaterialComponents.Caption"
53
77
  android:layout_width="wrap_content"
54
78
  android:layout_height="wrap_content"
55
- android:text="@string/developer_tools_session_id" />
79
+ android:text="@string/developer_tools_session_id"
80
+ android:textColor="?android:attr/textColorSecondary" />
56
81
 
57
82
  <TextView
58
83
  android:id="@+id/runtimeSessionIdValue"
59
84
  style="@style/TextAppearance.MaterialComponents.Body2"
60
85
  android:layout_width="match_parent"
61
86
  android:layout_height="wrap_content"
62
- android:layout_marginBottom="12dp"
87
+ android:layout_marginTop="2dp"
88
+ android:layout_marginBottom="14dp"
63
89
  android:textIsSelectable="true" />
64
90
 
65
91
  <TextView
66
92
  style="@style/TextAppearance.MaterialComponents.Caption"
67
93
  android:layout_width="wrap_content"
68
94
  android:layout_height="wrap_content"
69
- android:text="@string/developer_tools_app_version" />
95
+ android:text="@string/developer_tools_app_version"
96
+ android:textColor="?android:attr/textColorSecondary" />
70
97
 
71
98
  <TextView
72
99
  android:id="@+id/runtimeAppVersionValue"
73
100
  style="@style/TextAppearance.MaterialComponents.Body2"
74
101
  android:layout_width="match_parent"
75
102
  android:layout_height="wrap_content"
76
- android:layout_marginBottom="12dp" />
103
+ android:layout_marginTop="2dp"
104
+ android:layout_marginBottom="14dp" />
77
105
 
78
106
  <TextView
79
107
  style="@style/TextAppearance.MaterialComponents.Caption"
80
108
  android:layout_width="wrap_content"
81
109
  android:layout_height="wrap_content"
82
- android:text="@string/developer_tools_android_version" />
110
+ android:text="@string/developer_tools_android_version"
111
+ android:textColor="?android:attr/textColorSecondary" />
83
112
 
84
113
  <TextView
85
114
  android:id="@+id/runtimeAndroidVersionValue"
86
115
  style="@style/TextAppearance.MaterialComponents.Body2"
87
116
  android:layout_width="match_parent"
88
117
  android:layout_height="wrap_content"
89
- android:layout_marginBottom="12dp" />
118
+ android:layout_marginTop="2dp"
119
+ android:layout_marginBottom="14dp" />
90
120
 
91
121
  <TextView
92
122
  style="@style/TextAppearance.MaterialComponents.Caption"
93
123
  android:layout_width="wrap_content"
94
124
  android:layout_height="wrap_content"
95
- android:text="@string/developer_tools_device_model" />
125
+ android:text="@string/developer_tools_device_model"
126
+ android:textColor="?android:attr/textColorSecondary" />
96
127
 
97
128
  <TextView
98
129
  android:id="@+id/runtimeDeviceModelValue"
99
130
  style="@style/TextAppearance.MaterialComponents.Body2"
100
131
  android:layout_width="match_parent"
101
132
  android:layout_height="wrap_content"
102
- android:layout_marginBottom="12dp" />
133
+ android:layout_marginTop="2dp"
134
+ android:layout_marginBottom="14dp" />
103
135
 
104
136
  <TextView
105
137
  style="@style/TextAppearance.MaterialComponents.Caption"
106
138
  android:layout_width="wrap_content"
107
139
  android:layout_height="wrap_content"
108
- android:text="@string/developer_tools_app_uptime" />
140
+ android:text="@string/developer_tools_app_uptime"
141
+ android:textColor="?android:attr/textColorSecondary" />
109
142
 
110
143
  <TextView
111
144
  android:id="@+id/runtimeUptimeValue"
112
145
  style="@style/TextAppearance.MaterialComponents.Body2"
113
146
  android:layout_width="match_parent"
114
- android:layout_height="wrap_content" />
147
+ android:layout_height="wrap_content"
148
+ android:layout_marginTop="2dp" />
115
149
  </LinearLayout>
116
150
  </com.google.android.material.card.MaterialCardView>
117
151
 
@@ -119,33 +153,100 @@
119
153
  android:layout_width="match_parent"
120
154
  android:layout_height="wrap_content"
121
155
  android:layout_marginBottom="16dp"
122
- app:cardCornerRadius="12dp"
123
- app:cardElevation="1dp">
156
+ app:cardCornerRadius="16dp"
157
+ app:cardElevation="0dp"
158
+ app:strokeColor="?attr/colorControlNormal"
159
+ app:strokeWidth="1dp">
124
160
 
125
161
  <LinearLayout
126
162
  android:layout_width="match_parent"
127
163
  android:layout_height="wrap_content"
128
164
  android:orientation="vertical"
129
- android:padding="16dp">
165
+ android:padding="20dp">
130
166
 
131
167
  <TextView
132
168
  style="@style/TextAppearance.MaterialComponents.Headline6"
133
169
  android:layout_width="wrap_content"
134
170
  android:layout_height="wrap_content"
135
- android:layout_marginBottom="8dp"
136
171
  android:text="@string/developer_tools_debug" />
137
172
 
173
+ <TextView
174
+ style="@style/TextAppearance.MaterialComponents.Caption"
175
+ android:layout_width="match_parent"
176
+ android:layout_height="wrap_content"
177
+ android:layout_marginTop="2dp"
178
+ android:layout_marginBottom="12dp"
179
+ android:text="@string/developer_tools_debug_description"
180
+ android:textColor="?android:attr/textColorSecondary" />
181
+
138
182
  <com.google.android.material.switchmaterial.SwitchMaterial
139
183
  android:id="@+id/debugModeSwitch"
140
184
  android:layout_width="match_parent"
141
185
  android:layout_height="wrap_content"
186
+ android:minHeight="48dp"
142
187
  android:text="@string/developer_tools_enable_debug_mode" />
143
188
 
189
+ <LinearLayout
190
+ android:layout_width="match_parent"
191
+ android:layout_height="wrap_content"
192
+ android:layout_marginTop="8dp"
193
+ android:gravity="center_vertical"
194
+ android:minHeight="48dp"
195
+ android:orientation="horizontal">
196
+
197
+ <TextView
198
+ style="@style/TextAppearance.MaterialComponents.Body1"
199
+ android:layout_width="0dp"
200
+ android:layout_height="wrap_content"
201
+ android:layout_weight="1"
202
+ android:text="@string/developer_tools_webview_debug" />
203
+
204
+ <TextView
205
+ android:id="@+id/debugWebViewStatus"
206
+ style="@style/TextAppearance.MaterialComponents.Body2"
207
+ android:layout_width="wrap_content"
208
+ android:layout_height="wrap_content"
209
+ android:textColor="?android:attr/textColorSecondary" />
210
+ </LinearLayout>
211
+ </LinearLayout>
212
+ </com.google.android.material.card.MaterialCardView>
213
+
214
+ <com.google.android.material.card.MaterialCardView
215
+ android:layout_width="match_parent"
216
+ android:layout_height="wrap_content"
217
+ android:layout_marginBottom="16dp"
218
+ app:cardCornerRadius="16dp"
219
+ app:cardElevation="0dp"
220
+ app:strokeColor="?attr/colorControlNormal"
221
+ app:strokeWidth="1dp">
222
+
223
+ <LinearLayout
224
+ android:layout_width="match_parent"
225
+ android:layout_height="wrap_content"
226
+ android:orientation="vertical"
227
+ android:padding="20dp">
228
+
229
+ <TextView
230
+ style="@style/TextAppearance.MaterialComponents.Headline6"
231
+ android:layout_width="wrap_content"
232
+ android:layout_height="wrap_content"
233
+ android:text="@string/developer_tools_api_proxy" />
234
+
235
+ <TextView
236
+ style="@style/TextAppearance.MaterialComponents.Caption"
237
+ android:layout_width="match_parent"
238
+ android:layout_height="wrap_content"
239
+ android:layout_marginTop="2dp"
240
+ android:layout_marginBottom="8dp"
241
+ android:text="@string/developer_tools_api_proxy_description"
242
+ android:textColor="?android:attr/textColorSecondary" />
243
+
144
244
  <com.google.android.material.switchmaterial.SwitchMaterial
145
245
  android:id="@+id/apiEnabledSwitch"
146
246
  android:layout_width="match_parent"
147
247
  android:layout_height="wrap_content"
148
248
  android:enabled="false"
249
+ android:minHeight="48dp"
149
250
  android:text="@string/developer_tools_enable_api" />
150
251
 
151
252
  <TextView
@@ -154,7 +255,58 @@
154
255
  android:layout_height="wrap_content"
155
256
  android:layout_marginStart="4dp"
156
257
  android:layout_marginBottom="16dp"
157
- android:text="@string/developer_tools_api_read_only" />
258
+ android:text="@string/developer_tools_api_effective_status"
259
+ android:textColor="?android:attr/textColorSecondary" />
260
+
261
+ <TextView
262
+ style="@style/TextAppearance.MaterialComponents.Overline"
263
+ android:layout_width="match_parent"
264
+ android:layout_height="wrap_content"
265
+ android:layout_marginBottom="8dp"
266
+ android:text="@string/developer_tools_api_effective_configuration"
267
+ android:textColor="?android:attr/textColorSecondary" />
268
+
269
+ <com.google.android.material.card.MaterialCardView
270
+ android:layout_width="match_parent"
271
+ android:layout_height="wrap_content"
272
+ android:layout_marginBottom="16dp"
273
+ app:cardCornerRadius="12dp"
274
+ app:cardElevation="0dp"
275
+ app:strokeColor="?attr/colorControlNormal"
276
+ app:strokeWidth="1dp">
277
+
278
+ <LinearLayout
279
+ android:layout_width="match_parent"
280
+ android:layout_height="wrap_content"
281
+ android:orientation="vertical"
282
+ android:padding="16dp">
283
+
284
+ <com.google.android.material.textfield.TextInputLayout
285
+ android:id="@+id/apiBaseInputLayout"
286
+ style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
287
+ android:layout_width="match_parent"
288
+ android:layout_height="wrap_content"
289
+ android:hint="@string/developer_tools_api_base_url">
290
+
291
+ <com.google.android.material.textfield.TextInputEditText
292
+ android:id="@+id/apiBaseInput"
293
+ android:layout_width="match_parent"
294
+ android:layout_height="wrap_content"
295
+ android:inputType="textUri"
296
+ android:maxLength="2048"
297
+ android:singleLine="true" />
298
+ </com.google.android.material.textfield.TextInputLayout>
299
+
300
+ <TextView
301
+ android:id="@+id/apiBaseSource"
302
+ style="@style/TextAppearance.MaterialComponents.Body2"
303
+ android:layout_width="match_parent"
304
+ android:layout_height="wrap_content"
305
+ android:layout_marginStart="4dp"
306
+ android:layout_marginTop="8dp"
307
+ android:textColor="?android:attr/textColorSecondary" />
308
+ </LinearLayout>
309
+ </com.google.android.material.card.MaterialCardView>
158
310
 
159
311
  <LinearLayout
160
312
  android:layout_width="match_parent"
@@ -162,18 +314,21 @@
162
314
  android:gravity="center_vertical"
163
315
  android:orientation="horizontal">
164
316
 
165
- <TextView
166
- style="@style/TextAppearance.MaterialComponents.Body1"
317
+ <com.google.android.material.button.MaterialButton
318
+ android:id="@+id/saveApiBaseButton"
167
319
  android:layout_width="0dp"
168
- android:layout_height="wrap_content"
320
+ android:layout_height="match_parent"
169
321
  android:layout_weight="1"
170
- android:text="@string/developer_tools_webview_debug" />
322
+ android:text="@string/developer_tools_api_base_save" />
171
323
 
172
- <TextView
173
- android:id="@+id/debugWebViewStatus"
174
- style="@style/TextAppearance.MaterialComponents.Body2"
175
- android:layout_width="wrap_content"
176
- android:layout_height="wrap_content" />
324
+ <com.google.android.material.button.MaterialButton
325
+ android:id="@+id/resetApiBaseButton"
326
+ style="@style/Widget.MaterialComponents.Button.OutlinedButton"
327
+ android:layout_width="0dp"
328
+ android:layout_height="match_parent"
329
+ android:layout_marginStart="8dp"
330
+ android:layout_weight="1"
331
+ android:text="@string/developer_tools_api_base_reset_button" />
177
332
  </LinearLayout>
178
333
  </LinearLayout>
179
334
  </com.google.android.material.card.MaterialCardView>
@@ -182,22 +337,32 @@
182
337
  android:layout_width="match_parent"
183
338
  android:layout_height="wrap_content"
184
339
  android:layout_marginBottom="16dp"
185
- app:cardCornerRadius="12dp"
186
- app:cardElevation="1dp">
340
+ app:cardCornerRadius="16dp"
341
+ app:cardElevation="0dp"
342
+ app:strokeColor="?attr/colorControlNormal"
343
+ app:strokeWidth="1dp">
187
344
 
188
345
  <LinearLayout
189
346
  android:layout_width="match_parent"
190
347
  android:layout_height="wrap_content"
191
348
  android:orientation="vertical"
192
- android:padding="16dp">
349
+ android:padding="20dp">
193
350
 
194
351
  <TextView
195
352
  style="@style/TextAppearance.MaterialComponents.Headline6"
196
353
  android:layout_width="wrap_content"
197
354
  android:layout_height="wrap_content"
198
- android:layout_marginBottom="8dp"
199
355
  android:text="@string/developer_tools_logs" />
200
356
 
357
+ <TextView
358
+ style="@style/TextAppearance.MaterialComponents.Caption"
359
+ android:layout_width="match_parent"
360
+ android:layout_height="wrap_content"
361
+ android:layout_marginTop="2dp"
362
+ android:layout_marginBottom="16dp"
363
+ android:text="@string/developer_tools_logs_description"
364
+ android:textColor="?android:attr/textColorSecondary" />
365
+
201
366
  <com.google.android.material.button.MaterialButton
202
367
  android:id="@+id/viewLogsButton"
203
368
  style="@style/Widget.MaterialComponents.Button.OutlinedButton"
@@ -210,18 +375,22 @@
210
375
  style="@style/Widget.MaterialComponents.Button.OutlinedButton"
211
376
  android:layout_width="match_parent"
212
377
  android:layout_height="wrap_content"
378
+ android:layout_marginTop="4dp"
213
379
  android:text="@string/developer_tools_clear_logs" />
214
380
 
215
381
  <com.google.android.material.button.MaterialButton
216
382
  android:id="@+id/exportDebugReportButton"
217
383
  android:layout_width="match_parent"
218
384
  android:layout_height="wrap_content"
385
+ android:layout_marginTop="12dp"
219
386
  android:text="@string/developer_tools_export_debug_report" />
220
387
 
221
388
  <com.google.android.material.button.MaterialButton
222
389
  android:id="@+id/shareReportButton"
390
+ style="@style/Widget.MaterialComponents.Button.OutlinedButton"
223
391
  android:layout_width="match_parent"
224
392
  android:layout_height="wrap_content"
393
+ android:layout_marginTop="4dp"
225
394
  android:text="@string/developer_tools_share_report" />
226
395
  </LinearLayout>
227
396
  </com.google.android.material.card.MaterialCardView>
@@ -230,55 +399,38 @@
230
399
  android:layout_width="match_parent"
231
400
  android:layout_height="wrap_content"
232
401
  android:layout_marginBottom="16dp"
233
- app:cardCornerRadius="12dp"
234
- app:cardElevation="1dp">
402
+ app:cardCornerRadius="16dp"
403
+ app:cardElevation="0dp"
404
+ app:strokeColor="?attr/colorControlNormal"
405
+ app:strokeWidth="1dp">
235
406
 
236
407
  <LinearLayout
237
408
  android:layout_width="match_parent"
238
409
  android:layout_height="wrap_content"
239
410
  android:orientation="vertical"
240
- android:padding="16dp">
411
+ android:padding="20dp">
241
412
 
242
413
  <TextView
243
414
  style="@style/TextAppearance.MaterialComponents.Headline6"
244
415
  android:layout_width="wrap_content"
245
416
  android:layout_height="wrap_content"
246
- android:layout_marginBottom="8dp"
247
- android:text="@string/developer_tools_crash_testing" />
248
-
249
- <com.google.android.material.button.MaterialButton
250
- android:id="@+id/throwTestExceptionButton"
251
- style="@style/Widget.MaterialComponents.Button.OutlinedButton"
252
- android:layout_width="match_parent"
253
- android:layout_height="wrap_content"
254
- android:text="@string/developer_tools_throw_test_exception" />
255
- </LinearLayout>
256
- </com.google.android.material.card.MaterialCardView>
257
-
258
- <com.google.android.material.card.MaterialCardView
259
- android:layout_width="match_parent"
260
- android:layout_height="wrap_content"
261
- app:cardCornerRadius="12dp"
262
- app:cardElevation="1dp">
263
-
264
- <LinearLayout
265
- android:layout_width="match_parent"
266
- android:layout_height="wrap_content"
267
- android:orientation="vertical"
268
- android:padding="16dp">
417
+ android:text="@string/developer_tools_webview" />
269
418
 
270
419
  <TextView
271
- style="@style/TextAppearance.MaterialComponents.Headline6"
272
- android:layout_width="wrap_content"
420
+ style="@style/TextAppearance.MaterialComponents.Caption"
421
+ android:layout_width="match_parent"
273
422
  android:layout_height="wrap_content"
423
+ android:layout_marginTop="2dp"
274
424
  android:layout_marginBottom="12dp"
275
- android:text="@string/developer_tools_webview" />
425
+ android:text="@string/developer_tools_webview_description"
426
+ android:textColor="?android:attr/textColorSecondary" />
276
427
 
277
428
  <LinearLayout
278
429
  android:layout_width="match_parent"
279
430
  android:layout_height="wrap_content"
280
431
  android:layout_marginBottom="12dp"
281
432
  android:gravity="center_vertical"
433
+ android:minHeight="48dp"
282
434
  android:orientation="horizontal">
283
435
 
284
436
  <TextView
@@ -292,7 +444,8 @@
292
444
  android:id="@+id/webViewStatus"
293
445
  style="@style/TextAppearance.MaterialComponents.Body2"
294
446
  android:layout_width="wrap_content"
295
- android:layout_height="wrap_content" />
447
+ android:layout_height="wrap_content"
448
+ android:textColor="?android:attr/textColorSecondary" />
296
449
  </LinearLayout>
297
450
 
298
451
  <com.google.android.material.button.MaterialButton
@@ -303,6 +456,44 @@
303
456
  android:text="@string/developer_tools_open_chrome_inspect_docs" />
304
457
  </LinearLayout>
305
458
  </com.google.android.material.card.MaterialCardView>
459
+
460
+ <com.google.android.material.card.MaterialCardView
461
+ android:layout_width="match_parent"
462
+ android:layout_height="wrap_content"
463
+ app:cardCornerRadius="16dp"
464
+ app:cardElevation="0dp"
465
+ app:strokeColor="?attr/colorControlNormal"
466
+ app:strokeWidth="1dp">
467
+
468
+ <LinearLayout
469
+ android:layout_width="match_parent"
470
+ android:layout_height="wrap_content"
471
+ android:orientation="vertical"
472
+ android:padding="20dp">
473
+
474
+ <TextView
475
+ style="@style/TextAppearance.MaterialComponents.Headline6"
476
+ android:layout_width="wrap_content"
477
+ android:layout_height="wrap_content"
478
+ android:text="@string/developer_tools_crash_testing" />
479
+
480
+ <TextView
481
+ style="@style/TextAppearance.MaterialComponents.Caption"
482
+ android:layout_width="match_parent"
483
+ android:layout_height="wrap_content"
484
+ android:layout_marginTop="2dp"
485
+ android:layout_marginBottom="16dp"
486
+ android:text="@string/developer_tools_crash_testing_description"
487
+ android:textColor="?android:attr/textColorSecondary" />
488
+
489
+ <com.google.android.material.button.MaterialButton
490
+ android:id="@+id/throwTestExceptionButton"
491
+ style="@style/Widget.MaterialComponents.Button.OutlinedButton"
492
+ android:layout_width="match_parent"
493
+ android:layout_height="wrap_content"
494
+ android:text="@string/developer_tools_throw_test_exception" />
495
+ </LinearLayout>
496
+ </com.google.android.material.card.MaterialCardView>
306
497
  </LinearLayout>
307
498
  </androidx.core.widget.NestedScrollView>
308
499
  </LinearLayout>
@@ -2,7 +2,9 @@
2
2
  <string name="app_name">Pakstr</string>
3
3
 
4
4
  <string name="developer_tools_title">Developer Tools</string>
5
+ <string name="developer_tools_description">Inspect the app runtime, configure diagnostics, and collect information for troubleshooting.</string>
5
6
  <string name="developer_tools_runtime">Runtime</string>
7
+ <string name="developer_tools_runtime_description">App, device, and session details</string>
6
8
  <string name="developer_tools_session_id">Session ID</string>
7
9
  <string name="developer_tools_app_version">App version</string>
8
10
  <string name="developer_tools_android_version">Android version</string>
@@ -12,14 +14,29 @@
12
14
  <string name="developer_tools_device_value">%1$s %2$s</string>
13
15
 
14
16
  <string name="developer_tools_debug">Debug</string>
17
+ <string name="developer_tools_debug_description">Control diagnostic logging and inspection</string>
15
18
  <string name="developer_tools_enable_debug_mode">Enable Debug Mode</string>
16
- <string name="developer_tools_enable_api">Enable API</string>
17
- <string name="developer_tools_api_read_only">Controlled by the packaged runtime configuration.</string>
19
+ <string name="developer_tools_api_proxy">API Proxy</string>
20
+ <string name="developer_tools_api_proxy_description">Configure the backend used for same-origin /api/* requests.</string>
21
+ <string name="developer_tools_enable_api">API proxy enabled</string>
22
+ <string name="developer_tools_api_effective_status">Read-only status based on the effective API Base URL.</string>
23
+ <string name="developer_tools_api_effective_configuration">Effective configuration</string>
24
+ <string name="developer_tools_api_base_url">API Base URL</string>
25
+ <string name="developer_tools_api_base_runtime_override">Source: runtime override</string>
26
+ <string name="developer_tools_api_base_packaged_default">Source: packaged default</string>
27
+ <string name="developer_tools_api_base_not_configured">Source: not configured; API proxy disabled</string>
28
+ <string name="developer_tools_api_base_save">Save / Apply</string>
29
+ <string name="developer_tools_api_base_reset_button">Reset to default</string>
30
+ <string name="developer_tools_api_base_saved">API Base URL override applied.</string>
31
+ <string name="developer_tools_api_base_reset">Runtime override removed.</string>
32
+ <string name="developer_tools_api_base_save_failed">Unable to persist the API Base URL setting.</string>
33
+ <string name="developer_tools_api_base_invalid">Enter an absolute HTTPS URL without credentials, query, or fragment.</string>
18
34
  <string name="developer_tools_webview_debug">WebView Debug</string>
19
35
  <string name="developer_tools_enabled">Enabled</string>
20
36
  <string name="developer_tools_disabled">Disabled</string>
21
37
 
22
- <string name="developer_tools_logs">Logs</string>
38
+ <string name="developer_tools_logs">Logs &amp; Reports</string>
39
+ <string name="developer_tools_logs_description">Review local diagnostics or export them for support</string>
23
40
  <string name="developer_tools_view_logs">View Logs</string>
24
41
  <string name="developer_tools_clear_logs">Clear Logs</string>
25
42
  <string name="developer_tools_export_debug_report">Export Debug Report</string>
@@ -32,13 +49,15 @@
32
49
  <string name="developer_tools_report_export_failed">Unable to export the debug report.</string>
33
50
 
34
51
  <string name="developer_tools_crash_testing">Crash Testing</string>
52
+ <string name="developer_tools_crash_testing_description">Verify that uncaught errors are captured correctly</string>
35
53
  <string name="developer_tools_throw_test_exception">Throw Test Exception</string>
36
54
  <string name="developer_tools_crash_confirmation_title">Crash Pakstr?</string>
37
55
  <string name="developer_tools_crash_confirmation_message">This intentionally throws an uncaught RuntimeException and closes the app so CrashHandler can capture it.</string>
38
56
  <string name="developer_tools_crash">Crash</string>
39
57
 
40
- <string name="developer_tools_webview">WebView</string>
41
- <string name="developer_tools_current_status">Current WebView Debug status</string>
58
+ <string name="developer_tools_webview">WebView Inspection</string>
59
+ <string name="developer_tools_webview_description">Inspect rendered web content with Chrome DevTools</string>
60
+ <string name="developer_tools_current_status">Inspection status</string>
42
61
  <string name="developer_tools_open_chrome_inspect_docs">Open Chrome Inspect documentation</string>
43
62
  <string name="developer_tools_browser_unavailable">No browser is available.</string>
44
63
 
@@ -0,0 +1,52 @@
1
+ package com.pakstr.app
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Assert.assertFalse
5
+ import org.junit.Assert.assertTrue
6
+ import org.junit.Test
7
+
8
+ class PakstrBridgeRuntimeApiBaseTest {
9
+ @Test
10
+ fun setterPersistsValidatedOverrideAndGetterReturnsEffectiveValue() {
11
+ val preferences = FakePreferences()
12
+ val bridge = PakstrBridge(
13
+ RuntimeApiBaseSettings.createForTesting(preferences) {
14
+ "https://default.example.com"
15
+ }
16
+ )
17
+
18
+ assertTrue(bridge.setApiBaseUrl("https://override.example.com/"))
19
+ assertEquals("https://override.example.com", bridge.getApiBaseUrl())
20
+ assertFalse(bridge.setApiBaseUrl("http://unsafe.example.com"))
21
+ assertEquals("https://override.example.com", bridge.getApiBaseUrl())
22
+ }
23
+
24
+ @Test
25
+ fun setterReportsPersistenceFailure() {
26
+ val bridge = PakstrBridge(
27
+ RuntimeApiBaseSettings.createForTesting(FakePreferences(commitResult = false)) {
28
+ null
29
+ }
30
+ )
31
+
32
+ assertFalse(bridge.setApiBaseUrl("https://api.example.com"))
33
+ }
34
+
35
+ private class FakePreferences(
36
+ private val commitResult: Boolean = true
37
+ ) : RuntimeApiBaseSettings.Preferences {
38
+ private val values = mutableMapOf<String, String>()
39
+
40
+ override fun getString(key: String): String? = values[key]
41
+
42
+ override fun putString(key: String, value: String): Boolean {
43
+ if (commitResult) values[key] = value
44
+ return commitResult
45
+ }
46
+
47
+ override fun remove(key: String): Boolean {
48
+ if (commitResult) values.remove(key)
49
+ return commitResult
50
+ }
51
+ }
52
+ }
@@ -0,0 +1,49 @@
1
+ package com.pakstr.app
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Assert.assertTrue
5
+ import org.junit.Test
6
+
7
+ class RuntimeApiBaseProxyTest {
8
+ @Test
9
+ fun proxyUsesUpdatedEffectiveValueOnSubsequentRequest() {
10
+ val preferences = FakePreferences()
11
+ val settings = RuntimeApiBaseSettings.createForTesting(preferences) {
12
+ "https://default.example.com"
13
+ }
14
+
15
+ val firstRequestApiBase = settings.current().url!!
16
+ assertEquals(
17
+ "https://default.example.com/api/items",
18
+ proxyApiUrl(firstRequestApiBase, "/api/items").toString()
19
+ )
20
+
21
+ assertTrue(settings.setOverride("https://updated.example.com/base/"))
22
+
23
+ val secondRequestApiBase = settings.current().url!!
24
+ assertEquals(
25
+ "https://updated.example.com/base/api/items",
26
+ proxyApiUrl(secondRequestApiBase, "/api/items").toString()
27
+ )
28
+ assertEquals(
29
+ "https://default.example.com/api/items",
30
+ proxyApiUrl(firstRequestApiBase, "/api/items").toString()
31
+ )
32
+ }
33
+
34
+ private class FakePreferences : RuntimeApiBaseSettings.Preferences {
35
+ private val values = mutableMapOf<String, String>()
36
+
37
+ override fun getString(key: String): String? = values[key]
38
+
39
+ override fun putString(key: String, value: String): Boolean {
40
+ values[key] = value
41
+ return true
42
+ }
43
+
44
+ override fun remove(key: String): Boolean {
45
+ values.remove(key)
46
+ return true
47
+ }
48
+ }
49
+ }
@@ -0,0 +1,107 @@
1
+ package com.pakstr.app
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Assert.assertFalse
5
+ import org.junit.Assert.assertNull
6
+ import org.junit.Assert.assertTrue
7
+ import org.junit.Test
8
+
9
+ class RuntimeApiBaseSettingsTest {
10
+ @Test
11
+ fun persistedOverrideTakesPrecedenceOverPackagedDefault() {
12
+ val preferences = FakePreferences()
13
+ val settings = settings(preferences, "https://default.example.com")
14
+
15
+ assertTrue(settings.setOverride("https://override.example.com/"))
16
+ assertEquals(
17
+ "https://override.example.com",
18
+ preferences.values[RuntimeApiBaseSettings.API_BASE_OVERRIDE_KEY]
19
+ )
20
+ assertEquals(
21
+ RuntimeApiBaseSettings.Value(
22
+ "https://override.example.com",
23
+ RuntimeApiBaseSettings.Source.RUNTIME_OVERRIDE
24
+ ),
25
+ settings.current()
26
+ )
27
+ }
28
+
29
+ @Test
30
+ fun resetRemovesOverrideAndRestoresPackagedDefault() {
31
+ val preferences = FakePreferences()
32
+ val settings = settings(preferences, "https://default.example.com/")
33
+ settings.setOverride("https://override.example.com")
34
+
35
+ assertTrue(settings.reset())
36
+ assertFalse(preferences.values.containsKey(RuntimeApiBaseSettings.API_BASE_OVERRIDE_KEY))
37
+ assertEquals(
38
+ RuntimeApiBaseSettings.Value(
39
+ "https://default.example.com",
40
+ RuntimeApiBaseSettings.Source.PACKAGED_DEFAULT
41
+ ),
42
+ settings.current()
43
+ )
44
+ }
45
+
46
+ @Test
47
+ fun noConfiguredDefaultDisablesApiProxy() {
48
+ val settings = settings(FakePreferences(), null)
49
+
50
+ assertEquals(
51
+ RuntimeApiBaseSettings.Value(
52
+ null,
53
+ RuntimeApiBaseSettings.Source.DISABLED
54
+ ),
55
+ settings.current()
56
+ )
57
+ }
58
+
59
+ @Test
60
+ fun invalidPersistedValueIsIgnored() {
61
+ val preferences = FakePreferences(
62
+ mutableMapOf(
63
+ RuntimeApiBaseSettings.API_BASE_OVERRIDE_KEY to "http://unsafe.example.com"
64
+ )
65
+ )
66
+
67
+ assertEquals(
68
+ "https://default.example.com",
69
+ settings(preferences, "https://default.example.com").current().url
70
+ )
71
+ assertNull(settings(preferences, null).current().url)
72
+ }
73
+
74
+ @Test
75
+ fun invalidOverrideDoesNotPersist() {
76
+ val preferences = FakePreferences()
77
+ val settings = settings(preferences, "https://default.example.com")
78
+
79
+ assertFalse(settings.setOverride("https://user@example.com"))
80
+ assertTrue(preferences.values.isEmpty())
81
+ }
82
+
83
+ private fun settings(
84
+ preferences: FakePreferences,
85
+ packagedDefault: String?
86
+ ): RuntimeApiBaseSettings = RuntimeApiBaseSettings.createForTesting(
87
+ preferences
88
+ ) {
89
+ packagedDefault
90
+ }
91
+
92
+ private class FakePreferences(
93
+ val values: MutableMap<String, String> = mutableMapOf()
94
+ ) : RuntimeApiBaseSettings.Preferences {
95
+ override fun getString(key: String): String? = values[key]
96
+
97
+ override fun putString(key: String, value: String): Boolean {
98
+ values[key] = value
99
+ return true
100
+ }
101
+
102
+ override fun remove(key: String): Boolean {
103
+ values.remove(key)
104
+ return true
105
+ }
106
+ }
107
+ }
@@ -0,0 +1,50 @@
1
+ package com.pakstr.app
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Assert.assertNull
5
+ import org.junit.Test
6
+
7
+ class RuntimeApiBaseValidatorTest {
8
+ @Test
9
+ fun acceptsAbsoluteHttpsUrlWithHostname() {
10
+ assertEquals(
11
+ "https://api.example.com/v1",
12
+ RuntimeApiBaseValidator.validateAndCanonicalize(
13
+ "https://api.example.com/v1"
14
+ )
15
+ )
16
+ }
17
+
18
+ @Test
19
+ fun normalizesTrailingSlashesAndHostCase() {
20
+ assertEquals(
21
+ "https://api.example.com/v1",
22
+ RuntimeApiBaseValidator.validateAndCanonicalize(
23
+ "HTTPS://API.Example.COM/v1///"
24
+ )
25
+ )
26
+ assertEquals(
27
+ "https://api.example.com",
28
+ RuntimeApiBaseValidator.validateAndCanonicalize(
29
+ "https://api.example.com/"
30
+ )
31
+ )
32
+ }
33
+
34
+ @Test
35
+ fun rejectsInvalidOrUnsafeUrls() {
36
+ listOf(
37
+ "",
38
+ " https://api.example.com",
39
+ "http://api.example.com",
40
+ "api.example.com",
41
+ "https:///v1",
42
+ "https://user:password@api.example.com",
43
+ "https://api.example.com?v=1",
44
+ "https://api.example.com#fragment",
45
+ "https://api.example.com/${"a".repeat(RuntimeApiBaseValidator.MAX_LENGTH)}"
46
+ ).forEach { value ->
47
+ assertNull(value, RuntimeApiBaseValidator.validateAndCanonicalize(value))
48
+ }
49
+ }
50
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pakstr",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "CLI for packaging Nostr web apps into Android APKs",
5
5
  "type": "commonjs",
6
6
  "main": "dist/index.js",