@capacitor/android 4.1.0 → 4.1.1-dev-7e3589f9.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.
@@ -241,6 +241,209 @@ const nativeBridge = (function (exports) {
241
241
  }
242
242
  return String(msg);
243
243
  };
244
+ /**
245
+ * Safely web decode a string value (inspired by js-cookie)
246
+ * @param str The string value to decode
247
+ */
248
+ const decode = (str) => str.replace(/(%[\dA-F]{2})+/gi, decodeURIComponent);
249
+ const platform = getPlatformId(win);
250
+ if (platform == 'android' || platform == 'ios') {
251
+ // patch document.cookie on Android/iOS
252
+ win.CapacitorCookiesDescriptor =
253
+ Object.getOwnPropertyDescriptor(Document.prototype, 'cookie') ||
254
+ Object.getOwnPropertyDescriptor(HTMLDocument.prototype, 'cookie');
255
+ Object.defineProperty(document, 'cookie', {
256
+ get: function () {
257
+ if (platform === 'ios') {
258
+ // Use prompt to synchronously get cookies.
259
+ // https://stackoverflow.com/questions/29249132/wkwebview-complex-communication-between-javascript-native-code/49474323#49474323
260
+ const payload = {
261
+ type: 'CapacitorCookies',
262
+ };
263
+ const res = prompt(JSON.stringify(payload));
264
+ return res;
265
+ }
266
+ else if (typeof win.CapacitorCookiesAndroidInterface !== 'undefined') {
267
+ return win.CapacitorCookiesAndroidInterface.getCookies();
268
+ }
269
+ },
270
+ set: function (val) {
271
+ const cookiePairs = val.split(';');
272
+ for (const cookiePair of cookiePairs) {
273
+ const cookieKey = cookiePair.split('=')[0];
274
+ const cookieValue = cookiePair.split('=')[1];
275
+ if (null == cookieValue) {
276
+ continue;
277
+ }
278
+ cap.toNative('CapacitorCookies', 'setCookie', {
279
+ key: cookieKey,
280
+ value: decode(cookieValue),
281
+ });
282
+ }
283
+ },
284
+ });
285
+ // patch fetch / XHR on Android/iOS
286
+ // store original fetch & XHR functions
287
+ win.CapacitorWebFetch = window.fetch;
288
+ win.CapacitorWebXMLHttpRequest = {
289
+ abort: window.XMLHttpRequest.prototype.abort,
290
+ open: window.XMLHttpRequest.prototype.open,
291
+ send: window.XMLHttpRequest.prototype.send,
292
+ setRequestHeader: window.XMLHttpRequest.prototype.setRequestHeader,
293
+ };
294
+ // fetch patch
295
+ window.fetch = async (resource, options) => {
296
+ if (resource.toString().startsWith('data:')) {
297
+ return win.CapacitorWebFetch(resource, options);
298
+ }
299
+ try {
300
+ // intercept request & pass to the bridge
301
+ const nativeResponse = await cap.nativePromise('CapacitorHttp', 'request', {
302
+ url: resource,
303
+ method: (options === null || options === void 0 ? void 0 : options.method) ? options.method : undefined,
304
+ data: (options === null || options === void 0 ? void 0 : options.body) ? options.body : undefined,
305
+ headers: (options === null || options === void 0 ? void 0 : options.headers) ? options.headers : undefined,
306
+ });
307
+ // intercept & parse response before returning
308
+ const response = new Response(JSON.stringify(nativeResponse.data), {
309
+ headers: nativeResponse.headers,
310
+ status: nativeResponse.status,
311
+ });
312
+ return response;
313
+ }
314
+ catch (error) {
315
+ return Promise.reject(error);
316
+ }
317
+ };
318
+ // XHR event listeners
319
+ const addEventListeners = function () {
320
+ this.addEventListener('abort', function () {
321
+ if (typeof this.onabort === 'function')
322
+ this.onabort();
323
+ });
324
+ this.addEventListener('error', function () {
325
+ if (typeof this.onerror === 'function')
326
+ this.onerror();
327
+ });
328
+ this.addEventListener('load', function () {
329
+ if (typeof this.onload === 'function')
330
+ this.onload();
331
+ });
332
+ this.addEventListener('loadend', function () {
333
+ if (typeof this.onloadend === 'function')
334
+ this.onloadend();
335
+ });
336
+ this.addEventListener('loadstart', function () {
337
+ if (typeof this.onloadstart === 'function')
338
+ this.onloadstart();
339
+ });
340
+ this.addEventListener('readystatechange', function () {
341
+ if (typeof this.onreadystatechange === 'function')
342
+ this.onreadystatechange();
343
+ });
344
+ this.addEventListener('timeout', function () {
345
+ if (typeof this.ontimeout === 'function')
346
+ this.ontimeout();
347
+ });
348
+ };
349
+ // XHR patch abort
350
+ window.XMLHttpRequest.prototype.abort = function () {
351
+ Object.defineProperties(this, {
352
+ _headers: {
353
+ value: {},
354
+ writable: true,
355
+ },
356
+ readyState: {
357
+ get: function () {
358
+ var _a;
359
+ return (_a = this._readyState) !== null && _a !== void 0 ? _a : 0;
360
+ },
361
+ set: function (val) {
362
+ this._readyState = val;
363
+ this.dispatchEvent(new Event('readystatechange'));
364
+ },
365
+ },
366
+ response: {
367
+ value: '',
368
+ writable: true,
369
+ },
370
+ responseText: {
371
+ value: '',
372
+ writable: true,
373
+ },
374
+ responseURL: {
375
+ value: '',
376
+ writable: true,
377
+ },
378
+ status: {
379
+ value: 0,
380
+ writable: true,
381
+ },
382
+ });
383
+ this.readyState = 0;
384
+ this.dispatchEvent(new Event('abort'));
385
+ this.dispatchEvent(new Event('loadend'));
386
+ };
387
+ // XHR patch open
388
+ window.XMLHttpRequest.prototype.open = function (method, url) {
389
+ this.abort();
390
+ addEventListeners.call(this);
391
+ this._method = method;
392
+ this._url = url;
393
+ this.readyState = 1;
394
+ };
395
+ // XHR patch set request header
396
+ window.XMLHttpRequest.prototype.setRequestHeader = function (header, value) {
397
+ this._headers[header] = value;
398
+ };
399
+ // XHR patch send
400
+ window.XMLHttpRequest.prototype.send = function (body) {
401
+ try {
402
+ this.readyState = 2;
403
+ // intercept request & pass to the bridge
404
+ cap
405
+ .nativePromise('CapacitorHttp', 'request', {
406
+ url: this._url,
407
+ method: this._method,
408
+ data: body !== null ? body : undefined,
409
+ headers: this._headers,
410
+ })
411
+ .then((nativeResponse) => {
412
+ // intercept & parse response before returning
413
+ if (this.readyState == 2) {
414
+ this.dispatchEvent(new Event('loadstart'));
415
+ this.status = nativeResponse.status;
416
+ this.response = nativeResponse.data;
417
+ this.responseText = JSON.stringify(nativeResponse.data);
418
+ this.responseURL = nativeResponse.url;
419
+ this.readyState = 4;
420
+ this.dispatchEvent(new Event('load'));
421
+ this.dispatchEvent(new Event('loadend'));
422
+ }
423
+ })
424
+ .catch((error) => {
425
+ this.dispatchEvent(new Event('loadstart'));
426
+ this.status = error.status;
427
+ this.response = error.data;
428
+ this.responseText = JSON.stringify(error.data);
429
+ this.responseURL = error.url;
430
+ this.readyState = 4;
431
+ this.dispatchEvent(new Event('error'));
432
+ this.dispatchEvent(new Event('loadend'));
433
+ });
434
+ }
435
+ catch (error) {
436
+ this.dispatchEvent(new Event('loadstart'));
437
+ this.status = 500;
438
+ this.response = error;
439
+ this.responseText = error.toString();
440
+ this.responseURL = this._url;
441
+ this.readyState = 4;
442
+ this.dispatchEvent(new Event('error'));
443
+ this.dispatchEvent(new Event('loadend'));
444
+ }
445
+ };
446
+ }
244
447
  // patch window.console on iOS and store original console fns
