craft-native 0.0.128 → 0.0.132

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.
@@ -24,10 +24,17 @@ import androidx.security.crypto.MasterKey
24
24
  import org.json.JSONArray
25
25
  import org.json.JSONObject
26
26
  import org.json.JSONTokener
27
+ import java.io.ByteArrayOutputStream
28
+ import java.net.HttpURLConnection
29
+ import java.net.SocketTimeoutException
30
+ import java.net.URL
27
31
  import java.util.UUID
28
32
  import java.util.concurrent.ConcurrentHashMap
29
33
  import java.util.concurrent.Executors
30
34
 
35
+ private const val CRAFT_NATIVE_FETCH_MAX_RESPONSE_BYTES = 8 * 1024 * 1024
36
+ private const val CRAFT_NATIVE_FETCH_TIMEOUT_MS = 30_000
37
+
31
38
  internal const val CRAFT_NATIVE_CAPABILITY_PROTOCOL_VERSION = 1
32
39
 
33
40
  internal data class CraftNativeCapabilityResult(
@@ -58,14 +65,16 @@ internal class CraftNativeCapabilities(
58
65
  }
59
66
  private val notificationStorage = activity.getSharedPreferences("craft-native-notifications", Context.MODE_PRIVATE)
60
67
  private val databaseExecutor = Executors.newSingleThreadExecutor()
68
+ private val networkExecutor = Executors.newCachedThreadPool()
61
69
  private val cancelled = ConcurrentHashMap.newKeySet<String>()
70
+ private val fetchConnections = ConcurrentHashMap<String, HttpURLConnection>()
62
71
  private var database: SQLiteDatabase? = null
63
72
  private val biometricPrompts = ConcurrentHashMap<String, BiometricPrompt>()
64
73
 
65
74
  init { restoreNotifications() }
66
75
 
67
76
  fun names(): JSONArray = JSONArray().apply {
68
- listOf("device", "clipboard", "haptics", "storage", "lifecycle").forEach(::put)
77
+ listOf("device", "clipboard", "haptics", "storage", "lifecycle", "fetch").forEach(::put)
69
78
  if (config.optBoolean("enableBiometric")) put("biometric")
70
79
  if (config.optBoolean("enableSecureStorage")) put("secureStorage")
71
80
  if (config.optBoolean("enableLocalDatabase")) put("database")
@@ -105,23 +114,116 @@ internal class CraftNativeCapabilities(
105
114
  else failure("UNKNOWN_ACTION", "Unsupported Lifecycle method"), completion)
106
115
  "DeepLinks" -> complete(requestToken, deepLinks(method), completion)
107
116
  "Notifications" -> notifications(requestToken, method, args, completion)
117
+ "Network" -> {
118
+ if (method != "fetch") {
119
+ complete(requestToken, failure("UNKNOWN_ACTION", "Unsupported Network method"), completion)
120
+ } else networkExecutor.execute {
121
+ complete(requestToken, fetch(requestToken, args), completion)
122
+ }
123
+ }
108
124
  else -> complete(requestToken, activity.performImmediateCapability(module, method, args), completion)
109
125
  }
110
126
  }
111
127
 
112
128
  fun cancel(requestToken: String) {
113
129
  cancelled.add(requestToken)
130
+ fetchConnections.remove(requestToken)?.disconnect()
114
131
  biometricPrompts.remove(requestToken)?.cancelAuthentication()
115
132
  }
116
133
 
117
134
  fun close() {
118
135
  databaseExecutor.shutdownNow()
136
+ networkExecutor.shutdownNow()
137
+ fetchConnections.values.forEach(HttpURLConnection::disconnect)
138
+ fetchConnections.clear()
119
139
  database?.close()
120
140
  database = null
121
141
  biometricPrompts.values.forEach { it.cancelAuthentication() }
122
142
  biometricPrompts.clear()
123
143
  }
124
144
 
