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.
- package/android/build.gradle +25 -0
- package/android/src/main/AndroidManifest.xml +15 -0
- package/android/src/main/java/expo/modules/uploados/UploadosModule.kt +121 -0
- package/android/src/main/java/expo/modules/uploados/upload/CompressionPipeline.kt +192 -0
- package/android/src/main/java/expo/modules/uploados/upload/FileStager.kt +125 -0
- package/android/src/main/java/expo/modules/uploados/upload/ProgressRequestBody.kt +36 -0
- package/android/src/main/java/expo/modules/uploados/upload/UploadManager.kt +857 -0
- package/android/src/main/java/expo/modules/uploados/upload/UploadModels.kt +209 -0
- package/android/src/main/java/expo/modules/uploados/upload/UploadNotificationHelper.kt +93 -0
- package/android/src/main/java/expo/modules/uploados/upload/UploadTaskStore.kt +224 -0
- package/android/src/main/java/expo/modules/uploados/upload/UploadWorker.kt +31 -0
- package/build/Uploados.types.d.ts +226 -0
- package/build/Uploados.types.d.ts.map +1 -0
- package/build/Uploados.types.js +2 -0
- package/build/Uploados.types.js.map +1 -0
- package/build/UploadosModule.d.ts +13 -0
- package/build/UploadosModule.d.ts.map +1 -0
- package/build/UploadosModule.js +3 -0
- package/build/UploadosModule.js.map +1 -0
- package/build/UploadosModule.web.d.ts +13 -0
- package/build/UploadosModule.web.d.ts.map +1 -0
- package/build/UploadosModule.web.js +33 -0
- package/build/UploadosModule.web.js.map +1 -0
- package/build/createUploader.d.ts +3 -0
- package/build/createUploader.d.ts.map +1 -0
- package/build/createUploader.js +108 -0
- package/build/createUploader.js.map +1 -0
- package/build/index.d.ts +5 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +5 -0
- package/build/index.js.map +1 -0
- package/build/normalizeUploadOptions.d.ts +9 -0
- package/build/normalizeUploadOptions.d.ts.map +1 -0
- package/build/normalizeUploadOptions.js +81 -0
- package/build/normalizeUploadOptions.js.map +1 -0
- package/build/providers/defineUploadProvider.d.ts +26 -0
- package/build/providers/defineUploadProvider.d.ts.map +1 -0
- package/build/providers/defineUploadProvider.js +39 -0
- package/build/providers/defineUploadProvider.js.map +1 -0
- package/build/providers/multipartPlan.d.ts +10 -0
- package/build/providers/multipartPlan.d.ts.map +1 -0
- package/build/providers/multipartPlan.js +28 -0
- package/build/providers/multipartPlan.js.map +1 -0
- package/eslint.config.cjs +5 -0
- package/expo-module.config.json +10 -0
- package/ios/Upload/CompressionPipeline.swift +183 -0
- package/ios/Upload/FileStager.swift +67 -0
- package/ios/Upload/UploadManager.swift +813 -0
- package/ios/Upload/UploadModels.swift +305 -0
- package/ios/Upload/UploadSessionDelegate.swift +82 -0
- package/ios/Upload/UploadTaskStore.swift +92 -0
- package/ios/Upload/UploadosAppDelegate.swift +14 -0
- package/ios/Uploados.podspec +23 -0
- package/ios/UploadosModule.swift +87 -0
- package/jest.config.js +15 -0
- package/package.json +54 -0
- package/readme.md +169 -0
- package/src/Uploados.types.ts +260 -0
- package/src/UploadosModule.ts +18 -0
- package/src/UploadosModule.web.ts +49 -0
- package/src/createUploader.ts +146 -0
- package/src/index.ts +4 -0
- package/src/normalizeUploadOptions.ts +132 -0
- package/src/providers/defineUploadProvider.ts +75 -0
- package/src/providers/multipartPlan.ts +43 -0
- package/tsconfig.json +42 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
plugins {
|
|
2
|
+
id 'com.android.library'
|
|
3
|
+
id 'expo-module-gradle-plugin'
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
group = 'expo.modules.uploados'
|
|
7
|
+
version = '0.1.0'
|
|
8
|
+
|
|
9
|
+
android {
|
|
10
|
+
namespace "expo.modules.uploados"
|
|
11
|
+
defaultConfig {
|
|
12
|
+
versionCode 1
|
|
13
|
+
versionName "0.1.0"
|
|
14
|
+
}
|
|
15
|
+
lintOptions {
|
|
16
|
+
abortOnError false
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
dependencies {
|
|
21
|
+
implementation 'androidx.core:core-ktx:1.13.1'
|
|
22
|
+
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
|
|
23
|
+
implementation 'androidx.work:work-runtime-ktx:2.9.1'
|
|
24
|
+
implementation 'androidx.exifinterface:exifinterface:1.3.7'
|
|
25
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<manifest
|
|
2
|
+
xmlns:android="http://schemas.android.com/apk/res/android"
|
|
3
|
+
xmlns:tools="http://schemas.android.com/tools">
|
|
4
|
+
<uses-permission android:name="android.permission.INTERNET" />
|
|
5
|
+
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
|
6
|
+
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
|
7
|
+
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
|
8
|
+
|
|
9
|
+
<application>
|
|
10
|
+
<service
|
|
11
|
+
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
|
12
|
+
android:foregroundServiceType="dataSync"
|
|
13
|
+
tools:node="merge" />
|
|
14
|
+
</application>
|
|
15
|
+
</manifest>
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
package expo.modules.uploados
|
|
2
|
+
|
|
3
|
+
import expo.modules.kotlin.exception.CodedException
|
|
4
|
+
import expo.modules.kotlin.modules.Module
|
|
5
|
+
import expo.modules.kotlin.modules.ModuleDefinition
|
|
6
|
+
import expo.modules.uploados.upload.CompressionPreset
|
|
7
|
+
import expo.modules.uploados.upload.CreateUploadOptions
|
|
8
|
+
import expo.modules.uploados.upload.UploadManager
|
|
9
|
+
import expo.modules.uploados.upload.UploadManagerException
|
|
10
|
+
import expo.modules.uploados.upload.UploadNetworkPolicy
|
|
11
|
+
|
|
12
|
+
class UploadosModule : Module() {
|
|
13
|
+
override fun definition() = ModuleDefinition {
|
|
14
|
+
Name("Uploados")
|
|
15
|
+
|
|
16
|
+
Events("onUploadEvent")
|
|
17
|
+
|
|
18
|
+
OnCreate {
|
|
19
|
+
val context = appContext.reactContext?.applicationContext
|
|
20
|
+
?: return@OnCreate
|
|
21
|
+
val manager = UploadManager.getInstance(context)
|
|
22
|
+
manager.setEventEmitter { payload ->
|
|
23
|
+
sendEvent("onUploadEvent", payload)
|
|
24
|
+
}
|
|
25
|
+
manager.restoreQueue()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
AsyncFunction("createUploadAsync") { options: Map<String, Any?> ->
|
|
29
|
+
val parsed = parseCreateOptions(options)
|
|
30
|
+
val manager = requireManager()
|
|
31
|
+
try {
|
|
32
|
+
manager.createUpload(parsed).toMap()
|
|
33
|
+
} catch (e: UploadManagerException) {
|
|
34
|
+
throw CodedException(e.errorCode.raw, e.message, e)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
AsyncFunction("cancelUploadAsync") { taskId: String ->
|
|
39
|
+
val manager = requireManager()
|
|
40
|
+
try {
|
|
41
|
+
manager.cancelUpload(taskId).toMap()
|
|
42
|
+
} catch (e: UploadManagerException) {
|
|
43
|
+
throw CodedException(e.errorCode.raw, e.message, e)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
AsyncFunction("retryUploadAsync") { taskId: String ->
|
|
48
|
+
val manager = requireManager()
|
|
49
|
+
try {
|
|
50
|
+
manager.retryUpload(taskId).toMap()
|
|
51
|
+
} catch (e: UploadManagerException) {
|
|
52
|
+
throw CodedException(e.errorCode.raw, e.message, e)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
AsyncFunction("getTaskAsync") { taskId: String ->
|
|
57
|
+
expo.modules.uploados.upload.UploadTaskStore.getInstance(requireContext())
|
|
58
|
+
.task(taskId)
|
|
59
|
+
?.toMap()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
AsyncFunction("getAllTasksAsync") {
|
|
63
|
+
val store = expo.modules.uploados.upload.UploadTaskStore.getInstance(requireContext())
|
|
64
|
+
store.allTasks().map { it.toMap() }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
AsyncFunction("restoreQueueAsync") {
|
|
68
|
+
val manager = requireManager()
|
|
69
|
+
manager.restoreQueue().map { it.toMap() }
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private fun requireContext() =
|
|
74
|
+
appContext.reactContext?.applicationContext
|
|
75
|
+
?: throw CodedException("ERR_UPLOADOS", "React application context is not available.", null)
|
|
76
|
+
|
|
77
|
+
private fun requireManager(): UploadManager =
|
|
78
|
+
UploadManager.getInstance(requireContext())
|
|
79
|
+
|
|
80
|
+
private fun parseCreateOptions(options: Map<String, Any?>): CreateUploadOptions {
|
|
81
|
+
val localUri = options["localUri"] as? String
|
|
82
|
+
?: throw InvalidOptionsException("localUri")
|
|
83
|
+
val uploadUrl = options["uploadUrl"] as? String
|
|
84
|
+
?: throw InvalidOptionsException("uploadUrl")
|
|
85
|
+
val method = (options["method"] as? String)?.uppercase() ?: "PUT"
|
|
86
|
+
if (method != "PUT" && method != "POST") {
|
|
87
|
+
throw InvalidOptionsException("method")
|
|
88
|
+
}
|
|
89
|
+
val networkPolicyRaw = options["networkPolicy"] as? String ?: UploadNetworkPolicy.WAIT.raw
|
|
90
|
+
val networkPolicy = UploadNetworkPolicy.fromRaw(networkPolicyRaw)
|
|
91
|
+
?: throw InvalidOptionsException("networkPolicy")
|
|
92
|
+
val retry = options["retry"] as? Map<*, *>
|
|
93
|
+
val maxAttempts = (retry?.get("maxAttempts") as? Number)?.toInt()?.coerceAtLeast(1) ?: 3
|
|
94
|
+
val headers = (options["headers"] as? Map<*, *>)?.mapNotNull { (key, value) ->
|
|
95
|
+
val name = key as? String
|
|
96
|
+
val headerValue = value as? String
|
|
97
|
+
if (name != null && headerValue != null) name to headerValue else null
|
|
98
|
+
}?.toMap() ?: emptyMap()
|
|
99
|
+
val background = options["background"] as? Boolean ?: false
|
|
100
|
+
val compression = options["compression"] as? Map<*, *>
|
|
101
|
+
val compressionEnabled = compression?.get("enabled") as? Boolean ?: false
|
|
102
|
+
val compressionPreset = CompressionPreset.fromRaw(compression?.get("preset") as? String)
|
|
103
|
+
return CreateUploadOptions(
|
|
104
|
+
localUri = localUri,
|
|
105
|
+
uploadUrl = uploadUrl,
|
|
106
|
+
method = method,
|
|
107
|
+
headers = headers,
|
|
108
|
+
background = background,
|
|
109
|
+
networkPolicy = if (background) UploadNetworkPolicy.WAIT else networkPolicy,
|
|
110
|
+
maxAttempts = maxAttempts,
|
|
111
|
+
compressionEnabled = compressionEnabled,
|
|
112
|
+
compressionPreset = compressionPreset,
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private class InvalidOptionsException(option: String) : CodedException(
|
|
118
|
+
"ERR_INVALID_OPTIONS",
|
|
119
|
+
"Missing or invalid upload option: $option",
|
|
120
|
+
null,
|
|
121
|
+
)
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
package expo.modules.uploados.upload
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.graphics.Bitmap
|
|
5
|
+
import android.graphics.BitmapFactory
|
|
6
|
+
import android.graphics.Matrix
|
|
7
|
+
import androidx.exifinterface.media.ExifInterface
|
|
8
|
+
import java.io.File
|
|
9
|
+
import java.io.FileOutputStream
|
|
10
|
+
|
|
11
|
+
enum class CompressionPreset(val raw: String) {
|
|
12
|
+
BALANCED("balanced"),
|
|
13
|
+
INSPECTION("inspection"),
|
|
14
|
+
AVATAR("avatar");
|
|
15
|
+
|
|
16
|
+
val maxDimension: Int
|
|
17
|
+
get() = when (this) {
|
|
18
|
+
BALANCED -> 2048
|
|
19
|
+
INSPECTION -> 4096
|
|
20
|
+
AVATAR -> 512
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
val jpegQuality: Int
|
|
24
|
+
get() = when (this) {
|
|
25
|
+
BALANCED -> 82
|
|
26
|
+
INSPECTION -> 92
|
|
27
|
+
AVATAR -> 85
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
companion object {
|
|
31
|
+
fun fromRaw(value: String?): CompressionPreset =
|
|
32
|
+
entries.firstOrNull { it.raw == value } ?: BALANCED
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
data class CompressionStats(
|
|
37
|
+
val preset: CompressionPreset,
|
|
38
|
+
val originalSize: Long,
|
|
39
|
+
val optimizedSize: Long,
|
|
40
|
+
val format: String = "jpeg",
|
|
41
|
+
) {
|
|
42
|
+
fun toMap(): Map<String, Any> = mapOf(
|
|
43
|
+
"preset" to preset.raw,
|
|
44
|
+
"originalSize" to originalSize,
|
|
45
|
+
"optimizedSize" to optimizedSize,
|
|
46
|
+
"format" to format,
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
data class CompressionResult(
|
|
51
|
+
val outputFile: File,
|
|
52
|
+
val stats: CompressionStats,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
object CompressionPipeline {
|
|
56
|
+
private const val OPTIMIZED_DIR = "optimized"
|
|
57
|
+
|
|
58
|
+
fun optimizedDirectory(context: Context): File =
|
|
59
|
+
File(File(context.filesDir, "uploados"), OPTIMIZED_DIR).also { it.mkdirs() }
|
|
60
|
+
|
|
61
|
+
fun isSdkOwnedOptimizedPath(context: Context, path: String): Boolean {
|
|
62
|
+
val root = optimizedDirectory(context).absolutePath
|
|
63
|
+
return path == root || path.startsWith("$root/")
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
fun cleanup(context: Context, path: String) {
|
|
67
|
+
if (!isSdkOwnedOptimizedPath(context, path)) {
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
File(path).delete()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
fun compress(
|
|
74
|
+
context: Context,
|
|
75
|
+
sourceFile: File,
|
|
76
|
+
taskId: String,
|
|
77
|
+
preset: CompressionPreset,
|
|
78
|
+
): CompressionResult {
|
|
79
|
+
val directory = optimizedDirectory(context)
|
|
80
|
+
val destination = File(directory, "$taskId.jpg")
|
|
81
|
+
if (destination.exists() && destination.isFile) {
|
|
82
|
+
return CompressionResult(
|
|
83
|
+
outputFile = destination,
|
|
84
|
+
stats = CompressionStats(
|
|
85
|
+
preset = preset,
|
|
86
|
+
originalSize = sourceFile.length(),
|
|
87
|
+
optimizedSize = destination.length(),
|
|
88
|
+
),
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
val originalSize = sourceFile.length()
|
|
93
|
+
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
|
94
|
+
BitmapFactory.decodeFile(sourceFile.absolutePath, bounds)
|
|
95
|
+
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) {
|
|
96
|
+
throw UploadManagerException(
|
|
97
|
+
UploadErrorCode.COMPRESSION_FAILED,
|
|
98
|
+
"The selected file is not a supported image format.",
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
val sampleSize = calculateSampleSize(bounds.outWidth, bounds.outHeight, preset.maxDimension)
|
|
103
|
+
val decodeOptions = BitmapFactory.Options().apply {
|
|
104
|
+
inSampleSize = sampleSize
|
|
105
|
+
inPreferredConfig = Bitmap.Config.ARGB_8888
|
|
106
|
+
}
|
|
107
|
+
var bitmap = BitmapFactory.decodeFile(sourceFile.absolutePath, decodeOptions)
|
|
108
|
+
?: throw UploadManagerException(
|
|
109
|
+
UploadErrorCode.COMPRESSION_FAILED,
|
|
110
|
+
"Image compression failed.",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
bitmap = applyExifOrientation(sourceFile, bitmap)
|
|
114
|
+
bitmap = scaleToMaxDimension(bitmap, preset.maxDimension)
|
|
115
|
+
|
|
116
|
+
destination.parentFile?.mkdirs()
|
|
117
|
+
FileOutputStream(destination).use { output ->
|
|
118
|
+
if (!bitmap.compress(Bitmap.CompressFormat.JPEG, preset.jpegQuality, output)) {
|
|
119
|
+
throw UploadManagerException(
|
|
120
|
+
UploadErrorCode.COMPRESSION_FAILED,
|
|
121
|
+
"Image compression failed.",
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
bitmap.recycle()
|
|
126
|
+
|
|
127
|
+
if (!destination.exists() || destination.length() <= 0) {
|
|
128
|
+
throw UploadManagerException(
|
|
129
|
+
UploadErrorCode.COMPRESSION_FAILED,
|
|
130
|
+
"Image compression failed.",
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return CompressionResult(
|
|
135
|
+
outputFile = destination,
|
|
136
|
+
stats = CompressionStats(
|
|
137
|
+
preset = preset,
|
|
138
|
+
originalSize = originalSize,
|
|
139
|
+
optimizedSize = destination.length(),
|
|
140
|
+
),
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private fun calculateSampleSize(width: Int, height: Int, maxDimension: Int): Int {
|
|
145
|
+
var sampleSize = 1
|
|
146
|
+
var scaledWidth = width
|
|
147
|
+
var scaledHeight = height
|
|
148
|
+
while (scaledWidth / 2 >= maxDimension || scaledHeight / 2 >= maxDimension) {
|
|
149
|
+
sampleSize *= 2
|
|
150
|
+
scaledWidth /= 2
|
|
151
|
+
scaledHeight /= 2
|
|
152
|
+
}
|
|
153
|
+
return sampleSize
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private fun scaleToMaxDimension(bitmap: Bitmap, maxDimension: Int): Bitmap {
|
|
157
|
+
val width = bitmap.width
|
|
158
|
+
val height = bitmap.height
|
|
159
|
+
val scale = minOf(1f, maxDimension.toFloat() / maxOf(width, height).toFloat())
|
|
160
|
+
if (scale >= 1f) {
|
|
161
|
+
return bitmap
|
|
162
|
+
}
|
|
163
|
+
val matrix = Matrix().apply { setScale(scale, scale) }
|
|
164
|
+
val scaled = Bitmap.createBitmap(bitmap, 0, 0, width, height, matrix, true)
|
|
165
|
+
if (scaled != bitmap) {
|
|
166
|
+
bitmap.recycle()
|
|
167
|
+
}
|
|
168
|
+
return scaled
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private fun applyExifOrientation(sourceFile: File, bitmap: Bitmap): Bitmap {
|
|
172
|
+
val exif = runCatching { ExifInterface(sourceFile.absolutePath) }.getOrNull() ?: return bitmap
|
|
173
|
+
val orientation = exif.getAttributeInt(
|
|
174
|
+
ExifInterface.TAG_ORIENTATION,
|
|
175
|
+
ExifInterface.ORIENTATION_NORMAL,
|
|
176
|
+
)
|
|
177
|
+
val matrix = Matrix()
|
|
178
|
+
when (orientation) {
|
|
179
|
+
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
|
|
180
|
+
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
|
|
181
|
+
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
|
|
182
|
+
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.preScale(-1f, 1f)
|
|
183
|
+
ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.preScale(1f, -1f)
|
|
184
|
+
else -> return bitmap
|
|
185
|
+
}
|
|
186
|
+
val rotated = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
|
|
187
|
+
if (rotated != bitmap) {
|
|
188
|
+
bitmap.recycle()
|
|
189
|
+
}
|
|
190
|
+
return rotated
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
package expo.modules.uploados.upload
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.net.Uri
|
|
5
|
+
import android.webkit.MimeTypeMap
|
|
6
|
+
import java.io.File
|
|
7
|
+
|
|
8
|
+
object FileStager {
|
|
9
|
+
private const val STAGED_DIR = "staged"
|
|
10
|
+
|
|
11
|
+
fun stagedDirectory(context: Context): File =
|
|
12
|
+
File(File(context.filesDir, "uploados"), STAGED_DIR).also { it.mkdirs() }
|
|
13
|
+
|
|
14
|
+
fun needsStaging(context: Context, uri: String): Boolean {
|
|
15
|
+
if (uri.startsWith("content://")) {
|
|
16
|
+
return true
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
val path = if (uri.startsWith("file://")) Uri.parse(uri).path else uri
|
|
20
|
+
if (path.isNullOrBlank()) {
|
|
21
|
+
return false
|
|
22
|
+
}
|
|
23
|
+
if (isSdkOwnedStagedPath(context, path) || CompressionPipeline.isSdkOwnedOptimizedPath(context, path)) {
|
|
24
|
+
return false
|
|
25
|
+
}
|
|
26
|
+
val cacheDir = context.cacheDir.absolutePath
|
|
27
|
+
if (path == cacheDir || path.startsWith("$cacheDir/")) {
|
|
28
|
+
return true
|
|
29
|
+
}
|
|
30
|
+
for (externalCacheDir in context.externalCacheDirs.filterNotNull()) {
|
|
31
|
+
val externalPath = externalCacheDir.absolutePath
|
|
32
|
+
if (path == externalPath || path.startsWith("$externalPath/")) {
|
|
33
|
+
return true
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (path.contains("/cache/", ignoreCase = true) || path.contains("/tmp/", ignoreCase = true)) {
|
|
37
|
+
return true
|
|
38
|
+
}
|
|
39
|
+
return false
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
fun stageFile(context: Context, sourceUri: String, taskId: String): File {
|
|
43
|
+
val directory = stagedDirectory(context)
|
|
44
|
+
val extension = resolveExtension(context, sourceUri)
|
|
45
|
+
val destination = File(directory, "$taskId$extension")
|
|
46
|
+
if (destination.exists() && destination.isFile) {
|
|
47
|
+
return destination
|
|
48
|
+
}
|
|
49
|
+
destination.parentFile?.mkdirs()
|
|
50
|
+
when {
|
|
51
|
+
sourceUri.startsWith("content://") -> copyContentUri(context, sourceUri, destination)
|
|
52
|
+
sourceUri.startsWith("file://") -> {
|
|
53
|
+
val path = Uri.parse(sourceUri).path
|
|
54
|
+
?: throw UploadManagerException(UploadErrorCode.FILE_NOT_FOUND, "File not found at $sourceUri.")
|
|
55
|
+
copyFile(File(path), destination)
|
|
56
|
+
}
|
|
57
|
+
else -> {
|
|
58
|
+
val source = if (sourceUri.startsWith("/")) File(sourceUri) else File(sourceUri)
|
|
59
|
+
copyFile(source, destination)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return destination
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
fun isSdkOwnedStagedPath(context: Context, path: String): Boolean {
|
|
66
|
+
val root = stagedDirectory(context).absolutePath
|
|
67
|
+
return path == root || path.startsWith("$root/")
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
fun cleanup(context: Context, path: String) {
|
|
71
|
+
if (!isSdkOwnedStagedPath(context, path)) {
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
File(path).delete()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private fun copyContentUri(context: Context, uri: String, destination: File) {
|
|
78
|
+
val contentUri = Uri.parse(uri)
|
|
79
|
+
context.contentResolver.openInputStream(contentUri)?.use { input ->
|
|
80
|
+
destination.outputStream().use { output ->
|
|
81
|
+
input.copyTo(output)
|
|
82
|
+
}
|
|
83
|
+
} ?: throw UploadManagerException(
|
|
84
|
+
UploadErrorCode.FILE_NOT_FOUND,
|
|
85
|
+
"File not found at $uri.",
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private fun copyFile(source: File, destination: File) {
|
|
90
|
+
if (!source.exists() || !source.isFile) {
|
|
91
|
+
throw UploadManagerException(
|
|
92
|
+
UploadErrorCode.FILE_NOT_FOUND,
|
|
93
|
+
"File not found at ${source.absolutePath}.",
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
source.inputStream().use { input ->
|
|
97
|
+
destination.outputStream().use { output ->
|
|
98
|
+
input.copyTo(output)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private fun resolveExtension(context: Context, uri: String): String {
|
|
104
|
+
if (uri.startsWith("content://")) {
|
|
105
|
+
val contentUri = Uri.parse(uri)
|
|
106
|
+
val mime = context.contentResolver.getType(contentUri)
|
|
107
|
+
val ext = mime?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) }
|
|
108
|
+
if (!ext.isNullOrBlank()) {
|
|
109
|
+
return ".$ext"
|
|
110
|
+
}
|
|
111
|
+
val name = contentUri.lastPathSegment ?: return ""
|
|
112
|
+
val dot = name.lastIndexOf('.')
|
|
113
|
+
if (dot >= 0 && dot < name.length - 1) {
|
|
114
|
+
return name.substring(dot)
|
|
115
|
+
}
|
|
116
|
+
return ""
|
|
117
|
+
}
|
|
118
|
+
val path = if (uri.startsWith("file://")) Uri.parse(uri).path else uri
|
|
119
|
+
if (path.isNullOrBlank()) {
|
|
120
|
+
return ""
|
|
121
|
+
}
|
|
122
|
+
val dot = path.lastIndexOf('.')
|
|
123
|
+
return if (dot >= 0 && dot < path.length - 1) path.substring(dot) else ""
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
package expo.modules.uploados.upload
|
|
2
|
+
|
|
3
|
+
import okhttp3.MediaType
|
|
4
|
+
import okhttp3.RequestBody
|
|
5
|
+
import okio.Buffer
|
|
6
|
+
import okio.BufferedSink
|
|
7
|
+
import okio.source
|
|
8
|
+
import java.io.File
|
|
9
|
+
|
|
10
|
+
class ProgressRequestBody(
|
|
11
|
+
private val file: File,
|
|
12
|
+
private val contentType: MediaType?,
|
|
13
|
+
private val onProgress: (bytesWritten: Long, totalBytes: Long) -> Unit,
|
|
14
|
+
) : RequestBody() {
|
|
15
|
+
override fun contentType(): MediaType? = contentType
|
|
16
|
+
|
|
17
|
+
override fun contentLength(): Long = file.length()
|
|
18
|
+
|
|
19
|
+
override fun writeTo(sink: BufferedSink) {
|
|
20
|
+
val total = contentLength()
|
|
21
|
+
var uploaded = 0L
|
|
22
|
+
file.source().use { source ->
|
|
23
|
+
var read: Long
|
|
24
|
+
val buffer = Buffer()
|
|
25
|
+
while (source.read(buffer, SEGMENT_SIZE).also { read = it } != -1L) {
|
|
26
|
+
sink.write(buffer, read)
|
|
27
|
+
uploaded += read
|
|
28
|
+
onProgress(uploaded, total)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
companion object {
|
|
34
|
+
private const val SEGMENT_SIZE = 8_192L
|
|
35
|
+
}
|
|
36
|
+
}
|