245
448
  const isIos = getPlatformId(win) === 'ios';
246
449
  if (win.console && isIos) {
@@ -556,7 +556,9 @@ public class Bridge {
556
556
  * Register our core Plugin APIs
557
557
  */
558
558
  private void registerAllPlugins() {
559
+ this.registerPlugin(com.getcapacitor.plugin.CapacitorCookies.class);
559
560
  this.registerPlugin(com.getcapacitor.plugin.WebView.class);
561
+ this.registerPlugin(com.getcapacitor.plugin.CapacitorHttp.class);
560
562
 
561
563
  for (Class<? extends Plugin> pluginClass : this.initialPlugins) {
562
564
  this.registerPlugin(pluginClass);
@@ -0,0 +1,65 @@
1
+ package com.getcapacitor;
2
+
3
+ import org.json.JSONException;
4
+
5
+ /**
6
+ * Represents a single user-data value of any type on the capacitor PluginCall object.
7
+ */
8
+ public class JSValue {
9
+
10
+ private final Object value;
11
+
12
+ /**
13
+ * @param call The capacitor plugin call, used for accessing the value safely.
14
+ * @param name The name of the property to access.
15
+ */
16
+ public JSValue(PluginCall call, String name) {
17
+ this.value = this.toValue(call, name);
18
+ }
19
+
20
+ /**
21
+ * Returns the coerced but uncasted underlying value.
22
+ */
23
+ public Object getValue() {
24
+ return this.value;
25
+ }
26
+
27
+ @Override
28
+ public String toString() {
29
+ return this.getValue().toString();
30
+ }
31
+
32
+ /**
33
+ * Returns the underlying value as a JSObject, or throwing if it cannot.
34
+ *
35
+ * @throws JSONException If the underlying value is not a JSObject.
36
+ */
37
+ public JSObject toJSObject() throws JSONException {
38
+ if (this.value instanceof JSObject) return (JSObject) this.value;
39
+ throw new JSONException("JSValue could not be coerced to JSObject.");
40
+ }
41
+
42
+ /**
43
+ * Returns the underlying value as a JSArray, or throwing if it cannot.
44
+ *
45
+ * @throws JSONException If the underlying value is not a JSArray.
46
+ */
47
+ public JSArray toJSArray() throws JSONException {
48
+ if (this.value instanceof JSArray) return (JSArray) this.value;
49
+ throw new JSONException("JSValue could not be coerced to JSArray.");
50
+ }
51
+
52
+ /**
53
+ * Returns the underlying value this object represents, coercing it into a capacitor-friendly object if supported.
54
+ */
55
+ private Object toValue(PluginCall call, String name) {
56
+ Object value = null;
57
+ value = call.getArray(name, null);
58
+ if (value != null) return value;
59
+ value = call.getObject(name, null);
60
+ if (value != null) return value;
61
+ value = call.getString(name, null);
62
+ if (value != null) return value;
63
+ return call.getData().opt(name);
64
+ }
65
+ }
@@ -0,0 +1,172 @@
1
+ package com.getcapacitor.plugin;
2
+
3
+ import java.net.CookieManager;
4
+ import java.net.CookiePolicy;
5
+ import java.net.CookieStore;
6
+ import java.net.HttpCookie;
7
+ import java.net.URI;
8
+ import java.util.ArrayList;
9
+ import java.util.Collections;
10
+ import java.util.HashMap;
11
+ import java.util.List;
12
+ import java.util.Map;
13
+ import java.util.Objects;
14
+
15
+ public class CapacitorCookieManager extends CookieManager {
16
+
17
+ private final android.webkit.CookieManager webkitCookieManager;
18
+
19
+ /**
20
+ * Create a new cookie manager with the default cookie store and policy
21
+ */
22
+ public CapacitorCookieManager() {
23
+ this(null, null);
24
+ }
25
+
26
+ /**
27
+ * Create a new cookie manager with specified cookie store and cookie policy.
28
+ * @param store a {@code CookieStore} to be used by CookieManager. if {@code null}, cookie
29
+ * manager will use a default one, which is an in-memory CookieStore implementation.
30
+ * @param policy a {@code CookiePolicy} instance to be used by cookie manager as policy
31
+ * callback. if {@code null}, ACCEPT_ORIGINAL_SERVER will be used.
32
+ */
33
+ public CapacitorCookieManager(CookieStore store, CookiePolicy policy) {
34
+ super(store, policy);
35
+ webkitCookieManager = android.webkit.CookieManager.getInstance();
36
+ }
37
+
38
+ /**
39
+ * Gets the cookies for the given URL.
40
+ * @param url the URL for which the cookies are requested
41
+ * @return value the cookies as a string, using the format of the 'Cookie' HTTP request header
42
+ */
43
+ public String getCookieString(String url) {
44
+ return webkitCookieManager.getCookie(url);
45
+ }
46
+
47
+ /**
48
+ * Gets a cookie value for the given URL and key.
49
+ * @param url the URL for which the cookies are requested
50
+ * @param key the key of the cookie to search for
51
+ * @return the {@code HttpCookie} value of the cookie at the key,
52
+ * otherwise it will return a new empty {@code HttpCookie}
53
+ */
54
+ public HttpCookie getCookie(String url, String key) {
55
+ HttpCookie[] cookies = getCookies(url);
56
+ for (HttpCookie cookie : cookies) {
57
+ if (cookie.getName().equals(key)) {
58
+ return cookie;
59
+ }
60
+ }
61
+
62
+ return null;
63
+ }
64
+
65
+ /**
66
+ * Gets an array of {@code HttpCookie} given a URL.
67
+ * @param url the URL for which the cookies are requested
68
+ * @return an {@code HttpCookie} array of non-expired cookies
69
+ */
70
+ public HttpCookie[] getCookies(String url) {
71
+ try {
72
+ ArrayList<HttpCookie> cookieList = new ArrayList<>();
73
+ String cookieString = getCookieString(url);
74
+ if (cookieString != null) {
75
+ String[] singleCookie = cookieString.split(";");
76
+ for (String c : singleCookie) {
77
+ HttpCookie parsed = HttpCookie.parse(c).get(0);
78
+ parsed.setValue(parsed.getValue());
79
+ cookieList.add(parsed);
80
+ }
81
+ }
82
+ HttpCookie[] cookies = new HttpCookie[cookieList.size()];
83
+ return cookieList.toArray(cookies);
84
+ } catch (Exception ex) {
85
+ return new HttpCookie[0];
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Sets a cookie for the given URL. Any existing cookie with the same host, path and name will
91
+ * be replaced with the new cookie. The cookie being set will be ignored if it is expired.
92
+ * @param url the URL for which the cookie is to be set
93
+ * @param value the cookie as a string, using the format of the 'Set-Cookie' HTTP response header
94
+ */
95
+ public void setCookie(String url, String value) {
96
+ webkitCookieManager.setCookie(url, value);
97
+ flush();
98
+ }
99
+
100
+ /**
101
+ * Sets a cookie for the given URL. Any existing cookie with the same host, path and name will
102
+ * be replaced with the new cookie. The cookie being set will be ignored if it is expired.
103
+ * @param url the URL for which the cookie is to be set
104
+ * @param key the {@code HttpCookie} name to use for lookup
105
+ * @param value the value of the {@code HttpCookie} given a key
106
+ */
107
+ public void setCookie(String url, String key, String value) {
108
+ String cookieValue = key + "=" + value;
109
+ setCookie(url, cookieValue);
110
+ }
111
+
112
+ /**
113
+ * Removes all cookies. This method is asynchronous.
114
+ */
115
+ public void removeAllCookies() {
116
+ webkitCookieManager.removeAllCookies(null);
117
+ flush();
118
+ }
119
+
120
+ /**
121
+ * Ensures all cookies currently accessible through the getCookie API are written to persistent
122
+ * storage. This call will block the caller until it is done and may perform I/O.
123
+ */
124
+ public void flush() {
125
+ webkitCookieManager.flush();
126
+ }
127
+
128
+ @Override
129
+ public void put(URI uri, Map<String, List<String>> responseHeaders) {
130
+ // make sure our args are valid
131
+ if ((uri == null) || (responseHeaders == null)) return;
132
+
133
+ // save our url once
134
+ String url = uri.toString();
135
+
136
+ // go over the headers
137
+ for (String headerKey : responseHeaders.keySet()) {
138
+ // ignore headers which aren't cookie related
139
+ if ((headerKey == null) || !(headerKey.equalsIgnoreCase("Set-Cookie2") || headerKey.equalsIgnoreCase("Set-Cookie"))) continue;
140
+
141
+ // process each of the headers
142
+ for (String headerValue : Objects.requireNonNull(responseHeaders.get(headerKey))) {
143
+ setCookie(url, headerValue);
144
+ }
145
+ }
146
+ }
147
+
148
+ @Override
149
+ public Map<String, List<String>> get(URI uri, Map<String, List<String>> requestHeaders) {
150
+ // make sure our args are valid
151
+ if ((uri == null) || (requestHeaders == null)) throw new IllegalArgumentException("Argument is null");
152
+
153
+ // save our url once
154
+ String url = uri.toString();
155
+
156
+ // prepare our response
157
+ Map<String, List<String>> res = new HashMap<>();
158
+
159
+ // get the cookie
160
+ String cookie = getCookieString(url);
161
+
162
+ // return it
163
+ if (cookie != null) res.put("Cookie", Collections.singletonList(cookie));
164
+ return res;
165
+ }
166
+
167
+ @Override
168
+ public CookieStore getCookieStore() {
169
+ // we don't want anyone to work with this cookie store directly
170
+ throw new UnsupportedOperationException();
171
+ }
172
+ }
@@ -0,0 +1,122 @@
1
+ package com.getcapacitor.plugin;
2
+
3
+ import android.app.Activity;
4
+ import android.content.SharedPreferences;
5
+ import android.util.Log;
6
+ import android.webkit.JavascriptInterface;
7
+ import androidx.annotation.Nullable;
8
+ import com.getcapacitor.CapConfig;
9
+ import com.getcapacitor.JSObject;
10
+ import com.getcapacitor.Plugin;
11
+ import com.getcapacitor.PluginCall;
12
+ import com.getcapacitor.PluginMethod;
13
+ import com.getcapacitor.annotation.CapacitorPlugin;
14
+ import java.net.CookieHandler;
15
+ import java.net.HttpCookie;
16
+ import java.net.URI;
17
+
18
+ @CapacitorPlugin
19
+ public class CapacitorCookies extends Plugin {
20
+
21
+ CapacitorCookieManager cookieManager;
22
+
23
+ @Override
24
+ public void load() {
25
+ this.bridge.getWebView().addJavascriptInterface(this, "CapacitorCookiesAndroidInterface");
26
+ this.cookieManager = new CapacitorCookieManager(null, java.net.CookiePolicy.ACCEPT_ALL);
27
+ CookieHandler.setDefault(cookieManager);
28
+ super.load();
29
+ }
30
+
31
+ /**
32
+ * Helper function for getting the serverUrl from the Capacitor Config. Returns an empty
33
+ * string if it is invalid and will auto-reject through {@code call}
34
+ * @param call the {@code PluginCall} context
35
+ * @return the string of the server specified in the Capacitor config
36
+ */
37
+ private String getServerUrl(@Nullable PluginCall call) {
38
+ String url = (call == null) ? this.bridge.getServerUrl() : call.getString("url", this.bridge.getServerUrl());
39
+
40
+ if (url == null || url.isEmpty()) {
41
+ url = this.bridge.getLocalUrl();
42
+ }
43
+
44
+ URI uri = getUri(url);
45
+ if (uri == null) {
46
+ if (call != null) {
47
+ call.reject("Invalid URL. Check that \"server\" is passed in correctly");
48
+ }
49
+
50
+ return "";
51
+ }
52
+
53
+ return url;
54
+ }
55
+
56
+ /**
57
+ * Try to parse a url string and if it can't be parsed, return null
58
+ * @param url the url string to try to parse
59
+ * @return a parsed URI
60
+ */
61
+ private URI getUri(String url) {
62
+ try {
63
+ return new URI(url);
64
+ } catch (Exception ex) {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ @JavascriptInterface
70
+ public String getCookies() {
71
+ try {
72
+ String url = getServerUrl(null);
73
+ if (!url.isEmpty()) {
74
+ return cookieManager.getCookieString(url);
75
+ }
76
+ } catch (Exception e) {
77
+ e.printStackTrace();
78
+ }
79
+
80
+ return "";
81
+ }
82
+
83
+ @PluginMethod
84
+ public void setCookie(PluginCall call) {
85
+ String key = call.getString("key");
86
+ String value = call.getString("value");
87
+ String url = getServerUrl(call);
88
+
89
+ if (!url.isEmpty()) {
90
+ cookieManager.setCookie(url, key, value);
91
+ call.resolve();
92
+ }
93
+ }
94
+
95
+ @PluginMethod
96
+ public void deleteCookie(PluginCall call) {
97
+ String key = call.getString("key");
98
+ String url = getServerUrl(call);
99
+ if (!url.isEmpty()) {
100
+ cookieManager.setCookie(url, key + "=; Expires=Wed, 31 Dec 2000 23:59:59 GMT");
101
+ call.resolve();
102
+ }
103
+ }
104
+
105
+ @PluginMethod
106
+ public void clearCookies(PluginCall call) {
107
+ String url = getServerUrl(call);
108
+ if (!url.isEmpty()) {
109
+ HttpCookie[] cookies = cookieManager.getCookies(url);
110
+ for (HttpCookie cookie : cookies) {
111
+ cookieManager.setCookie(url, cookie.getName() + "=; Expires=Wed, 31 Dec 2000 23:59:59 GMT");
112
+ }
113
+ call.resolve();
114
+ }
115
+ }
116
+
117
+ @PluginMethod
118
+ public void clearAllCookies(PluginCall call) {
119
+ cookieManager.removeAllCookies();
120
+ call.resolve();
121
+ }
122
+ }
@@ -0,0 +1,75 @@
1
+ package com.getcapacitor.plugin;
2
+
3
+ import android.Manifest;
4
+ import android.webkit.JavascriptInterface;
5
+ import com.getcapacitor.CapConfig;
6
+ import com.getcapacitor.JSObject;
7
+ import com.getcapacitor.Plugin;
8
+ import com.getcapacitor.PluginCall;
9
+ import com.getcapacitor.PluginConfig;
10
+ import com.getcapacitor.PluginMethod;
11
+ import com.getcapacitor.annotation.CapacitorPlugin;
12
+ import com.getcapacitor.annotation.Permission;
13
+ import com.getcapacitor.plugin.util.HttpRequestHandler;
14
+
15
+ @CapacitorPlugin(
16
+ permissions = {
17
+ @Permission(strings = { Manifest.permission.WRITE_EXTERNAL_STORAGE }, alias = "HttpWrite"),
18
+ @Permission(strings = { Manifest.permission.READ_EXTERNAL_STORAGE }, alias = "HttpRead")
19
+ }
20
+ )
21
+ public class CapacitorHttp extends Plugin {
22
+
23
+ private void http(final PluginCall call, final String httpMethod) {
24
+ Runnable asyncHttpCall = new Runnable() {
25
+ @Override
26
+ public void run() {
27
+ try {
28
+ JSObject response = HttpRequestHandler.request(call, httpMethod);
29
+ call.resolve(response);
30
+ } catch (Exception e) {
31
+ System.out.println(e.toString());
32
+ call.reject(e.getClass().getSimpleName(), e);
33
+ }
34
+ }
35
+ };
36
+ Thread httpThread = new Thread(asyncHttpCall);
37
+ httpThread.start();
38
+ }
39
+
40
+ @JavascriptInterface
41
+ public boolean isDisabled() {
42
+ PluginConfig pluginConfig = getBridge().getConfig().getPluginConfiguration("CapacitorHttp");
43
+ return pluginConfig.getBoolean("disabled", false);
44
+ }
45
+
46
+ @PluginMethod
47
+ public void request(final PluginCall call) {
48
+ this.http(call, null);
49
+ }
50
+
51
+ @PluginMethod
52
+ public void get(final PluginCall call) {
53
+ this.http(call, "GET");
54
+ }
55
+
56
+ @PluginMethod
57
+ public void post(final PluginCall call) {
58
+ this.http(call, "POST");
59
+ }
60
+
61
+ @PluginMethod
62
+ public void put(final PluginCall call) {
63
+ this.http(call, "PUT");
64
+ }
65
+
66
+ @PluginMethod
67
+ public void patch(final PluginCall call) {
68
+ this.http(call, "PATCH");
69
+ }
70
+
71
+ @PluginMethod
72
+ public void delete(final PluginCall call) {
73
+ this.http(call, "DELETE");
74
+ }
75
+ }