craft-native 0.0.128 → 0.0.133
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/dist/android/templates/CraftNativeCapabilities.kt.template +103 -1
- package/dist/android/templates/CraftNativeMutation.kt.template +12 -3
- package/dist/android/templates/MainActivityNative.kt.template +262 -57
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/templates/CraftApp.swift +51 -7
- package/dist/ios/templates/CraftHybrid.swift +3 -6
- package/dist/ios/templates/CraftNativeActions.swift +5 -1
- package/dist/ios/templates/CraftNativeMutation.swift +10 -3
- package/dist/ios/templates/CraftNativeScreen.swift +178 -11
- package/package.json +2 -2
|
@@ -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
|
|
189
|
-
if (patch.has("style")) node.style
|
|
190
|
-
if (patch.has("events")) node.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()
|