Skip to content

Commit 55aff01

Browse files
committed
use subfolders for attachments
Signed-off-by: sowjanyakch <sowjanya.kch@gmail.com>
1 parent 520261e commit 55aff01

14 files changed

Lines changed: 421 additions & 20 deletions

app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ import com.nextcloud.talk.conversationinfo.CreateRoomRequest
1212
import com.nextcloud.talk.models.json.autocomplete.AutocompleteOverall
1313
import com.nextcloud.talk.models.json.chat.ChatOverall
1414
import com.nextcloud.talk.models.json.chat.ChatOverallSingleMessage
15+
import com.nextcloud.talk.models.json.chatpostattachment.ChatPostAttachmentOverall
16+
import com.nextcloud.talk.models.json.chatpostattachment.PostConversationAttachmentResponse
17+
import com.nextcloud.talk.models.json.chatprobeattachmentfolder.ChatProbeAttachmentFolderOverall
18+
import com.nextcloud.talk.models.json.chatprobeattachmentfolder.ProbeConversationAttachmentRequest
1519
import com.nextcloud.talk.models.json.conversations.RoomOverall
1620
import com.nextcloud.talk.models.json.conversations.RoomsOverall
1721
import com.nextcloud.talk.models.json.generic.GenericOverall
@@ -463,4 +467,25 @@ interface NcApiCoroutines {
463467
// Url is: /api/{apiVersion}/chat/{token}/read
464468
@DELETE
465469
suspend fun markRoomAsUnread(@Header("Authorization") authorization: String, @Url url: String): GenericOverall
470+
471+
@POST
472+
suspend fun probeConversationAttachmentFolder(
473+
@Header("Authorization") authorization: String,
474+
@Url url: String,
475+
@Body request: ProbeConversationAttachmentRequest
476+
): ChatProbeAttachmentFolderOverall
477+
478+
@POST
479+
suspend fun postConversationAttachment(
480+
@Header("Authorization") authorization: String,
481+
@Url url: String,
482+
@Body body: PostConversationAttachmentResponse
483+
): ChatPostAttachmentOverall
484+
485+
@PUT
486+
suspend fun uploadFile(
487+
@Header("Authorization") authorization: String,
488+
@Url url: String,
489+
@Body body: RequestBody
490+
): Response<GenericOverall>
466491
}

app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt

Lines changed: 156 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,16 +26,23 @@ import androidx.work.OneTimeWorkRequest
2626
import androidx.work.WorkManager
2727
import androidx.work.Worker
2828
import androidx.work.WorkerParameters
29+
import at.bitfire.dav4jvm.DavResource
2930
import autodagger.AutoInjector
3031
import com.nextcloud.talk.R
3132
import com.nextcloud.talk.activities.MainActivity
3233
import com.nextcloud.talk.api.NcApi
34+
import com.nextcloud.talk.api.NcApiCoroutines
3335
import com.nextcloud.talk.application.NextcloudTalkApplication
36+
import com.nextcloud.talk.dagger.modules.RestModule
3437
import com.nextcloud.talk.data.user.model.User
38+
import com.nextcloud.talk.models.json.chatpostattachment.PostConversationAttachmentResponse
39+
import com.nextcloud.talk.models.json.chatprobeattachmentfolder.ChatProbeAttachmentData
40+
import com.nextcloud.talk.models.json.chatprobeattachmentfolder.ProbeConversationAttachmentRequest
3541
import com.nextcloud.talk.upload.chunked.ChunkedFileUploader
3642
import com.nextcloud.talk.upload.chunked.OnDataTransferProgressListener
3743
import com.nextcloud.talk.upload.normal.FileUploader
3844
import com.nextcloud.talk.users.UserManager
45+
import com.nextcloud.talk.utils.ApiUtils
3946
import com.nextcloud.talk.utils.CapabilitiesUtil
4047
import com.nextcloud.talk.utils.FileUtils
4148
import com.nextcloud.talk.utils.NotificationUtils
@@ -45,9 +52,16 @@ import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN
4552
import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld
4653
import com.nextcloud.talk.utils.permissions.PlatformPermissionUtil
4754
import com.nextcloud.talk.utils.preferences.AppPreferences
55+
import kotlinx.coroutines.runBlocking
56+
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
4857
import okhttp3.MediaType.Companion.toMediaTypeOrNull
4958
import okhttp3.OkHttpClient
59+
import okhttp3.Protocol
60+
import okhttp3.RequestBody
61+
import okhttp3.Response
5062
import java.io.File
63+
import java.io.IOException
64+
import java.util.UUID
5165
import javax.inject.Inject
5266

