@webority/mobile-core 0.13.0 → 0.14.2
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/lib/commonjs/utils/imageCompression.js +1 -1
- package/lib/commonjs/utils/index.js +1 -1
- package/lib/module/utils/imageCompression.js +1 -1
- package/lib/module/utils/index.js +1 -1
- package/lib/typescript/commonjs/utils/imageCompression.d.ts +14 -10
- package/lib/typescript/commonjs/utils/index.d.ts +1 -1
- package/lib/typescript/module/utils/imageCompression.d.ts +14 -10
- package/lib/typescript/module/utils/index.d.ts +1 -1
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.setImageCompressorImplementation=exports.qualityForFileSize=exports.compressImage=exports.ImageUnreadableError=void 0;var _expoFileSystem=require("expo-file-system"),_expoImageManipulator=require("expo-image-manipulator"),_index=require("../logger/index.js");class ImageUnreadableError extends Error{constructor(e,t){super(`Image is unreadable (${t}): ${e||"<empty uri>"}`),this.name="ImageUnreadableError",this.uri=e}}exports.ImageUnreadableError=ImageUnreadableError;const release=e=>{try{e?.release?.()}catch{}},expoBackend={stat:async e=>{const t=new _expoFileSystem.File(e);return t.exists?null==t.size?null:{exists:!0,size:t.size}:{exists:!1,size:0}},remove:async e=>{new _expoFileSystem.File(e).delete()},open:async e=>{const t=_expoImageManipulator.ImageManipulator.manipulate(e);let i;try{i=await t.renderAsync()}finally{release(t)}return{width:i.width,height:i.height,encode:async({width:e,height:t,quality:
|
|
1
|
+
"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.setImageCompressorImplementation=exports.qualityForFileSize=exports.compressImage=exports.ImageUnreadableError=void 0;var _expoFileSystem=require("expo-file-system"),_expoImageManipulator=require("expo-image-manipulator"),_index=require("../logger/index.js");class ImageUnreadableError extends Error{constructor(e,t){super(`Image is unreadable (${t}): ${e||"<empty uri>"}`),this.name="ImageUnreadableError",this.uri=e}}exports.ImageUnreadableError=ImageUnreadableError;const release=e=>{try{e?.release?.()}catch{}},expoBackend={stat:async e=>{const t=new _expoFileSystem.File(e);return t.exists?null==t.size?null:{exists:!0,size:t.size}:{exists:!1,size:0}},remove:async e=>{new _expoFileSystem.File(e).delete()},open:async e=>{const t=_expoImageManipulator.ImageManipulator.manipulate(e);let i;try{i=await t.renderAsync()}finally{release(t)}return{width:i.width,height:i.height,encode:async({width:e,height:t,quality:r,format:a})=>{const n=_expoImageManipulator.ImageManipulator.manipulate(i);let o=null;try{void 0===e&&void 0===t||n.resize({width:e??null,height:t??null}),o=await n.renderAsync();const i=await o.saveAsync({compress:r,format:a});return{uri:i.uri,width:i.width,height:i.height}}finally{release(o),release(n)}},close:()=>release(i)}}};let backend=null;const setImageCompressorImplementation=e=>{backend=e};exports.setImageCompressorImplementation=setImageCompressorImplementation;const resolveBackend=()=>backend??expoBackend,EIGHT_MB=8388608,THREE_MB=3145728,qualityForFileSize=e=>null==e?.7:e>8388608?.6:e>3145728?.7:.8;exports.qualityForFileSize=qualityForFileSize;const DEFAULT_MAX_SIZE=1500,DEFAULT_MIN_QUALITY=.4,QUALITY_STEP=.1,SIZE_STEP=.75,MIN_SIDE=320,FORMAT={jpg:{encode:"jpeg",mimeType:"image/jpeg"},png:{encode:"png",mimeType:"image/png"},webp:{encode:"webp",mimeType:"image/webp"}},MIME_BY_EXTENSION={jpg:"image/jpeg",jpeg:"image/jpeg",png:"image/png",webp:"image/webp",gif:"image/gif",heic:"image/heic",heif:"image/heif",bmp:"image/bmp",tif:"image/tiff",tiff:"image/tiff"},lastSegment=e=>{const t=e.split(/[?#]/)[0]??"";return decodeURIComponentSafe(t.slice(t.lastIndexOf("/")+1))},decodeURIComponentSafe=e=>{try{return decodeURIComponent(e)}catch{return e}},extensionOf=e=>{const t=e.lastIndexOf(".");return t>0?e.slice(t+1).toLowerCase():""},withoutExtension=e=>{const t=e.lastIndexOf(".");return t>0?e.slice(0,t):e},baseNameFor=(e,t)=>withoutExtension(t?.trim()||lastSegment(e))||"image",round1=e=>Math.round(10*e)/10,safeStat=async(e,t)=>{try{return await e.stat(t)}catch{return null}},safeRemove=async(e,t)=>{try{await e.remove(t)}catch{}},renderWithin=async(e,t,i,r,a)=>{const n=Math.max(t.width,t.height)>i?t.width>=t.height?{width:i}:{height:i}:{},o=await t.encode({...n,quality:r,format:a}),s=await safeStat(e,o.uri);if(s&&(!s.exists||0===s.size))throw await safeRemove(e,o.uri),new ImageUnreadableError(o.uri,"the encoder wrote an empty file");return{...o,size:s?.size??null}},originalResult=(e,t,i,r)=>{const a=extensionOf(lastSegment(e))||"jpg",n=MIME_BY_EXTENSION[a]??"image/jpeg",o=`${i}.${a}`;return{uri:e,width:r?.width??null,height:r?.height??null,size:t,mimeType:n,extension:a,fileName:o,compressed:!1,quality:null,part:{uri:e,name:o,type:n}}},compressImage=async(e,t={})=>{if(!e)throw new ImageUnreadableError(e,"no uri");const i=resolveBackend(),{maxSize:r=DEFAULT_MAX_SIZE,output:a="jpg",maxBytes:n,minQuality:o=DEFAULT_MIN_QUALITY,name:s}=t,l=FORMAT[a]??FORMAT.jpg,m=baseNameFor(e,s),g=await safeStat(i,e);if(g&&!g.exists)throw new ImageUnreadableError(e,"the file does not exist");if(g&&0===g.size)throw new ImageUnreadableError(e,"the file is empty");const p=g?.size??t.fileSize??null;let d;try{d=await i.open(e)}catch(t){throw _index.Logger.error("[imageCompression] could not decode the image",t),new ImageUnreadableError(e,"the photo could not be decoded")}try{let e=t.quality??qualityForFileSize(p),s=r,g=await renderWithin(i,d,s,e,l.encode);if(void 0!==n)for(;null!==g.size&&g.size>n;){if("png"!==a&&round1(e-.1)>=o)e=round1(e-.1);else{const e=Math.max(g.width,g.height);if(e<=320)break;s=Math.max(320,Math.floor(.75*e))}const t=await renderWithin(i,d,s,e,l.encode);await safeRemove(i,g.uri),g=t}const h=`${m}.${a}`;return{uri:g.uri,width:g.width,height:g.height,size:g.size,mimeType:l.mimeType,extension:a,fileName:h,compressed:!0,quality:"png"===a?null:e,part:{uri:g.uri,name:h,type:l.mimeType}}}catch(t){if(t instanceof ImageUnreadableError)throw t;_index.Logger.error("[imageCompression] encoding failed; retrying once as jpeg at quality 0.7",t);try{const e=await renderWithin(i,d,r,.7,"jpeg"),t=`${m}.jpg`;return{uri:e.uri,width:e.width,height:e.height,size:e.size,mimeType:"image/jpeg",extension:"jpg",fileName:t,compressed:!0,quality:.7,part:{uri:e.uri,name:t,type:"image/jpeg"}}}catch(t){if(t instanceof ImageUnreadableError)throw t;_index.Logger.error("[imageCompression] jpeg retry failed",t);if(Math.max(d.width,d.height)<=r&&(void 0===n||null!==p&&p<=n))return originalResult(e,p,m,{width:d.width,height:d.height});throw new ImageUnreadableError(e,"the photo could not be prepared for upload")}}finally{d.close()}};exports.compressImage=compressImage;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),Object.defineProperty(exports,"compressImage",{enumerable:!0,get:function(){return _imageCompression.compressImage}}),Object.defineProperty(exports,"setImageCompressorImplementation",{enumerable:!0,get:function(){return _imageCompression.setImageCompressorImplementation}});var _imageCompression=require("./imageCompression.js");
|
|
1
|
+
"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),Object.defineProperty(exports,"ImageUnreadableError",{enumerable:!0,get:function(){return _imageCompression.ImageUnreadableError}}),Object.defineProperty(exports,"compressImage",{enumerable:!0,get:function(){return _imageCompression.compressImage}}),Object.defineProperty(exports,"setImageCompressorImplementation",{enumerable:!0,get:function(){return _imageCompression.setImageCompressorImplementation}});var _imageCompression=require("./imageCompression.js");
|
|
@@ -1 +1 @@
|
|
|
1
|
-
"use strict";import{File}from"expo-file-system";import{ImageManipulator}from"expo-image-manipulator";import{Logger}from"../logger/index.js";export class ImageUnreadableError extends Error{constructor(e,t){super(`Image is unreadable (${t}): ${e||"<empty uri>"}`),this.name="ImageUnreadableError",this.uri=e}}const release=e=>{try{e?.release?.()}catch{}},expoBackend={stat:async e=>{const t=new File(e);return t.exists?null==t.size?null:{exists:!0,size:t.size}:{exists:!1,size:0}},remove:async e=>{new File(e).delete()},open:async e=>{const t=ImageManipulator.manipulate(e);let i;try{i=await t.renderAsync()}finally{release(t)}return{width:i.width,height:i.height,encode:async({width:e,height:t,quality:a,format:
|
|
1
|
+
"use strict";import{File}from"expo-file-system";import{ImageManipulator}from"expo-image-manipulator";import{Logger}from"../logger/index.js";export class ImageUnreadableError extends Error{constructor(e,t){super(`Image is unreadable (${t}): ${e||"<empty uri>"}`),this.name="ImageUnreadableError",this.uri=e}}const release=e=>{try{e?.release?.()}catch{}},expoBackend={stat:async e=>{const t=new File(e);return t.exists?null==t.size?null:{exists:!0,size:t.size}:{exists:!1,size:0}},remove:async e=>{new File(e).delete()},open:async e=>{const t=ImageManipulator.manipulate(e);let i;try{i=await t.renderAsync()}finally{release(t)}return{width:i.width,height:i.height,encode:async({width:e,height:t,quality:a,format:r})=>{const n=ImageManipulator.manipulate(i);let o=null;try{void 0===e&&void 0===t||n.resize({width:e??null,height:t??null}),o=await n.renderAsync();const i=await o.saveAsync({compress:a,format:r});return{uri:i.uri,width:i.width,height:i.height}}finally{release(o),release(n)}},close:()=>release(i)}}};let backend=null;export const setImageCompressorImplementation=e=>{backend=e};const resolveBackend=()=>backend??expoBackend,EIGHT_MB=8388608,THREE_MB=3145728;export const qualityForFileSize=e=>null==e?.7:e>8388608?.6:e>3145728?.7:.8;const DEFAULT_MAX_SIZE=1500,DEFAULT_MIN_QUALITY=.4,QUALITY_STEP=.1,SIZE_STEP=.75,MIN_SIDE=320,FORMAT={jpg:{encode:"jpeg",mimeType:"image/jpeg"},png:{encode:"png",mimeType:"image/png"},webp:{encode:"webp",mimeType:"image/webp"}},MIME_BY_EXTENSION={jpg:"image/jpeg",jpeg:"image/jpeg",png:"image/png",webp:"image/webp",gif:"image/gif",heic:"image/heic",heif:"image/heif",bmp:"image/bmp",tif:"image/tiff",tiff:"image/tiff"},lastSegment=e=>{const t=e.split(/[?#]/)[0]??"";return decodeURIComponentSafe(t.slice(t.lastIndexOf("/")+1))},decodeURIComponentSafe=e=>{try{return decodeURIComponent(e)}catch{return e}},extensionOf=e=>{const t=e.lastIndexOf(".");return t>0?e.slice(t+1).toLowerCase():""},withoutExtension=e=>{const t=e.lastIndexOf(".");return t>0?e.slice(0,t):e},baseNameFor=(e,t)=>withoutExtension(t?.trim()||lastSegment(e))||"image",round1=e=>Math.round(10*e)/10,safeStat=async(e,t)=>{try{return await e.stat(t)}catch{return null}},safeRemove=async(e,t)=>{try{await e.remove(t)}catch{}},renderWithin=async(e,t,i,a,r)=>{const n=Math.max(t.width,t.height)>i?t.width>=t.height?{width:i}:{height:i}:{},o=await t.encode({...n,quality:a,format:r}),s=await safeStat(e,o.uri);if(s&&(!s.exists||0===s.size))throw await safeRemove(e,o.uri),new ImageUnreadableError(o.uri,"the encoder wrote an empty file");return{...o,size:s?.size??null}},originalResult=(e,t,i,a)=>{const r=extensionOf(lastSegment(e))||"jpg",n=MIME_BY_EXTENSION[r]??"image/jpeg",o=`${i}.${r}`;return{uri:e,width:a?.width??null,height:a?.height??null,size:t,mimeType:n,extension:r,fileName:o,compressed:!1,quality:null,part:{uri:e,name:o,type:n}}};export const compressImage=async(e,t={})=>{if(!e)throw new ImageUnreadableError(e,"no uri");const i=resolveBackend(),{maxSize:a=DEFAULT_MAX_SIZE,output:r="jpg",maxBytes:n,minQuality:o=DEFAULT_MIN_QUALITY,name:s}=t,l=FORMAT[r]??FORMAT.jpg,m=baseNameFor(e,s),g=await safeStat(i,e);if(g&&!g.exists)throw new ImageUnreadableError(e,"the file does not exist");if(g&&0===g.size)throw new ImageUnreadableError(e,"the file is empty");const h=g?.size??t.fileSize??null;let c;try{c=await i.open(e)}catch(t){throw Logger.error("[imageCompression] could not decode the image",t),new ImageUnreadableError(e,"the photo could not be decoded")}try{let e=t.quality??qualityForFileSize(h),s=a,g=await renderWithin(i,c,s,e,l.encode);if(void 0!==n)for(;null!==g.size&&g.size>n;){if("png"!==r&&round1(e-.1)>=o)e=round1(e-.1);else{const e=Math.max(g.width,g.height);if(e<=320)break;s=Math.max(320,Math.floor(.75*e))}const t=await renderWithin(i,c,s,e,l.encode);await safeRemove(i,g.uri),g=t}const p=`${m}.${r}`;return{uri:g.uri,width:g.width,height:g.height,size:g.size,mimeType:l.mimeType,extension:r,fileName:p,compressed:!0,quality:"png"===r?null:e,part:{uri:g.uri,name:p,type:l.mimeType}}}catch(t){if(t instanceof ImageUnreadableError)throw t;Logger.error("[imageCompression] encoding failed; retrying once as jpeg at quality 0.7",t);try{const e=await renderWithin(i,c,a,.7,"jpeg"),t=`${m}.jpg`;return{uri:e.uri,width:e.width,height:e.height,size:e.size,mimeType:"image/jpeg",extension:"jpg",fileName:t,compressed:!0,quality:.7,part:{uri:e.uri,name:t,type:"image/jpeg"}}}catch(t){if(t instanceof ImageUnreadableError)throw t;Logger.error("[imageCompression] jpeg retry failed",t);if(Math.max(c.width,c.height)<=a&&(void 0===n||null!==h&&h<=n))return originalResult(e,h,m,{width:c.width,height:c.height});throw new ImageUnreadableError(e,"the photo could not be prepared for upload")}}finally{c.close()}};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
"use strict";export{compressImage,setImageCompressorImplementation}from"./imageCompression.js";
|
|
1
|
+
"use strict";export{compressImage,ImageUnreadableError,setImageCompressorImplementation}from"./imageCompression.js";
|
|
@@ -21,10 +21,11 @@
|
|
|
21
21
|
* an upload needs: uri, pixel size, byte size, mime type, extension, file
|
|
22
22
|
* name and a ready `FormData` part.
|
|
23
23
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
24
|
+
* An encoder failure is retried once as a jpeg at quality 0.7. If the retry
|
|
25
|
+
* also fails, the original file is uploaded only when it already fits
|
|
26
|
+
* `maxSize` and `maxBytes`, reported through `compressed: false`. Otherwise the
|
|
27
|
+
* call throws `ImageUnreadableError`, because the server would refuse the
|
|
28
|
+
* original. A file that cannot be decoded always throws; it is never a fallback.
|
|
28
29
|
*
|
|
29
30
|
* Backed by `expo-image-manipulator` and `expo-file-system`; an app wires
|
|
30
31
|
* nothing. Tests and the showcase inject a backend through
|
|
@@ -67,7 +68,7 @@ export interface CompressImageOptions {
|
|
|
67
68
|
export interface CompressedImage {
|
|
68
69
|
/** Local URI of the file to upload. */
|
|
69
70
|
uri: string;
|
|
70
|
-
/** Pixel size;
|
|
71
|
+
/** Pixel size of the uploaded file; always known, for an original too. */
|
|
71
72
|
width: number | null;
|
|
72
73
|
height: number | null;
|
|
73
74
|
/** Byte size on disk; null only for an uncompressed original that could not be measured. */
|
|
@@ -78,7 +79,10 @@ export interface CompressedImage {
|
|
|
78
79
|
extension: string;
|
|
79
80
|
/** `<name>.<extension>`. */
|
|
80
81
|
fileName: string;
|
|
81
|
-
/**
|
|
82
|
+
/**
|
|
83
|
+
* False only for the untouched original, returned when the encoder failed
|
|
84
|
+
* and the original already fits `maxSize` (and `maxBytes`).
|
|
85
|
+
*/
|
|
82
86
|
compressed: boolean;
|
|
83
87
|
/** The quality the output was encoded at; null for PNG and for an original. */
|
|
84
88
|
quality: number | null;
|
|
@@ -89,7 +93,7 @@ export interface CompressedImage {
|
|
|
89
93
|
type: string;
|
|
90
94
|
};
|
|
91
95
|
}
|
|
92
|
-
/** The
|
|
96
|
+
/** The photo was missing, empty or undecodable, or could not be prepared for upload. */
|
|
93
97
|
export declare class ImageUnreadableError extends Error {
|
|
94
98
|
readonly uri: string;
|
|
95
99
|
constructor(uri: string, reason: string);
|
|
@@ -144,9 +148,9 @@ export declare const qualityForFileSize: (fileSize?: number | null) => number;
|
|
|
144
148
|
/**
|
|
145
149
|
* Compress an image for upload. See the module comment for the contract.
|
|
146
150
|
*
|
|
147
|
-
* @throws ImageUnreadableError when the source is missing
|
|
148
|
-
* encoder
|
|
149
|
-
*
|
|
151
|
+
* @throws ImageUnreadableError when the source is missing, empty or cannot be
|
|
152
|
+
* decoded; when the encoder fails twice and the original is too large to upload
|
|
153
|
+
* as it is; or when the encoder writes an empty file.
|
|
150
154
|
*/
|
|
151
155
|
export declare const compressImage: (uri: string, options?: CompressImageOptions) => Promise<CompressedImage>;
|
|
152
156
|
//# sourceMappingURL=imageCompression.d.ts.map
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export type { CompressImageOptions, ImageCompressorLike } from './imageCompression';
|
|
2
|
-
export { compressImage, setImageCompressorImplementation } from './imageCompression';
|
|
2
|
+
export { compressImage, ImageUnreadableError, setImageCompressorImplementation } from './imageCompression';
|
|
3
3
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -21,10 +21,11 @@
|
|
|
21
21
|
* an upload needs: uri, pixel size, byte size, mime type, extension, file
|
|
22
22
|
* name and a ready `FormData` part.
|
|
23
23
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
24
|
+
* An encoder failure is retried once as a jpeg at quality 0.7. If the retry
|
|
25
|
+
* also fails, the original file is uploaded only when it already fits
|
|
26
|
+
* `maxSize` and `maxBytes`, reported through `compressed: false`. Otherwise the
|
|
27
|
+
* call throws `ImageUnreadableError`, because the server would refuse the
|
|
28
|
+
* original. A file that cannot be decoded always throws; it is never a fallback.
|
|
28
29
|
*
|
|
29
30
|
* Backed by `expo-image-manipulator` and `expo-file-system`; an app wires
|
|
30
31
|
* nothing. Tests and the showcase inject a backend through
|
|
@@ -67,7 +68,7 @@ export interface CompressImageOptions {
|
|
|
67
68
|
export interface CompressedImage {
|
|
68
69
|
/** Local URI of the file to upload. */
|
|
69
70
|
uri: string;
|
|
70
|
-
/** Pixel size;
|
|
71
|
+
/** Pixel size of the uploaded file; always known, for an original too. */
|
|
71
72
|
width: number | null;
|
|
72
73
|
height: number | null;
|
|
73
74
|
/** Byte size on disk; null only for an uncompressed original that could not be measured. */
|
|
@@ -78,7 +79,10 @@ export interface CompressedImage {
|
|
|
78
79
|
extension: string;
|
|
79
80
|
/** `<name>.<extension>`. */
|
|
80
81
|
fileName: string;
|
|
81
|
-
/**
|
|
82
|
+
/**
|
|
83
|
+
* False only for the untouched original, returned when the encoder failed
|
|
84
|
+
* and the original already fits `maxSize` (and `maxBytes`).
|
|
85
|
+
*/
|
|
82
86
|
compressed: boolean;
|
|
83
87
|
/** The quality the output was encoded at; null for PNG and for an original. */
|
|
84
88
|
quality: number | null;
|
|
@@ -89,7 +93,7 @@ export interface CompressedImage {
|
|
|
89
93
|
type: string;
|
|
90
94
|
};
|
|
91
95
|
}
|
|
92
|
-
/** The
|
|
96
|
+
/** The photo was missing, empty or undecodable, or could not be prepared for upload. */
|
|
93
97
|
export declare class ImageUnreadableError extends Error {
|
|
94
98
|
readonly uri: string;
|
|
95
99
|
constructor(uri: string, reason: string);
|
|
@@ -144,9 +148,9 @@ export declare const qualityForFileSize: (fileSize?: number | null) => number;
|
|
|
144
148
|
/**
|
|
145
149
|
* Compress an image for upload. See the module comment for the contract.
|
|
146
150
|
*
|
|
147
|
-
* @throws ImageUnreadableError when the source is missing
|
|
148
|
-
* encoder
|
|
149
|
-
*
|
|
151
|
+
* @throws ImageUnreadableError when the source is missing, empty or cannot be
|
|
152
|
+
* decoded; when the encoder fails twice and the original is too large to upload
|
|
153
|
+
* as it is; or when the encoder writes an empty file.
|
|
150
154
|
*/
|
|
151
155
|
export declare const compressImage: (uri: string, options?: CompressImageOptions) => Promise<CompressedImage>;
|
|
152
156
|
//# sourceMappingURL=imageCompression.d.ts.map
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export type { CompressImageOptions, ImageCompressorLike } from './imageCompression.js';
|
|
2
|
-
export { compressImage, setImageCompressorImplementation } from './imageCompression.js';
|
|
2
|
+
export { compressImage, ImageUnreadableError, setImageCompressorImplementation } from './imageCompression.js';
|
|
3
3
|
//# sourceMappingURL=index.d.ts.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webority/mobile-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.2",
|
|
4
4
|
"description": "Platform layer for Webority React Native apps: HTTP clients, auth/token storage, config, logging, formatters, validators, network status, permissions, storage, version checks and over-the-air updates. No UI.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"react-native",
|