uploados 0.1.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.
Files changed (66) hide show
  1. package/android/build.gradle +25 -0
  2. package/android/src/main/AndroidManifest.xml +15 -0
  3. package/android/src/main/java/expo/modules/uploados/UploadosModule.kt +121 -0
  4. package/android/src/main/java/expo/modules/uploados/upload/CompressionPipeline.kt +192 -0
  5. package/android/src/main/java/expo/modules/uploados/upload/FileStager.kt +125 -0
  6. package/android/src/main/java/expo/modules/uploados/upload/ProgressRequestBody.kt +36 -0
  7. package/android/src/main/java/expo/modules/uploados/upload/UploadManager.kt +857 -0
  8. package/android/src/main/java/expo/modules/uploados/upload/UploadModels.kt +209 -0
  9. package/android/src/main/java/expo/modules/uploados/upload/UploadNotificationHelper.kt +93 -0
  10. package/android/src/main/java/expo/modules/uploados/upload/UploadTaskStore.kt +224 -0
  11. package/android/src/main/java/expo/modules/uploados/upload/UploadWorker.kt +31 -0
  12. package/build/Uploados.types.d.ts +226 -0
  13. package/build/Uploados.types.d.ts.map +1 -0
  14. package/build/Uploados.types.js +2 -0
  15. package/build/Uploados.types.js.map +1 -0
  16. package/build/UploadosModule.d.ts +13 -0
  17. package/build/UploadosModule.d.ts.map +1 -0
  18. package/build/UploadosModule.js +3 -0
  19. package/build/UploadosModule.js.map +1 -0
  20. package/build/UploadosModule.web.d.ts +13 -0
  21. package/build/UploadosModule.web.d.ts.map +1 -0
  22. package/build/UploadosModule.web.js +33 -0
  23. package/build/UploadosModule.web.js.map +1 -0
  24. package/build/createUploader.d.ts +3 -0
  25. package/build/createUploader.d.ts.map +1 -0
  26. package/build/createUploader.js +108 -0
  27. package/build/createUploader.js.map +1 -0
  28. package/build/index.d.ts +5 -0
  29. package/build/index.d.ts.map +1 -0
  30. package/build/index.js +5 -0
  31. package/build/index.js.map +1 -0
  32. package/build/normalizeUploadOptions.d.ts +9 -0
  33. package/build/normalizeUploadOptions.d.ts.map +1 -0
  34. package/build/normalizeUploadOptions.js +81 -0
  35. package/build/normalizeUploadOptions.js.map +1 -0
  36. package/build/providers/defineUploadProvider.d.ts +26 -0
  37. package/build/providers/defineUploadProvider.d.ts.map +1 -0
  38. package/build/providers/defineUploadProvider.js +39 -0
  39. package/build/providers/defineUploadProvider.js.map +1 -0
  40. package/build/providers/multipartPlan.d.ts +10 -0
  41. package/build/providers/multipartPlan.d.ts.map +1 -0
  42. package/build/providers/multipartPlan.js +28 -0
  43. package/build/providers/multipartPlan.js.map +1 -0
  44. package/eslint.config.cjs +5 -0
  45. package/expo-module.config.json +10 -0
  46. package/ios/Upload/CompressionPipeline.swift +183 -0
  47. package/ios/Upload/FileStager.swift +67 -0
  48. package/ios/Upload/UploadManager.swift +813 -0
  49. package/ios/Upload/UploadModels.swift +305 -0
  50. package/ios/Upload/UploadSessionDelegate.swift +82 -0
  51. package/ios/Upload/UploadTaskStore.swift +92 -0
  52. package/ios/Upload/UploadosAppDelegate.swift +14 -0
  53. package/ios/Uploados.podspec +23 -0
  54. package/ios/UploadosModule.swift +87 -0
  55. package/jest.config.js +15 -0
  56. package/package.json +54 -0
  57. package/readme.md +169 -0
  58. package/src/Uploados.types.ts +260 -0
  59. package/src/UploadosModule.ts +18 -0
  60. package/src/UploadosModule.web.ts +49 -0
  61. package/src/createUploader.ts +146 -0
  62. package/src/index.ts +4 -0
  63. package/src/normalizeUploadOptions.ts +132 -0
  64. package/src/providers/defineUploadProvider.ts +75 -0
  65. package/src/providers/multipartPlan.ts +43 -0
  66. package/tsconfig.json +42 -0