5367
@AutoInjector(NextcloudTalkApplication::class)
@@ -61,6 +75,9 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa
6175
@Inject
6276
lateinit var userManager: UserManager
6377

78+
@Inject
79+
lateinit var ncApiCoroutines: NcApiCoroutines
80+
6481
@Inject
6582
lateinit var currentUserProvider: CurrentUserProviderOld
6683

@@ -107,9 +124,12 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa
107124
file = FileUtils.getFileFromUri(context, sourceFileUri)
108125
val remotePath = getRemotePath(currentUser)
109126

127+
val useConversationSubfolders = CapabilitiesUtil.hasConversationSubfoldersForAttachments(
128+
currentUser.capabilities!!.spreedCapability!!
129+
)
110130
initNotificationSetup()
111131
file?.let { isChunkedUploading = it.length() > CHUNK_UPLOAD_THRESHOLD_SIZE }
112-
val uploadSuccess: Boolean = uploadFile(sourceFileUri, metaData, remotePath)
132+
val uploadSuccess: Boolean = uploadFile(sourceFileUri, metaData, remotePath, useConversationSubfolders)
113133

114134
if (uploadSuccess) {
115135
cancelNotification()
@@ -129,25 +149,154 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa
129149
}
130150
}
131151

132-
private fun uploadFile(sourceFileUri: Uri, metaData: String?, remotePath: String): Boolean =
152+
private fun uploadFile(
153+
sourceFileUri: Uri,
154+
metaData: String?,
155+
remotePath: String,
156+
useConversationSubfolders: Boolean
157+
): Boolean =
133158
if (file == null) {
134159
false
160+
} else if (useConversationSubfolders) {
161+
uploadUsingConversationSubfolders(sourceFileUri, metaData)
135162
} else if (isChunkedUploading) {
136163
Log.d(TAG, "starting chunked upload because size is " + file!!.length())
137-
138164
initNotificationWithPercentage()
139165
val mimeType = context.contentResolver.getType(sourceFileUri)?.toMediaTypeOrNull()
140-
141-
chunkedFileUploader = ChunkedFileUploader(okHttpClient, currentUser, roomToken, metaData, this)
166+
chunkedFileUploader = ChunkedFileUploader(
167+
okHttpClient,
168+
currentUser,
169+
roomToken,
170+
metaData,
171+
this,
172+
ncApiCoroutines,
173+
useConversationSubfolders
174+
)
142175
chunkedFileUploader!!.upload(file!!, mimeType, remotePath)
143176
} else {
144177
Log.d(TAG, "starting normal upload (not chunked) of $fileName")
145-
146-
FileUploader(okHttpClient, context, currentUser, roomToken, ncApi, file!!)
178+
FileUploader(
179+
okHttpClient,
180+
context,
181+
currentUser,
182+
roomToken,
183+
ncApi,
184+
file!!,
185+
ncApiCoroutines,
186+
useConversationSubfolders
187+
)
147188
.upload(sourceFileUri, fileName, remotePath, metaData)
148189
.blockingFirst()
149190
}
150191