145
+ /** The same bounded text-only fetch contract as the maintained iOS host. */
146
+ private fun fetch(requestToken: String, args: JSONArray): CraftNativeCapabilityResult {
147
+ val options = args.optJSONObject(0)
148
+ ?: return failure("INVALID_ARGUMENT", "fetch needs an options object")
149
+ val address = options.optString("url").trim()
150
+ val url = runCatching { URL(address) }.getOrNull()
151
+ ?: return failure("INVALID_ARGUMENT", "fetch needs an absolute http or https URL")
152
+ if (url.protocol.lowercase() !in setOf("http", "https") || url.host.isBlank())
153
+ return failure("INVALID_ARGUMENT", "fetch needs an absolute http or https URL")
154
+
155
+ val method = options.optString("method", "GET").uppercase()
156
+ if (method.isBlank() || method.any { !it.isLetter() || it.code > 127 })
157
+ return failure("INVALID_ARGUMENT", "fetch method must be an HTTP token")
158
+ val bodyValue = options.opt("body")
159
+ val body = when (bodyValue) {
160
+ null, JSONObject.NULL -> null
161
+ is String -> bodyValue
162
+ else -> return failure("INVALID_ARGUMENT", "fetch body must be a string")
163
+ }
164
+ if (body != null && method in setOf("GET", "HEAD"))
165
+ return failure("INVALID_ARGUMENT", "A GET or HEAD request cannot have a body")
166
+
167
+ val connection = try {
168
+ (url.openConnection() as HttpURLConnection).apply {
169
+ requestMethod = method
170
+ connectTimeout = CRAFT_NATIVE_FETCH_TIMEOUT_MS
171
+ readTimeout = CRAFT_NATIVE_FETCH_TIMEOUT_MS
172
+ instanceFollowRedirects = true
173
+ options.optJSONObject("headers")?.let { headers ->
174
+ for (name in headers.keys()) {
175
+ val value = headers.opt(name)
176
+ if (value !is String) throw IllegalArgumentException("fetch header values must be strings")
177
+ setRequestProperty(name, value)
178
+ }
179
+ }
180
+ if (body != null) doOutput = true
181
+ }
182
+ } catch (error: IllegalArgumentException) {
183
+ return failure("INVALID_ARGUMENT", error.message ?: "fetch request is invalid")
184
+ } catch (error: Exception) {
185
+ return failure("NETWORK_ERROR", error.message ?: "Network request failed")
186
+ }
187
+ fetchConnections[requestToken] = connection
188
+ if (cancelled.contains(requestToken)) {
189
+ fetchConnections.remove(requestToken)?.disconnect()
190
+ return failure("CANCELLED", "Network request was cancelled")
191
+ }
192
+ return try {
193
+ if (body != null) connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
194
+ val status = connection.responseCode
195
+ val input = if (status >= 400) connection.errorStream else connection.inputStream
196
+ val output = ByteArrayOutputStream()
197
+ input?.use { stream ->
198
+ val chunk = ByteArray(8192)
199
+ while (true) {
200
+ val count = stream.read(chunk)
201
+ if (count < 0) break
202
+ if (output.size() + count > CRAFT_NATIVE_FETCH_MAX_RESPONSE_BYTES)
203
+ return failure("RESPONSE_TOO_LARGE", "fetch responses are limited to $CRAFT_NATIVE_FETCH_MAX_RESPONSE_BYTES bytes")
204
+ output.write(chunk, 0, count)
205
+ }
206
+ }
207
+ val headers = JSONObject()
208
+ connection.headerFields.forEach { (name, values) ->
209
+ if (name != null && !values.isNullOrEmpty()) headers.put(name.lowercase(), values.joinToString(", "))
210
+ }
211
+ success(JSONObject()
212
+ .put("url", connection.url.toString())
213
+ .put("status", status)
214
+ .put("statusText", connection.responseMessage ?: "")
215
+ .put("headers", headers)
216
+ .put("redirected", connection.url != url)
217
+ .put("body", output.toString(Charsets.UTF_8.name())))
218
+ } catch (error: SocketTimeoutException) {
219
+ failure("TIMEOUT", error.message ?: "Network request timed out")
220
+ } catch (error: Exception) {
221
+ failure("NETWORK_ERROR", error.message ?: "Network request failed")
222
+ } finally {
223
+ fetchConnections.remove(requestToken)?.disconnect()
224
+ }
225
+ }
226
+
125
227
  private fun complete(
126
228
  requestToken: String,
127
229
  result: CraftNativeCapabilityResult,
@@ -185,9 +185,9 @@ class CraftNativeMutationDocument {
185
185
  val allowed = setOf("props", "style", "events", "children", "text")
186
186
  val unknown = patch.keys().asSequence().filter { it !in allowed }.sorted().firstOrNull()
187
187
  if (unknown != null) fail("INVALID_PATCH", "unsupported patch field $unknown")
188
- if (patch.has("props")) node.props = dictionary(patch.opt("props"))
189
- if (patch.has("style")) node.style = dictionary(patch.opt("style"))
190
- if (patch.has("events")) node.events = dictionary(patch.opt("events"))
188
+ if (patch.has("props")) mergePatch(node.props, patch.opt("props"))
189
+ if (patch.has("style")) mergePatch(node.style, patch.opt("style"))
190
+ if (patch.has("events")) mergePatch(node.events, patch.opt("events"))
191
191
  if (patch.has("children") || patch.has("text")) {
192
192
  node.text = textChildren(if (patch.has("children")) patch.opt("children") else patch.opt("text"))
193
193
  }
@@ -322,6 +322,15 @@ class CraftNativeMutationDocument {
322
322
  private fun dictionary(value: Any?): JSONObject =
323
323
  if (value is JSONObject) JSONObject(value.toString()) else JSONObject()
324
324
 
325
+ private fun mergePatch(target: JSONObject, value: Any?) {
326
+ val patch = value as? JSONObject ?: return
327
+ patch.keys().forEach { key ->
328
+ val next = patch.opt(key)
329
+ if (next == null || next == JSONObject.NULL) target.remove(key)
330
+ else target.put(key, next)
331
+ }
332
+ }
333
+
325
334
  private fun strictInteger(value: Any?): Int? {
326
335
  if (value !is Number) return null
327
336
  val integer = value.toInt()