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,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
+ }