192+
private fun uploadUsingConversationSubfolders(sourceFileUri: Uri, metaData: String?): Boolean =
193+
runBlocking {
194+
val credentials = ApiUtils.getCredentials(
195+
currentUser.username,
196+
currentUser.token
197+
) ?: return@runBlocking false
198+
val uploadId = UUID.randomUUID().toString()
199+
val fileNames = ProbeConversationAttachmentRequest().apply {
200+
fileNames = listOf(fileName)
201+
}
202+
203+
val probeResponse = ncApiCoroutines.probeConversationAttachmentFolder(
204+
credentials,
205+
ApiUtils.getUrlForChatAttachmentFolder(ApiUtils.API_V1, currentUser.baseUrl, roomToken),
206+
fileNames
207+
)
208+
209+
val draftFolderPath = probeResponse.ocs?.data?.folder
210+
if (draftFolderPath.isNullOrEmpty()) {
211+
Log.e(TAG, "Draft folder path missing in probe response")
212+
return@runBlocking false
213+
}
214+
val predictedName = resolveFinalFileName(fileName, probeResponse.ocs?.data!!)
215+
val tempRemotePath = "/$draftFolderPath/$uploadId-$fileName"
216+
217+
val uploadSuccess = if (isChunkedUploading) {
218+
initNotificationWithPercentage()
219+
val mimeType = context.contentResolver.getType(sourceFileUri)?.toMediaTypeOrNull()
220+
chunkedFileUploader = ChunkedFileUploader(
221+
okHttpClient,
222+
currentUser,
223+
roomToken,
224+
metaData,
225+
this@UploadAndShareFilesWorker,
226+
ncApiCoroutines,
227+
true
228+
)
229+
chunkedFileUploader!!.upload(file!!, mimeType, tempRemotePath)
230+
} else {
231+
FileUploader(okHttpClient, context, currentUser, roomToken, ncApi, file!!, ncApiCoroutines, true)
232+
.uploadToConversationSubfolder(sourceFileUri, tempRemotePath)
233+
}
234+
235+
if (!uploadSuccess) {
236+
return@runBlocking false
237+
}
238+
239+
val params = PostConversationAttachmentResponse().apply {
240+
filePath = tempRemotePath
241+
referenceId = uploadId
242+
talkMetaData = metaData
243+
fileName = predictedName
244+
}
245+
246+
runCatching {
247+
ncApiCoroutines.postConversationAttachment(
248+
credentials,
249+
ApiUtils.getUrlForChatAttachment(ApiUtils.API_V1, currentUser.baseUrl, roomToken),
250+
params
251+
)
252+
}
253+
.onFailure { Log.e(TAG, "Failed to finalize uploaded attachment", it) }
254+
.isSuccess
255+
}
256+
257+
private fun resolveFinalFileName(originalName: String, probeData: ChatProbeAttachmentData): String =
258+
probeData.renames?.get(originalName) ?: originalName
259+
260+
private fun uploadFileToDraftPathUsingWebDav(sourceFileUri: Uri, remotePath: String): Boolean =
261+
runCatching {
262+
val mimeType = context.contentResolver.getType(sourceFileUri)?.toMediaTypeOrNull()
263+
val requestBody = RequestBody.create(mimeType, file!!)
264+
val uploadUrl = ApiUtils.getUrlForFileUpload(
265+
currentUser.baseUrl!!,
266+
currentUser.userId!!,
267+
remotePath
268+
)
269+
val davResource = DavResource(
270+
createWebDavClientWithAuth(),
271+
uploadUrl.toHttpUrlOrNull()!!
272+
)
273+
davResource.put(requestBody) { response: Response ->
274+
if (!response.isSuccessful) {
275+
throw IOException("Failed to upload file to draft folder. response code: ${response.code}")
276+
}
277+
}
278+
FileUtils.copyFileToCache(context, sourceFileUri, fileName)
279+
true
280+
}
281+
.onFailure { Log.e(TAG, "Failed to upload draft attachment via WebDAV", it) }
282+
.getOrDefault(false)
283+
284+
private fun createWebDavClientWithAuth(): OkHttpClient =
285+
okHttpClient.newBuilder()
286+
.followRedirects(false)
287+
.followSslRedirects(false)
288+
.protocols(listOf(Protocol.HTTP_1_1))
289+
.authenticator(
290+
RestModule.HttpAuthenticator(
291+
ApiUtils.getCredentials(
292+
currentUser.username,
293+
currentUser.token
294+
)!!,
295+
"Authorization"
296+
)
297+
)
298+
.build()
299+
151300
private fun getRemotePath(currentUser: User): String {
152301
val remotePath = CapabilitiesUtil.getAttachmentFolder(
153302
currentUser.capabilities!!.spreedCapability!!
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/*
2+
* Nextcloud Talk - Android Client
3+
*
4+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: GPL-3.0-or-later
6+
*/
7+
8+
package com.nextcloud.talk.models.json.chatpostattachment
9+
10+
import android.os.Parcelable
11+
import com.bluelinelabs.logansquare.annotation.JsonField
12+
import com.bluelinelabs.logansquare.annotation.JsonObject
13+
import kotlinx.parcelize.Parcelize
14+
15+
@Parcelize
16+
@JsonObject
17+
data class ChatPostAttachmentData(
18+
@JsonField(name = ["renames"])
19+
var renames: LinkedHashMap<String, String>? = null
20+
) : Parcelable {
21+
// This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject'
22+
constructor() : this(null)
23+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/*
2+
* Nextcloud Talk - Android Client
3+
*
4+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: GPL-3.0-or-later
6+
*/
7+
8+
package com.nextcloud.talk.models.json.chatpostattachment
9+
10+
import android.os.Parcelable
11+
import com.bluelinelabs.logansquare.annotation.JsonField
12+
import com.bluelinelabs.logansquare.annotation.JsonObject
13+
import kotlinx.parcelize.Parcelize
14+
15+
@Parcelize
16+
@JsonObject
17+
data class ChatPostAttachmentOCS(
18+
@JsonField(name = ["data"])
19+
var data: ChatPostAttachmentData? = null
20+
) : Parcelable {
21+
// This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject'
22+
constructor() : this(null)
23+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/*
2+
* Nextcloud Talk - Android Client
3+
*
4+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: GPL-3.0-or-later
6+
*/
7+
8+
package com.nextcloud.talk.models.json.chatpostattachment
9+
10+
import android.os.Parcelable
11+
import com.bluelinelabs.logansquare.annotation.JsonField
12+
import com.bluelinelabs.logansquare.annotation.JsonObject
13+
import kotlinx.parcelize.Parcelize
14+
15+
@Parcelize
16+
@JsonObject
17+
data class ChatPostAttachmentOverall(
18+
@JsonField(name = ["ocs"])
19+
var ocs: ChatPostAttachmentOCS? = null
20+
) : Parcelable {
21+
// This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject'
22+
constructor() : this(null)
23+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/*
2+
* Nextcloud Talk - Android Client
3+
*
4+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: GPL-3.0-or-later
6+
*/
7+
8+
package com.nextcloud.talk.models.json.chatpostattachment
9+
10+
import com.bluelinelabs.logansquare.annotation.JsonField
11+
import com.bluelinelabs.logansquare.annotation.JsonObject
12+
13+
@JsonObject
14+
class PostConversationAttachmentResponse {
15+
16+
@JsonField(name = ["filePath"])
17+
var filePath: String? = null
18+
19+
@JsonField(name = ["referenceId"])
20+
var referenceId: String? = null
21+
22+
@JsonField(name = ["talkMetaData"])
23+
var talkMetaData: String? = null
24+
25+
@JsonField(name = ["fileName"])
26+
var fileName: String? = null
27+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/*
2+
* Nextcloud Talk - Android Client
3+
*
4+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: GPL-3.0-or-later
6+
*/
7+
8+
package com.nextcloud.talk.models.json.chatprobeattachmentfolder
9+
10+
import android.os.Parcelable
11+
import com.bluelinelabs.logansquare.annotation.JsonField
12+
import com.bluelinelabs.logansquare.annotation.JsonObject
13+
import kotlinx.parcelize.Parcelize
14+
15+
@Parcelize
16+
@JsonObject
17+
data class ChatProbeAttachmentData(
18+
@JsonField(name = ["folder"])
19+
var folder: String? = null,
20+
@JsonField(name = ["renames"])
21+
var renames: LinkedHashMap<String, String>? = null
22+
) : Parcelable {
23+
// This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject'
24+
constructor() : this(null, null)
25+
}

0 commit comments

Comments
 (0)