@@ -0,0 +1,26 @@
1
+ import type { CompleteMultipartInput, CompleteMultipartResult, CreateMultipartInput, CreateMultipartResult, SignPartInput, SignedPartResult, UploadHttpMethod, UploadProvider } from '../Uploados.types';
2
+ export type ProviderUploadUrlInput = {
3
+ key: string;
4
+ contentType?: string;
5
+ metadata?: Record<string, string>;
6
+ };
7
+ export type ProviderUploadUrlResult = {
8
+ url: string;
9
+ method?: UploadHttpMethod;
10
+ headers?: Record<string, string>;
11
+ };
12
+ export type UploadProviderDefinition = {
13
+ getUploadUrl?: (input: ProviderUploadUrlInput) => Promise<ProviderUploadUrlResult>;
14
+ createMultipartUpload?: (input: CreateMultipartInput) => Promise<CreateMultipartResult>;
15
+ signPart?: (input: SignPartInput) => Promise<SignedPartResult>;
16
+ completeMultipartUpload?: (input: CompleteMultipartInput) => Promise<CompleteMultipartResult>;
17
+ abortMultipartUpload?: (input: {
18
+ key: string;
19
+ uploadId: string;
20
+ }) => Promise<void>;
21
+ };
22
+ export declare function defineUploadProvider(definition: UploadProviderDefinition): UploadProvider;
23
+ export declare function assertUploadProvider(provider: UploadProvider | undefined): UploadProvider;
24
+ export declare function assertProviderMultipartSupported(provider: UploadProvider): void;
25
+ export declare function assertProviderDirectUploadSupported(provider: UploadProvider): void;
26
+ //# sourceMappingURL=defineUploadProvider.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"defineUploadProvider.d.ts","sourceRoot":"","sources":["../../src/providers/defineUploadProvider.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,sBAAsB,EACtB,uBAAuB,EACvB,oBAAoB,EACpB,qBAAqB,EACrB,aAAa,EACb,gBAAgB,EAChB,gBAAgB,EAChB,cAAc,EACf,MAAM,mBAAmB,CAAC;AAE3B,MAAM,MAAM,sBAAsB,GAAG;IACnC,GAAG,EAAE,MAAM,CAAC;IACZ,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACnC,CAAC;AAEF,MAAM,MAAM,uBAAuB,GAAG;IACpC,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,CAAC,EAAE,gBAAgB,CAAC;IAC1B,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAClC,CAAC;AAEF,MAAM,MAAM,wBAAwB,GAAG;IACrC,YAAY,CAAC,EAAE,CAAC,KAAK,EAAE,sBAAsB,KAAK,OAAO,CAAC,uBAAuB,CAAC,CAAC;IACnF,qBAAqB,CAAC,EAAE,CAAC,KAAK,EAAE,oBAAoB,KAAK,OAAO,CAAC,qBAAqB,CAAC,CAAC;IACxF,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,OAAO,CAAC,gBAAgB,CAAC,CAAC;IAC/D,uBAAuB,CAAC,EAAE,CAAC,KAAK,EAAE,sBAAsB,KAAK,OAAO,CAAC,uBAAuB,CAAC,CAAC;IAC9F,oBAAoB,CAAC,EAAE,CAAC,KAAK,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACpF,CAAC;AAEF,wBAAgB,oBAAoB,CAAC,UAAU,EAAE,wBAAwB,GAAG,cAAc,CAQzF;AAED,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,cAAc,GAAG,SAAS,GAAG,cAAc,CAKzF;AAED,wBAAgB,gCAAgC,CAAC,QAAQ,EAAE,cAAc,GAAG,IAAI,CAU/E;AAED,wBAAgB,mCAAmC,CAAC,QAAQ,EAAE,cAAc,GAAG,IAAI,CAIlF"}
@@ -0,0 +1,39 @@
1
+ export function defineUploadProvider(definition) {
2
+ return {
3
+ getUploadUrl: definition.getUploadUrl,
4
+ createMultipartUpload: definition.createMultipartUpload,
5
+ signPart: definition.signPart,
6
+ completeMultipartUpload: definition.completeMultipartUpload,
7
+ abortMultipartUpload: definition.abortMultipartUpload,
8
+ };
9
+ }
10
+ export function assertUploadProvider(provider) {
11
+ if (!provider) {
12
+ throw providerError('Upload provider is required for provider-based uploads.');
13
+ }
14
+ return provider;
15
+ }
16
+ export function assertProviderMultipartSupported(provider) {
17
+ if (typeof provider.createMultipartUpload !== 'function') {
18
+ throw providerError('Provider must implement createMultipartUpload.');
19
+ }
20
+ if (typeof provider.signPart !== 'function') {
21
+ throw providerError('Provider must implement signPart.');
22
+ }
23
+ if (typeof provider.completeMultipartUpload !== 'function') {
24
+ throw providerError('Provider must implement completeMultipartUpload.');
25
+ }
26
+ }
27
+ export function assertProviderDirectUploadSupported(provider) {
28
+ if (typeof provider.getUploadUrl !== 'function') {
29
+ throw providerError('Provider must implement getUploadUrl for direct signed-URL uploads.');
30
+ }
31
+ }
32
+ function providerError(message) {
33
+ return Object.assign(new Error(message), {
34
+ code: 'PROVIDER_ERROR',
35
+ retryable: false,
36
+ taskId: '',
37
+ });
38
+ }
39
+ //# sourceMappingURL=defineUploadProvider.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"defineUploadProvider.js","sourceRoot":"","sources":["../../src/providers/defineUploadProvider.ts"],"names":[],"mappings":"AA+BA,MAAM,UAAU,oBAAoB,CAAC,UAAoC;IACvE,OAAO;QACL,YAAY,EAAE,UAAU,CAAC,YAAY;QACrC,qBAAqB,EAAE,UAAU,CAAC,qBAAqB;QACvD,QAAQ,EAAE,UAAU,CAAC,QAAQ;QAC7B,uBAAuB,EAAE,UAAU,CAAC,uBAAuB;QAC3D,oBAAoB,EAAE,UAAU,CAAC,oBAAoB;KACtD,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,oBAAoB,CAAC,QAAoC;IACvE,IAAI,CAAC,QAAQ,EAAE,CAAC;QACd,MAAM,aAAa,CAAC,yDAAyD,CAAC,CAAC;IACjF,CAAC;IACD,OAAO,QAAQ,CAAC;AAClB,CAAC;AAED,MAAM,UAAU,gCAAgC,CAAC,QAAwB;IACvE,IAAI,OAAO,QAAQ,CAAC,qBAAqB,KAAK,UAAU,EAAE,CAAC;QACzD,MAAM,aAAa,CAAC,gDAAgD,CAAC,CAAC;IACxE,CAAC;IACD,IAAI,OAAO,QAAQ,CAAC,QAAQ,KAAK,UAAU,EAAE,CAAC;QAC5C,MAAM,aAAa,CAAC,mCAAmC,CAAC,CAAC;IAC3D,CAAC;IACD,IAAI,OAAO,QAAQ,CAAC,uBAAuB,KAAK,UAAU,EAAE,CAAC;QAC3D,MAAM,aAAa,CAAC,kDAAkD,CAAC,CAAC;IAC1E,CAAC;AACH,CAAC;AAED,MAAM,UAAU,mCAAmC,CAAC,QAAwB;IAC1E,IAAI,OAAO,QAAQ,CAAC,YAAY,KAAK,UAAU,EAAE,CAAC;QAChD,MAAM,aAAa,CAAC,qEAAqE,CAAC,CAAC;IAC7F,CAAC;AACH,CAAC;AAED,SAAS,aAAa,CACpB,OAAe;IAEf,OAAO,MAAM,CAAC,MAAM,CAAC,IAAI,KAAK,CAAC,OAAO,CAAC,EAAE;QACvC,IAAI,EAAE,gBAAgB;QACtB,SAAS,EAAE,KAAK;QAChB,MAAM,EAAE,EAAE;KACX,CAAC,CAAC;AACL,CAAC","sourcesContent":["import type {\n CompleteMultipartInput,\n CompleteMultipartResult,\n CreateMultipartInput,\n CreateMultipartResult,\n SignPartInput,\n SignedPartResult,\n UploadHttpMethod,\n UploadProvider,\n} from '../Uploados.types';\n\nexport type ProviderUploadUrlInput = {\n key: string;\n contentType?: string;\n metadata?: Record<string, string>;\n};\n\nexport type ProviderUploadUrlResult = {\n url: string;\n method?: UploadHttpMethod;\n headers?: Record<string, string>;\n};\n\nexport type UploadProviderDefinition = {\n getUploadUrl?: (input: ProviderUploadUrlInput) => Promise<ProviderUploadUrlResult>;\n createMultipartUpload?: (input: CreateMultipartInput) => Promise<CreateMultipartResult>;\n signPart?: (input: SignPartInput) => Promise<SignedPartResult>;\n completeMultipartUpload?: (input: CompleteMultipartInput) => Promise<CompleteMultipartResult>;\n abortMultipartUpload?: (input: { key: string; uploadId: string }) => Promise<void>;\n};\n\nexport function defineUploadProvider(definition: UploadProviderDefinition): UploadProvider {\n return {\n getUploadUrl: definition.getUploadUrl,\n createMultipartUpload: definition.createMultipartUpload,\n signPart: definition.signPart,\n completeMultipartUpload: definition.completeMultipartUpload,\n abortMultipartUpload: definition.abortMultipartUpload,\n };\n}\n\nexport function assertUploadProvider(provider: UploadProvider | undefined): UploadProvider {\n if (!provider) {\n throw providerError('Upload provider is required for provider-based uploads.');\n }\n return provider;\n}\n\nexport function assertProviderMultipartSupported(provider: UploadProvider): void {\n if (typeof provider.createMultipartUpload !== 'function') {\n throw providerError('Provider must implement createMultipartUpload.');\n }\n if (typeof provider.signPart !== 'function') {\n throw providerError('Provider must implement signPart.');\n }\n if (typeof provider.completeMultipartUpload !== 'function') {\n throw providerError('Provider must implement completeMultipartUpload.');\n }\n}\n\nexport function assertProviderDirectUploadSupported(provider: UploadProvider): void {\n if (typeof provider.getUploadUrl !== 'function') {\n throw providerError('Provider must implement getUploadUrl for direct signed-URL uploads.');\n }\n}\n\nfunction providerError(\n message: string\n): Error & { code: string; retryable: boolean; taskId: string } {\n return Object.assign(new Error(message), {\n code: 'PROVIDER_ERROR',\n retryable: false,\n taskId: '',\n });\n}\n"]}
@@ -0,0 +1,10 @@
1
+ export declare const DEFAULT_MULTIPART_PART_SIZE: number;
2
+ export declare const MIN_MULTIPART_PART_SIZE: number;
3
+ export type MultipartUploadPlan = {
4
+ partSize: number;
5
+ partCount: number;
6
+ totalBytes: number;
7
+ };
8
+ export declare function createMultipartUploadPlan(totalBytes: number, partSize?: number): MultipartUploadPlan;
9
+ export declare function validateMultipartPartNumber(partNumber: number, partCount: number): void;
10
+ //# sourceMappingURL=multipartPlan.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"multipartPlan.d.ts","sourceRoot":"","sources":["../../src/providers/multipartPlan.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,2BAA2B,QAAkB,CAAC;AAC3D,eAAO,MAAM,uBAAuB,QAAkB,CAAC;AAEvD,MAAM,MAAM,mBAAmB,GAAG;IAChC,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF,wBAAgB,yBAAyB,CACvC,UAAU,EAAE,MAAM,EAClB,QAAQ,GAAE,MAAoC,GAC7C,mBAAmB,CAiBrB;AAED,wBAAgB,2BAA2B,CAAC,UAAU,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,IAAI,CAWvF"}
@@ -0,0 +1,28 @@
1
+ export const DEFAULT_MULTIPART_PART_SIZE = 8 * 1024 * 1024;
2
+ export const MIN_MULTIPART_PART_SIZE = 5 * 1024 * 1024;
3
+ export function createMultipartUploadPlan(totalBytes, partSize = DEFAULT_MULTIPART_PART_SIZE) {
4
+ if (!Number.isFinite(totalBytes) || totalBytes <= 0) {
5
+ throw Object.assign(new Error('File size must be greater than zero for multipart uploads.'), {
6
+ code: 'FILE_NOT_FOUND',
7
+ retryable: false,
8
+ taskId: '',
9
+ });
10
+ }
11
+ const normalizedPartSize = Math.max(MIN_MULTIPART_PART_SIZE, Math.floor(partSize));
12
+ const partCount = Math.max(1, Math.ceil(totalBytes / normalizedPartSize));
13
+ return {
14
+ partSize: normalizedPartSize,
15
+ partCount,
16
+ totalBytes,
17
+ };
18
+ }
19
+ export function validateMultipartPartNumber(partNumber, partCount) {
20
+ if (!Number.isInteger(partNumber) || partNumber < 1 || partNumber > partCount) {
21
+ throw Object.assign(new Error(`Invalid multipart part number ${partNumber}. Expected 1..${partCount}.`), {
22
+ code: 'MULTIPART_FAILED',
23
+ retryable: false,
24
+ taskId: '',
25
+ });
26
+ }
27
+ }
28
+ //# sourceMappingURL=multipartPlan.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"multipartPlan.js","sourceRoot":"","sources":["../../src/providers/multipartPlan.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,2BAA2B,GAAG,CAAC,GAAG,IAAI,GAAG,IAAI,CAAC;AAC3D,MAAM,CAAC,MAAM,uBAAuB,GAAG,CAAC,GAAG,IAAI,GAAG,IAAI,CAAC;AAQvD,MAAM,UAAU,yBAAyB,CACvC,UAAkB,EAClB,WAAmB,2BAA2B;IAE9C,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,UAAU,CAAC,IAAI,UAAU,IAAI,CAAC,EAAE,CAAC;QACpD,MAAM,MAAM,CAAC,MAAM,CAAC,IAAI,KAAK,CAAC,4DAA4D,CAAC,EAAE;YAC3F,IAAI,EAAE,gBAAgB;YACtB,SAAS,EAAE,KAAK;YAChB,MAAM,EAAE,EAAE;SACX,CAAC,CAAC;IACL,CAAC;IAED,MAAM,kBAAkB,GAAG,IAAI,CAAC,GAAG,CAAC,uBAAuB,EAAE,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC;IACnF,MAAM,SAAS,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,IAAI,CAAC,UAAU,GAAG,kBAAkB,CAAC,CAAC,CAAC;IAE1E,OAAO;QACL,QAAQ,EAAE,kBAAkB;QAC5B,SAAS;QACT,UAAU;KACX,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,2BAA2B,CAAC,UAAkB,EAAE,SAAiB;IAC/E,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,UAAU,CAAC,IAAI,UAAU,GAAG,CAAC,IAAI,UAAU,GAAG,SAAS,EAAE,CAAC;QAC9E,MAAM,MAAM,CAAC,MAAM,CACjB,IAAI,KAAK,CAAC,iCAAiC,UAAU,iBAAiB,SAAS,GAAG,CAAC,EACnF;YACE,IAAI,EAAE,kBAAkB;YACxB,SAAS,EAAE,KAAK;YAChB,MAAM,EAAE,EAAE;SACX,CACF,CAAC;IACJ,CAAC;AACH,CAAC","sourcesContent":["export const DEFAULT_MULTIPART_PART_SIZE = 8 * 1024 * 1024;\nexport const MIN_MULTIPART_PART_SIZE = 5 * 1024 * 1024;\n\nexport type MultipartUploadPlan = {\n partSize: number;\n partCount: number;\n totalBytes: number;\n};\n\nexport function createMultipartUploadPlan(\n totalBytes: number,\n partSize: number = DEFAULT_MULTIPART_PART_SIZE\n): MultipartUploadPlan {\n if (!Number.isFinite(totalBytes) || totalBytes <= 0) {\n throw Object.assign(new Error('File size must be greater than zero for multipart uploads.'), {\n code: 'FILE_NOT_FOUND',\n retryable: false,\n taskId: '',\n });\n }\n\n const normalizedPartSize = Math.max(MIN_MULTIPART_PART_SIZE, Math.floor(partSize));\n const partCount = Math.max(1, Math.ceil(totalBytes / normalizedPartSize));\n\n return {\n partSize: normalizedPartSize,\n partCount,\n totalBytes,\n };\n}\n\nexport function validateMultipartPartNumber(partNumber: number, partCount: number): void {\n if (!Number.isInteger(partNumber) || partNumber < 1 || partNumber > partCount) {\n throw Object.assign(\n new Error(`Invalid multipart part number ${partNumber}. Expected 1..${partCount}.`),\n {\n code: 'MULTIPART_FAILED',\n retryable: false,\n taskId: '',\n }\n );\n }\n}\n"]}
@@ -0,0 +1,5 @@
1
+ const { defineConfig } = require('eslint/config');
2
+ const universe = require('eslint-config-universe/flat/native');
3
+ const universeWeb = require('eslint-config-universe/flat/web');
4
+
5
+ module.exports = defineConfig([{ ignores: ['build'] }, ...universe, ...universeWeb]);
@@ -0,0 +1,10 @@
1
+ {
2
+ "platforms": ["apple", "android"],
3
+ "apple": {
4
+ "modules": ["UploadosModule"],
5
+ "appDelegateSubscribers": ["UploadosAppDelegate"]
6
+ },
7
+ "android": {
8
+ "modules": ["expo.modules.uploados.UploadosModule"]
9
+ }
10
+ }
@@ -0,0 +1,183 @@
1
+ import Foundation
2
+ import ImageIO
3
+ import UIKit
4
+ import UniformTypeIdentifiers
5
+
6
+ enum CompressionPreset: String, Codable {
7
+ case balanced
8
+ case inspection
9
+ case avatar
10
+
11
+ init(raw: String) {
12
+ self = CompressionPreset(rawValue: raw) ?? .balanced
13
+ }
14
+
15
+ var maxDimension: CGFloat {
16
+ switch self {
17
+ case .balanced:
18
+ return 2048
19
+ case .inspection:
20
+ return 4096
21
+ case .avatar:
22
+ return 512
23
+ }
24
+ }
25
+
26
+ var jpegQuality: CGFloat {
27
+ switch self {
28
+ case .balanced:
29
+ return 0.82
30
+ case .inspection:
31
+ return 0.92
32
+ case .avatar:
33
+ return 0.85
34
+ }
35
+ }
36
+ }
37
+
38
+ struct CompressionStats: Codable {
39
+ let preset: CompressionPreset
40
+ let originalSize: Int
41
+ let optimizedSize: Int
42
+ let format: String
43
+
44
+ func toDictionary() -> [String: Any] {
45
+ [
46
+ "preset": preset.rawValue,
47
+ "originalSize": originalSize,
48
+ "optimizedSize": optimizedSize,
49
+ "format": format,
50
+ ]
51
+ }
52
+ }
53
+
54
+ struct CompressionResult {
55
+ let outputURL: URL
56
+ let stats: CompressionStats
57
+ }
58
+
59
+ enum CompressionPipeline {
60
+ private static let optimizedDirectoryName = "optimized"
61
+
62
+ static func optimizedDirectoryURL() -> URL? {
63
+ FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first?
64
+ .appendingPathComponent("uploados", isDirectory: true)
65
+ .appendingPathComponent(optimizedDirectoryName, isDirectory: true)
66
+ }
67
+
68
+ static func isSdkOwnedOptimizedPath(_ path: String) -> Bool {
69
+ guard let root = optimizedDirectoryURL()?.path else {
70
+ return false
71
+ }
72
+ return path.hasPrefix(root + "/") || path == root
73
+ }
74
+
75
+ static func cleanupOptimizedFile(path: String) {
76
+ guard isSdkOwnedOptimizedPath(path) else {
77
+ return
78
+ }
79
+ try? FileManager.default.removeItem(atPath: path)
80
+ }
81
+
82
+ static func compress(sourceURL: URL, taskId: String, preset: CompressionPreset) throws -> CompressionResult {
83
+ guard let directory = optimizedDirectoryURL() else {
84
+ throw CompressionPipelineError.directoryUnavailable
85
+ }
86
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
87
+ let destination = directory.appendingPathComponent("\(taskId).jpg")
88
+ if FileManager.default.fileExists(atPath: destination.path) {
89
+ let attributes = try FileManager.default.attributesOfItem(atPath: sourceURL.path)
90
+ let originalSize = (attributes[.size] as? NSNumber)?.intValue ?? 0
91
+ let optimizedAttributes = try FileManager.default.attributesOfItem(atPath: destination.path)
92
+ let optimizedSize = (optimizedAttributes[.size] as? NSNumber)?.intValue ?? 0
93
+ return CompressionResult(
94
+ outputURL: destination,
95
+ stats: CompressionStats(
96
+ preset: preset,
97
+ originalSize: originalSize,
98
+ optimizedSize: optimizedSize,
99
+ format: "jpeg"
100
+ )
101
+ )
102
+ }
103
+
104
+ guard let source = CGImageSourceCreateWithURL(sourceURL as CFURL, nil) else {
105
+ throw CompressionPipelineError.unsupportedInput
106
+ }
107
+ guard let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any],
108
+ let width = properties[kCGImagePropertyPixelWidth] as? NSNumber,
109
+ let height = properties[kCGImagePropertyPixelHeight] as? NSNumber else {
110
+ throw CompressionPipelineError.unsupportedInput
111
+ }
112
+
113
+ let originalAttributes = try FileManager.default.attributesOfItem(atPath: sourceURL.path)
114
+ let originalSize = (originalAttributes[.size] as? NSNumber)?.intValue ?? 0
115
+ let maxDimension = preset.maxDimension
116
+ let scale = min(
117
+ 1,
118
+ maxDimension / max(CGFloat(truncating: width), CGFloat(truncating: height))
119
+ )
120
+ let targetWidth = Int(CGFloat(truncating: width) * scale)
121
+ let targetHeight = Int(CGFloat(truncating: height) * scale)
122
+
123
+ let options: [CFString: Any] = [
124
+ kCGImageSourceCreateThumbnailFromImageAlways: true,
125
+ kCGImageSourceCreateThumbnailWithTransform: true,
126
+ kCGImageSourceThumbnailMaxPixelSize: max(targetWidth, targetHeight),
127
+ ]
128
+ guard let image = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else {
129
+ throw CompressionPipelineError.compressionFailed
130
+ }
131
+
132
+ guard let destinationRef = CGImageDestinationCreateWithURL(
133
+ destination as CFURL,
134
+ UTType.jpeg.identifier as CFString,
135
+ 1,
136
+ nil
137
+ ) else {
138
+ throw CompressionPipelineError.compressionFailed
139
+ }
140
+
141
+ let jpegProperties: [CFString: Any] = [
142
+ kCGImageDestinationLossyCompressionQuality: preset.jpegQuality,
143
+ kCGImagePropertyOrientation: properties[kCGImagePropertyOrientation] ?? 1,
144
+ ]
145
+ CGImageDestinationAddImage(destinationRef, image, jpegProperties as CFDictionary)
146
+ guard CGImageDestinationFinalize(destinationRef) else {
147
+ throw CompressionPipelineError.compressionFailed
148
+ }
149
+
150
+ let optimizedAttributes = try FileManager.default.attributesOfItem(atPath: destination.path)
151
+ let optimizedSize = (optimizedAttributes[.size] as? NSNumber)?.intValue ?? 0
152
+ guard optimizedSize > 0 else {
153
+ throw CompressionPipelineError.compressionFailed
154
+ }
155
+
156
+ return CompressionResult(
157
+ outputURL: destination,
158
+ stats: CompressionStats(
159
+ preset: preset,
160
+ originalSize: originalSize,
161
+ optimizedSize: optimizedSize,
162
+ format: "jpeg"
163
+ )
164
+ )
165
+ }
166
+ }
167
+
168
+ enum CompressionPipelineError: Error, LocalizedError {
169
+ case directoryUnavailable
170
+ case unsupportedInput
171
+ case compressionFailed
172
+
173
+ var errorDescription: String? {
174
+ switch self {
175
+ case .directoryUnavailable:
176
+ return "Compression output directory is unavailable."
177
+ case .unsupportedInput:
178
+ return "The selected file is not a supported image format."
179
+ case .compressionFailed:
180
+ return "Image compression failed."
181
+ }
182
+ }
183
+ }
@@ -0,0 +1,67 @@
1
+ import Foundation
2
+
3
+ enum FileStager {
4
+ private static let stagedDirectoryName = "staged"
5
+
6
+ static func stagedDirectoryURL() -> URL? {
7
+ FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first?
8
+ .appendingPathComponent("uploados", isDirectory: true)
9
+ .appendingPathComponent(stagedDirectoryName, isDirectory: true)
10
+ }
11
+
12
+ static func needsStaging(uri: String, fileURL: URL) -> Bool {
13
+ if uri.hasPrefix("content://") {
14
+ return true
15
+ }
16
+ let path = fileURL.path
17
+ if path.contains("-Inbox/") {
18
+ return true
19
+ }
20
+ if path.contains("/tmp/") {
21
+ return true
22
+ }
23
+ if path.contains("/Library/Caches/") {
24
+ return true
25
+ }
26
+ return false
27
+ }
28
+
29
+ static func stageFile(sourceURL: URL, taskId: String) throws -> URL {
30
+ guard let directory = stagedDirectoryURL() else {
31
+ throw FileStagerError.directoryUnavailable
32
+ }
33
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
34
+ let ext = sourceURL.pathExtension.isEmpty ? "" : ".\(sourceURL.pathExtension)"
35
+ let destination = directory.appendingPathComponent("\(taskId)\(ext)")
36
+ if FileManager.default.fileExists(atPath: destination.path) {
37
+ return destination
38
+ }
39
+ try FileManager.default.copyItem(at: sourceURL, to: destination)
40
+ return destination
41
+ }
42
+
43
+ static func isSdkOwnedStagedPath(_ path: String) -> Bool {
44
+ guard let stagedRoot = stagedDirectoryURL()?.path else {
45
+ return false
46
+ }
47
+ return path.hasPrefix(stagedRoot + "/") || path == stagedRoot
48
+ }
49
+
50
+ static func cleanupStagedFile(path: String) {
51
+ guard isSdkOwnedStagedPath(path) else {
52
+ return
53
+ }
54
+ try? FileManager.default.removeItem(atPath: path)
55
+ }
56
+ }
57
+
58
+ enum FileStagerError: Error, LocalizedError {
59
+ case directoryUnavailable
60
+
61
+ var errorDescription: String? {
62
+ switch self {
63
+ case .directoryUnavailable:
64
+ return "Upload staging directory is unavailable."
65
+ }
66
+ }
67
+ }