stream-chat-expo 9.0.2-beta.2 → 9.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 +5 -4
- package/android/src/main/java/com/streamchatexpo/StreamChatExpoPackage.java +18 -3
- package/android/src/main/java/com/streamchatreactnative/shared/upload/StreamMultipartUploadFileRequestBody.kt +25 -0
- package/android/src/main/java/com/streamchatreactnative/shared/upload/StreamMultipartUploadModels.kt +39 -0
- package/android/src/main/java/com/streamchatreactnative/shared/upload/StreamMultipartUploadProgress.kt +80 -0
- package/android/src/main/java/com/streamchatreactnative/shared/upload/StreamMultipartUploadRequestParser.kt +110 -0
- package/android/src/main/java/com/streamchatreactnative/shared/upload/StreamMultipartUploadSourceResolver.kt +99 -0
- package/android/src/main/java/com/streamchatreactnative/shared/upload/StreamMultipartUploader.kt +138 -0
- package/android/src/newarch/com/streamchatexpo/StreamMultipartUploaderModule.kt +122 -0
- package/ios/shared/StreamMultipartUploadBodyStream.swift +254 -0
- package/ios/shared/StreamMultipartUploadManager.swift +462 -0
- package/ios/shared/StreamMultipartUploadModels.swift +69 -0
- package/ios/shared/StreamMultipartUploadProgress.swift +48 -0
- package/ios/shared/StreamMultipartUploadSourceResolver.swift +391 -0
- package/ios/shared/StreamMultipartUploader.h +16 -0
- package/ios/shared/StreamMultipartUploader.mm +109 -0
- package/ios/shared/StreamMultipartUploaderBridge.swift +145 -0
- package/ios/shared/StreamShimmerView.swift +180 -77
- package/ios/shared/StreamVideoThumbnailGenerator.swift +13 -2
- package/package.json +3 -2
- package/src/handlers/index.ts +1 -0
- package/src/handlers/multipartUpload.ts +9 -0
- package/src/index.js +2 -1
- package/src/native/NativeStreamMultipartUploader.ts +52 -0
- package/src/native/multipartUploader.ts +5 -0
- package/src/optionalDependencies/__tests__/pickDocument.test.ts +88 -0
- package/src/optionalDependencies/getPhotos.ts +8 -5
- package/src/optionalDependencies/pickDocument.ts +28 -12
package/android/build.gradle
CHANGED
|
@@ -29,8 +29,9 @@ if (isNewArchitectureEnabled()) {
|
|
|
29
29
|
def getExtOrIntegerDefault(name) {
|
|
30
30
|
return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties["StreamChatExpo_" + name]).toInteger()
|
|
31
31
|
}
|
|
32
|
+
def canonicalProjectDir = projectDir.getCanonicalFile()
|
|
32
33
|
def localSharedNativeRootDir = new File(projectDir, "src/main/java/com/streamchatreactnative/shared")
|
|
33
|
-
def sharedNativeRootDir = new File(
|
|
34
|
+
def sharedNativeRootDir = new File(canonicalProjectDir, "../../shared-native/android")
|
|
34
35
|
def hasNativeSources = { File dir ->
|
|
35
36
|
dir.exists() && !fileTree(dir).matching { include "**/*.kt"; include "**/*.java" }.files.isEmpty()
|
|
36
37
|
}
|
|
@@ -88,10 +89,10 @@ tasks.register("syncSharedShimmerSources") {
|
|
|
88
89
|
outputs.upToDateWhen { false }
|
|
89
90
|
doLast {
|
|
90
91
|
def sourceRootDir = null
|
|
91
|
-
if (hasNativeSources(
|
|
92
|
-
sourceRootDir = localSharedNativeRootDir
|
|
93
|
-
} else if (hasNativeSources(sharedNativeRootDir)) {
|
|
92
|
+
if (hasNativeSources(sharedNativeRootDir)) {
|
|
94
93
|
sourceRootDir = sharedNativeRootDir
|
|
94
|
+
} else if (hasNativeSources(localSharedNativeRootDir)) {
|
|
95
|
+
sourceRootDir = localSharedNativeRootDir
|
|
95
96
|
}
|
|
96
97
|
|
|
97
98
|
if (sourceRootDir == null) {
|
|
@@ -14,12 +14,17 @@ import java.util.List;
|
|
|
14
14
|
import java.util.Map;
|
|
15
15
|
|
|
16
16
|
public class StreamChatExpoPackage extends TurboReactPackage {
|
|
17
|
+
private static final String STREAM_MULTIPART_UPLOADER_MODULE = "StreamMultipartUploader";
|
|
17
18
|
private static final String STREAM_VIDEO_THUMBNAIL_MODULE = "StreamVideoThumbnail";
|
|
18
19
|
|
|
19
20
|
@Nullable
|
|
20
21
|
@Override
|
|
21
22
|
public NativeModule getModule(String name, ReactApplicationContext reactContext) {
|
|
22
|
-
if (name.equals(
|
|
23
|
+
if (name.equals(STREAM_MULTIPART_UPLOADER_MODULE)) {
|
|
24
|
+
return createNewArchModule("com.streamchatexpo.StreamMultipartUploaderModule", reactContext);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (name.equals(STREAM_VIDEO_THUMBNAIL_MODULE)) {
|
|
23
28
|
return createNewArchModule("com.streamchatexpo.StreamVideoThumbnailModule", reactContext);
|
|
24
29
|
}
|
|
25
30
|
|
|
@@ -30,7 +35,17 @@ public class StreamChatExpoPackage extends TurboReactPackage {
|
|
|
30
35
|
public ReactModuleInfoProvider getReactModuleInfoProvider() {
|
|
31
36
|
return () -> {
|
|
32
37
|
final Map<String, ReactModuleInfo> moduleInfos = new HashMap<>();
|
|
33
|
-
|
|
38
|
+
moduleInfos.put(
|
|
39
|
+
STREAM_MULTIPART_UPLOADER_MODULE,
|
|
40
|
+
new ReactModuleInfo(
|
|
41
|
+
STREAM_MULTIPART_UPLOADER_MODULE,
|
|
42
|
+
STREAM_MULTIPART_UPLOADER_MODULE,
|
|
43
|
+
false, // canOverrideExistingModule
|
|
44
|
+
false, // needsEagerInit
|
|
45
|
+
false, // hasConstants
|
|
46
|
+
false, // isCxxModule
|
|
47
|
+
true // isTurboModule
|
|
48
|
+
));
|
|
34
49
|
moduleInfos.put(
|
|
35
50
|
STREAM_VIDEO_THUMBNAIL_MODULE,
|
|
36
51
|
new ReactModuleInfo(
|
|
@@ -40,7 +55,7 @@ public class StreamChatExpoPackage extends TurboReactPackage {
|
|
|
40
55
|
false, // needsEagerInit
|
|
41
56
|
false, // hasConstants
|
|
42
57
|
false, // isCxxModule
|
|
43
|
-
|
|
58
|
+
true // isTurboModule
|
|
44
59
|
));
|
|
45
60
|
return moduleInfos;
|
|
46
61
|
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
package com.streamchatreactnative.shared.upload
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import okhttp3.RequestBody
|
|
5
|
+
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
|
6
|
+
import okio.BufferedSink
|
|
7
|
+
import okio.source
|
|
8
|
+
|
|
9
|
+
class StreamMultipartUploadFileRequestBody(
|
|
10
|
+
private val context: Context,
|
|
11
|
+
private val filePart: StreamMultipartFilePart,
|
|
12
|
+
) : RequestBody() {
|
|
13
|
+
private val resolvedMimeType = StreamMultipartUploadSourceResolver.mimeType(context, filePart)
|
|
14
|
+
private val resolvedContentLength = StreamMultipartUploadSourceResolver.contentLength(context, filePart.uri)
|
|
15
|
+
|
|
16
|
+
override fun contentLength(): Long = resolvedContentLength ?: -1L
|
|
17
|
+
|
|
18
|
+
override fun contentType() = resolvedMimeType.toMediaTypeOrNull()
|
|
19
|
+
|
|
20
|
+
override fun writeTo(sink: BufferedSink) {
|
|
21
|
+
StreamMultipartUploadSourceResolver.openInputStream(context, filePart.uri).use { inputStream ->
|
|
22
|
+
sink.writeAll(inputStream.source())
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
package/android/src/main/java/com/streamchatreactnative/shared/upload/StreamMultipartUploadModels.kt
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
package com.streamchatreactnative.shared.upload
|
|
2
|
+
|
|
3
|
+
data class StreamMultipartUploadRequest(
|
|
4
|
+
val headers: Map<String, String>,
|
|
5
|
+
val method: String,
|
|
6
|
+
val parts: List<StreamMultipartUploadPart>,
|
|
7
|
+
val progress: StreamMultipartUploadProgressOptions?,
|
|
8
|
+
val timeoutMs: Long?,
|
|
9
|
+
val uploadId: String,
|
|
10
|
+
val url: String,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
sealed interface StreamMultipartUploadPart {
|
|
14
|
+
val fieldName: String
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
data class StreamMultipartFilePart(
|
|
18
|
+
override val fieldName: String,
|
|
19
|
+
val fileName: String,
|
|
20
|
+
val mimeType: String?,
|
|
21
|
+
val uri: String,
|
|
22
|
+
) : StreamMultipartUploadPart
|
|
23
|
+
|
|
24
|
+
data class StreamMultipartTextPart(
|
|
25
|
+
override val fieldName: String,
|
|
26
|
+
val value: String,
|
|
27
|
+
) : StreamMultipartUploadPart
|
|
28
|
+
|
|
29
|
+
data class StreamMultipartUploadProgressOptions(
|
|
30
|
+
val count: Int?,
|
|
31
|
+
val intervalMs: Long?,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
data class StreamMultipartUploadResponse(
|
|
35
|
+
val body: String,
|
|
36
|
+
val headers: Map<String, String>,
|
|
37
|
+
val status: Int,
|
|
38
|
+
val statusText: String?,
|
|
39
|
+
)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
package com.streamchatreactnative.shared.upload
|
|
2
|
+
|
|
3
|
+
import android.os.SystemClock
|
|
4
|
+
import okhttp3.RequestBody
|
|
5
|
+
import okio.Buffer
|
|
6
|
+
import okio.BufferedSink
|
|
7
|
+
import okio.ForwardingSink
|
|
8
|
+
import okio.Sink
|
|
9
|
+
import okio.buffer
|
|
10
|
+
import kotlin.math.floor
|
|
11
|
+
|
|
12
|
+
class StreamMultipartUploadProgressThrottler(
|
|
13
|
+
options: StreamMultipartUploadProgressOptions?,
|
|
14
|
+
private val onProgress: (loaded: Long, total: Long?) -> Unit,
|
|
15
|
+
) {
|
|
16
|
+
private val intervalMs = (options?.intervalMs ?: 16L).coerceIn(16L, 1_000L)
|
|
17
|
+
private val count = (options?.count ?: 20).coerceIn(1, 100)
|
|
18
|
+
private var emittedBuckets = -1
|
|
19
|
+
private var lastEventAtMs = 0L
|
|
20
|
+
|
|
21
|
+
fun dispatch(loaded: Long, total: Long?) {
|
|
22
|
+
val now = SystemClock.elapsedRealtime()
|
|
23
|
+
val isTerminal = total != null && total >= 0 && loaded >= total
|
|
24
|
+
|
|
25
|
+
if (isTerminal) {
|
|
26
|
+
onProgress(loaded, total)
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
val passesInterval = now - lastEventAtMs >= intervalMs
|
|
31
|
+
val passesCount =
|
|
32
|
+
if (count > 0 && total != null && total > 0) {
|
|
33
|
+
val nextBucket = floor((loaded.toDouble() / total.toDouble()) * count.toDouble()).toInt()
|
|
34
|
+
if (nextBucket > emittedBuckets) {
|
|
35
|
+
emittedBuckets = nextBucket
|
|
36
|
+
true
|
|
37
|
+
} else {
|
|
38
|
+
false
|
|
39
|
+
}
|
|
40
|
+
} else {
|
|
41
|
+
true
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!passesInterval || !passesCount) {
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
lastEventAtMs = now
|
|
49
|
+
onProgress(loaded, total)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
class StreamMultipartUploadProgressRequestBody(
|
|
54
|
+
private val requestBody: RequestBody,
|
|
55
|
+
private val throttler: StreamMultipartUploadProgressThrottler,
|
|
56
|
+
) : RequestBody() {
|
|
57
|
+
private val resolvedContentLength by lazy { requestBody.contentLength().takeIf { it >= 0L } }
|
|
58
|
+
|
|
59
|
+
override fun contentLength(): Long = requestBody.contentLength()
|
|
60
|
+
|
|
61
|
+
override fun contentType() = requestBody.contentType()
|
|
62
|
+
|
|
63
|
+
override fun writeTo(sink: BufferedSink) {
|
|
64
|
+
val countingSink =
|
|
65
|
+
object : ForwardingSink(sink as Sink) {
|
|
66
|
+
private var bytesWritten = 0L
|
|
67
|
+
|
|
68
|
+
override fun write(source: Buffer, byteCount: Long) {
|
|
69
|
+
super.write(source, byteCount)
|
|
70
|
+
|
|
71
|
+
bytesWritten += byteCount
|
|
72
|
+
throttler.dispatch(bytesWritten, resolvedContentLength)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
val bufferedSink = countingSink.buffer()
|
|
77
|
+
requestBody.writeTo(bufferedSink)
|
|
78
|
+
bufferedSink.flush()
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
package com.streamchatreactnative.shared.upload
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.bridge.ReadableArray
|
|
4
|
+
import com.facebook.react.bridge.ReadableMap
|
|
5
|
+
import com.facebook.react.bridge.ReadableType
|
|
6
|
+
|
|
7
|
+
object StreamMultipartUploadRequestParser {
|
|
8
|
+
fun parse(
|
|
9
|
+
uploadId: String,
|
|
10
|
+
url: String,
|
|
11
|
+
method: String,
|
|
12
|
+
headers: ReadableArray,
|
|
13
|
+
parts: ReadableArray,
|
|
14
|
+
progress: ReadableMap?,
|
|
15
|
+
timeoutMs: Double?,
|
|
16
|
+
): StreamMultipartUploadRequest {
|
|
17
|
+
return StreamMultipartUploadRequest(
|
|
18
|
+
headers = headers.toStringMap(),
|
|
19
|
+
method = method,
|
|
20
|
+
parts = parts.toUploadParts(),
|
|
21
|
+
progress = progress?.toProgressOptions(),
|
|
22
|
+
timeoutMs = timeoutMs?.toLong()?.takeIf { it > 0L },
|
|
23
|
+
uploadId = uploadId,
|
|
24
|
+
url = url,
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
private fun ReadableArray.toUploadParts(): List<StreamMultipartUploadPart> {
|
|
29
|
+
val parsedParts = mutableListOf<StreamMultipartUploadPart>()
|
|
30
|
+
|
|
31
|
+
for (index in 0 until size()) {
|
|
32
|
+
val part = getMap(index) ?: throw IllegalArgumentException("Missing multipart part at index $index")
|
|
33
|
+
val fieldName =
|
|
34
|
+
part.getString("fieldName") ?: throw IllegalArgumentException("Multipart part $index is missing fieldName")
|
|
35
|
+
val kind =
|
|
36
|
+
part.getString("kind") ?: throw IllegalArgumentException("Multipart part $index is missing kind")
|
|
37
|
+
|
|
38
|
+
when (kind) {
|
|
39
|
+
"file" -> {
|
|
40
|
+
val uri =
|
|
41
|
+
part.getString("uri") ?: throw IllegalArgumentException("Multipart file part $index is missing uri")
|
|
42
|
+
val fileName =
|
|
43
|
+
part.getString("fileName")
|
|
44
|
+
?: throw IllegalArgumentException("Multipart file part $index is missing fileName")
|
|
45
|
+
|
|
46
|
+
parsedParts += StreamMultipartFilePart(
|
|
47
|
+
fieldName = fieldName,
|
|
48
|
+
fileName = fileName,
|
|
49
|
+
mimeType = part.getString("mimeType"),
|
|
50
|
+
uri = uri,
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
"text" -> {
|
|
55
|
+
val value =
|
|
56
|
+
part.getString("value") ?: throw IllegalArgumentException("Multipart text part $index is missing value")
|
|
57
|
+
parsedParts += StreamMultipartTextPart(fieldName = fieldName, value = value)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
else -> throw IllegalArgumentException("Unsupported multipart part kind: $kind")
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (parsedParts.none { it is StreamMultipartFilePart }) {
|
|
65
|
+
throw IllegalArgumentException("Multipart upload must contain at least one file part")
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return parsedParts
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private fun ReadableArray.toStringMap(): Map<String, String> {
|
|
72
|
+
val parsed = mutableMapOf<String, String>()
|
|
73
|
+
|
|
74
|
+
for (index in 0 until size()) {
|
|
75
|
+
val header = getMap(index) ?: throw IllegalArgumentException("Missing multipart header at index $index")
|
|
76
|
+
val name =
|
|
77
|
+
header.getString("name") ?: throw IllegalArgumentException("Multipart header $index is missing name")
|
|
78
|
+
if (header.getType("value") == ReadableType.Null) {
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
val value =
|
|
82
|
+
header.getString("value")
|
|
83
|
+
?: header.getDynamic("value").asString()
|
|
84
|
+
?: throw IllegalArgumentException("Multipart header $index is missing value")
|
|
85
|
+
parsed[name] = value
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return parsed
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private fun ReadableMap.toProgressOptions(): StreamMultipartUploadProgressOptions {
|
|
92
|
+
val count =
|
|
93
|
+
if (hasKey("count") && !isNull("count")) {
|
|
94
|
+
getDouble("count").toInt().coerceIn(1, 100)
|
|
95
|
+
} else {
|
|
96
|
+
null
|
|
97
|
+
}
|
|
98
|
+
val intervalMs =
|
|
99
|
+
if (hasKey("intervalMs") && !isNull("intervalMs")) {
|
|
100
|
+
getDouble("intervalMs").toLong().coerceIn(16L, 1_000L)
|
|
101
|
+
} else {
|
|
102
|
+
null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return StreamMultipartUploadProgressOptions(
|
|
106
|
+
count = count,
|
|
107
|
+
intervalMs = intervalMs,
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
package com.streamchatreactnative.shared.upload
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.database.Cursor
|
|
5
|
+
import android.net.Uri
|
|
6
|
+
import android.provider.OpenableColumns
|
|
7
|
+
import java.io.File
|
|
8
|
+
import java.io.FileInputStream
|
|
9
|
+
import java.io.InputStream
|
|
10
|
+
import java.net.URLConnection
|
|
11
|
+
|
|
12
|
+
object StreamMultipartUploadSourceResolver {
|
|
13
|
+
fun contentLength(context: Context, uriString: String): Long? {
|
|
14
|
+
val uri = normalizeUri(uriString)
|
|
15
|
+
|
|
16
|
+
return when (uri.scheme?.lowercase()) {
|
|
17
|
+
null, "file" -> {
|
|
18
|
+
val file = toFile(uri, uriString)
|
|
19
|
+
if (!file.exists()) {
|
|
20
|
+
throw IllegalArgumentException("File does not exist for upload: $uriString")
|
|
21
|
+
}
|
|
22
|
+
file.length()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
"content" -> {
|
|
26
|
+
context.contentResolver.openAssetFileDescriptor(uri, "r")?.use { descriptor ->
|
|
27
|
+
descriptor.length.takeIf { it >= 0L }
|
|
28
|
+
} ?: queryLongColumn(context, uri, OpenableColumns.SIZE)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
else -> throw IllegalArgumentException("Unsupported upload URI scheme: ${uri.scheme}")
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
fun mimeType(context: Context, part: StreamMultipartFilePart): String {
|
|
36
|
+
val explicitMimeType = part.mimeType?.takeIf { it.isNotBlank() }
|
|
37
|
+
if (explicitMimeType != null) {
|
|
38
|
+
return explicitMimeType
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
val uri = normalizeUri(part.uri)
|
|
42
|
+
val contentResolverMime = context.contentResolver.getType(uri)
|
|
43
|
+
if (!contentResolverMime.isNullOrBlank()) {
|
|
44
|
+
return contentResolverMime
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return URLConnection.guessContentTypeFromName(part.fileName) ?: "application/octet-stream"
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
fun openInputStream(context: Context, uriString: String): InputStream {
|
|
51
|
+
val uri = normalizeUri(uriString)
|
|
52
|
+
|
|
53
|
+
return when (uri.scheme?.lowercase()) {
|
|
54
|
+
null, "file" -> FileInputStream(toFile(uri, uriString))
|
|
55
|
+
"content" ->
|
|
56
|
+
context.contentResolver.openInputStream(uri)
|
|
57
|
+
?: throw IllegalArgumentException("Failed to open content URI for upload: $uriString")
|
|
58
|
+
else -> throw IllegalArgumentException("Unsupported upload URI scheme: ${uri.scheme}")
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private fun normalizeUri(uriString: String): Uri {
|
|
63
|
+
if (uriString.startsWith("/")) {
|
|
64
|
+
return Uri.fromFile(File(uriString))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
val parsed = Uri.parse(uriString)
|
|
68
|
+
|
|
69
|
+
if (parsed.scheme.isNullOrBlank()) {
|
|
70
|
+
return Uri.fromFile(File(uriString))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return parsed
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private fun queryLongColumn(context: Context, uri: Uri, columnName: String): Long? {
|
|
77
|
+
val projection = arrayOf(columnName)
|
|
78
|
+
val cursor: Cursor =
|
|
79
|
+
context.contentResolver.query(uri, projection, null, null, null) ?: return null
|
|
80
|
+
|
|
81
|
+
cursor.use {
|
|
82
|
+
if (!it.moveToFirst()) {
|
|
83
|
+
return null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
val columnIndex = it.getColumnIndex(columnName)
|
|
87
|
+
if (columnIndex == -1 || it.isNull(columnIndex)) {
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return it.getLong(columnIndex)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private fun toFile(uri: Uri, original: String): File {
|
|
96
|
+
val path = uri.path ?: original
|
|
97
|
+
return File(path)
|
|
98
|
+
}
|
|
99
|
+
}
|
package/android/src/main/java/com/streamchatreactnative/shared/upload/StreamMultipartUploader.kt
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
package com.streamchatreactnative.shared.upload
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import okhttp3.Call
|
|
5
|
+
import okhttp3.MultipartBody
|
|
6
|
+
import okhttp3.OkHttpClient
|
|
7
|
+
import okhttp3.Request
|
|
8
|
+
import okhttp3.RequestBody
|
|
9
|
+
import okhttp3.ResponseBody
|
|
10
|
+
import java.io.InterruptedIOException
|
|
11
|
+
import java.io.IOException
|
|
12
|
+
import java.util.concurrent.ConcurrentHashMap
|
|
13
|
+
import java.util.concurrent.TimeUnit
|
|
14
|
+
|
|
15
|
+
object StreamMultipartUploader {
|
|
16
|
+
private val client: OkHttpClient = OkHttpClient.Builder().retryOnConnectionFailure(true).build()
|
|
17
|
+
private const val MAX_RESPONSE_BODY_BYTES = 1_048_576L
|
|
18
|
+
private val cancelledUploadIds = ConcurrentHashMap.newKeySet<String>()
|
|
19
|
+
private val inFlightCalls = ConcurrentHashMap<String, Call>()
|
|
20
|
+
|
|
21
|
+
fun cancel(uploadId: String) {
|
|
22
|
+
cancelledUploadIds.add(uploadId)
|
|
23
|
+
inFlightCalls.remove(uploadId)?.cancel()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
fun upload(
|
|
27
|
+
context: Context,
|
|
28
|
+
request: StreamMultipartUploadRequest,
|
|
29
|
+
onProgress: (loaded: Long, total: Long?) -> Unit,
|
|
30
|
+
): StreamMultipartUploadResponse {
|
|
31
|
+
if (cancelledUploadIds.contains(request.uploadId)) {
|
|
32
|
+
cancelledUploadIds.remove(request.uploadId)
|
|
33
|
+
throw InterruptedIOException("Request aborted")
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
val httpRequest = createRequest(context, request, onProgress)
|
|
37
|
+
val call = clientFor(request).newCall(httpRequest)
|
|
38
|
+
val existingCall = inFlightCalls.putIfAbsent(request.uploadId, call)
|
|
39
|
+
if (existingCall != null) {
|
|
40
|
+
throw IllegalStateException("Upload already in flight for id: ${request.uploadId}")
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
if (cancelledUploadIds.remove(request.uploadId)) {
|
|
45
|
+
call.cancel()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
call.execute().use { response ->
|
|
49
|
+
return StreamMultipartUploadResponse(
|
|
50
|
+
body = readResponseBody(response.body),
|
|
51
|
+
headers =
|
|
52
|
+
response.headers.names().associateWith { name ->
|
|
53
|
+
response.headers(name).joinToString(", ")
|
|
54
|
+
},
|
|
55
|
+
status = response.code,
|
|
56
|
+
statusText = response.message,
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
} finally {
|
|
60
|
+
inFlightCalls.remove(request.uploadId, call)
|
|
61
|
+
cancelledUploadIds.remove(request.uploadId)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private fun clientFor(request: StreamMultipartUploadRequest): OkHttpClient {
|
|
66
|
+
val timeoutMs = request.timeoutMs ?: return client
|
|
67
|
+
return client.newBuilder()
|
|
68
|
+
.callTimeout(timeoutMs, TimeUnit.MILLISECONDS)
|
|
69
|
+
.build()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private fun readResponseBody(body: ResponseBody?): String {
|
|
73
|
+
if (body == null) {
|
|
74
|
+
return ""
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
val source = body.source()
|
|
78
|
+
source.request(MAX_RESPONSE_BODY_BYTES + 1L)
|
|
79
|
+
val buffer = source.buffer
|
|
80
|
+
|
|
81
|
+
if (buffer.size > MAX_RESPONSE_BODY_BYTES) {
|
|
82
|
+
throw IOException("Upload response body exceeded $MAX_RESPONSE_BODY_BYTES bytes")
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return buffer.clone().readString(Charsets.UTF_8)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private fun createMultipartBody(
|
|
89
|
+
context: Context,
|
|
90
|
+
request: StreamMultipartUploadRequest,
|
|
91
|
+
onProgress: (loaded: Long, total: Long?) -> Unit,
|
|
92
|
+
): RequestBody {
|
|
93
|
+
val multipartBodyBuilder = MultipartBody.Builder().setType(MultipartBody.FORM)
|
|
94
|
+
|
|
95
|
+
request.parts.forEach { part ->
|
|
96
|
+
when (part) {
|
|
97
|
+
is StreamMultipartFilePart -> {
|
|
98
|
+
multipartBodyBuilder.addFormDataPart(
|
|
99
|
+
part.fieldName,
|
|
100
|
+
part.fileName,
|
|
101
|
+
StreamMultipartUploadFileRequestBody(context, part),
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
is StreamMultipartTextPart -> {
|
|
106
|
+
multipartBodyBuilder.addFormDataPart(part.fieldName, part.value)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
val multipartBody = multipartBodyBuilder.build()
|
|
112
|
+
val throttler = StreamMultipartUploadProgressThrottler(request.progress, onProgress)
|
|
113
|
+
|
|
114
|
+
return StreamMultipartUploadProgressRequestBody(multipartBody, throttler)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private fun createRequest(
|
|
118
|
+
context: Context,
|
|
119
|
+
request: StreamMultipartUploadRequest,
|
|
120
|
+
onProgress: (loaded: Long, total: Long?) -> Unit,
|
|
121
|
+
): Request {
|
|
122
|
+
val requestBuilder = Request.Builder().url(request.url)
|
|
123
|
+
|
|
124
|
+
request.headers.forEach { (key, value) ->
|
|
125
|
+
if (
|
|
126
|
+
key.equals("Content-Type", ignoreCase = true) ||
|
|
127
|
+
key.equals("Content-Length", ignoreCase = true)
|
|
128
|
+
) {
|
|
129
|
+
return@forEach
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
requestBuilder.header(key, value)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
val body = createMultipartBody(context, request, onProgress)
|
|
136
|
+
return requestBuilder.method(request.method, body).build()
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
package com.streamchatexpo
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.bridge.Arguments
|
|
4
|
+
import com.facebook.react.bridge.Promise
|
|
5
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
6
|
+
import com.facebook.react.bridge.ReadableArray
|
|
7
|
+
import com.facebook.react.bridge.ReadableMap
|
|
8
|
+
import com.facebook.react.bridge.UiThreadUtil
|
|
9
|
+
import com.facebook.react.modules.core.DeviceEventManagerModule
|
|
10
|
+
import com.streamchatreactnative.shared.upload.StreamMultipartUploadRequestParser
|
|
11
|
+
import com.streamchatreactnative.shared.upload.StreamMultipartUploader
|
|
12
|
+
import java.util.concurrent.LinkedBlockingQueue
|
|
13
|
+
import java.util.concurrent.ThreadPoolExecutor
|
|
14
|
+
import java.util.concurrent.TimeUnit
|
|
15
|
+
|
|
16
|
+
class StreamMultipartUploaderModule(
|
|
17
|
+
reactContext: ReactApplicationContext,
|
|
18
|
+
) : NativeStreamMultipartUploaderSpec(reactContext) {
|
|
19
|
+
override fun getName(): String = NAME
|
|
20
|
+
|
|
21
|
+
override fun addListener(eventType: String) = Unit
|
|
22
|
+
|
|
23
|
+
override fun removeListeners(count: Double) = Unit
|
|
24
|
+
|
|
25
|
+
override fun cancelUpload(uploadId: String, promise: Promise) {
|
|
26
|
+
StreamMultipartUploader.cancel(uploadId)
|
|
27
|
+
promise.resolve(null)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
override fun uploadMultipart(
|
|
31
|
+
uploadId: String,
|
|
32
|
+
url: String,
|
|
33
|
+
method: String,
|
|
34
|
+
headers: ReadableArray,
|
|
35
|
+
parts: ReadableArray,
|
|
36
|
+
progress: ReadableMap?,
|
|
37
|
+
timeoutMs: Double?,
|
|
38
|
+
promise: Promise,
|
|
39
|
+
) {
|
|
40
|
+
val request =
|
|
41
|
+
try {
|
|
42
|
+
StreamMultipartUploadRequestParser.parse(
|
|
43
|
+
uploadId = uploadId,
|
|
44
|
+
url = url,
|
|
45
|
+
method = method,
|
|
46
|
+
headers = headers,
|
|
47
|
+
parts = parts,
|
|
48
|
+
progress = progress,
|
|
49
|
+
timeoutMs = timeoutMs,
|
|
50
|
+
)
|
|
51
|
+
} catch (error: Throwable) {
|
|
52
|
+
promise.reject("stream_multipart_upload_error", error.message, error)
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
executor.execute {
|
|
58
|
+
try {
|
|
59
|
+
val response =
|
|
60
|
+
StreamMultipartUploader.upload(reactApplicationContext, request) { loaded, total ->
|
|
61
|
+
emitProgress(uploadId, loaded, total)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
val payload = Arguments.createMap().apply {
|
|
65
|
+
putString("body", response.body)
|
|
66
|
+
putArray("headers", Arguments.createArray().apply {
|
|
67
|
+
response.headers.forEach { (name, value) ->
|
|
68
|
+
pushMap(
|
|
69
|
+
Arguments.createMap().apply {
|
|
70
|
+
putString("name", name)
|
|
71
|
+
putString("value", value)
|
|
72
|
+
},
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
putDouble("status", response.status.toDouble())
|
|
77
|
+
putString("statusText", response.statusText)
|
|
78
|
+
}
|
|
79
|
+
promise.resolve(payload)
|
|
80
|
+
} catch (error: Throwable) {
|
|
81
|
+
promise.reject("stream_multipart_upload_error", error.message, error)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
} catch (error: Throwable) {
|
|
85
|
+
promise.reject("stream_multipart_upload_error", error.message, error)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private fun emitProgress(uploadId: String, loaded: Long, total: Long?) {
|
|
90
|
+
UiThreadUtil.runOnUiThread {
|
|
91
|
+
val payload = Arguments.createMap().apply {
|
|
92
|
+
putDouble("loaded", loaded.toDouble())
|
|
93
|
+
if (total != null) {
|
|
94
|
+
putDouble("total", total.toDouble())
|
|
95
|
+
} else {
|
|
96
|
+
putNull("total")
|
|
97
|
+
}
|
|
98
|
+
putString("uploadId", uploadId)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
reactApplicationContext
|
|
102
|
+
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
|
103
|
+
.emit(PROGRESS_EVENT_NAME, payload)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
companion object {
|
|
108
|
+
const val NAME = "StreamMultipartUploader"
|
|
109
|
+
private const val PROGRESS_EVENT_NAME = "streamMultipartUploadProgress"
|
|
110
|
+
private val maxConcurrentUploads = Runtime.getRuntime().availableProcessors().coerceIn(2, 4)
|
|
111
|
+
private val executor =
|
|
112
|
+
ThreadPoolExecutor(
|
|
113
|
+
maxConcurrentUploads,
|
|
114
|
+
maxConcurrentUploads,
|
|
115
|
+
30L,
|
|
116
|
+
TimeUnit.SECONDS,
|
|
117
|
+
LinkedBlockingQueue(64),
|
|
118
|
+
).apply {
|
|
119
|
+
allowCoreThreadTimeOut(true)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|