Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ import com.nextcloud.talk.conversationinfo.CreateRoomRequest
import com.nextcloud.talk.models.json.autocomplete.AutocompleteOverall
import com.nextcloud.talk.models.json.chat.ChatOverall
import com.nextcloud.talk.models.json.chat.ChatOverallSingleMessage
import com.nextcloud.talk.models.json.chatpostattachment.ChatPostAttachmentOverall
import com.nextcloud.talk.models.json.chatpostattachment.PostConversationAttachmentResponse
import com.nextcloud.talk.models.json.chatprobeattachmentfolder.ChatProbeAttachmentFolderOverall
import com.nextcloud.talk.models.json.chatprobeattachmentfolder.ProbeConversationAttachmentRequest
import com.nextcloud.talk.models.json.conversations.RoomOverall
import com.nextcloud.talk.models.json.conversations.RoomsOverall
import com.nextcloud.talk.models.json.generic.GenericOverall
Expand Down Expand Up @@ -463,4 +467,25 @@ interface NcApiCoroutines {
// Url is: /api/{apiVersion}/chat/{token}/read
@DELETE
suspend fun markRoomAsUnread(@Header("Authorization") authorization: String, @Url url: String): GenericOverall

@POST
suspend fun probeConversationAttachmentFolder(
@Header("Authorization") authorization: String,
@Url url: String,
@Body request: ProbeConversationAttachmentRequest
): ChatProbeAttachmentFolderOverall

@POST
suspend fun postConversationAttachment(
@Header("Authorization") authorization: String,
@Url url: String,
@Body body: PostConversationAttachmentResponse
): ChatPostAttachmentOverall

@PUT
suspend fun uploadFile(
@Header("Authorization") authorization: String,
@Url url: String,
@Body body: RequestBody
): Response<GenericOverall>
}
162 changes: 155 additions & 7 deletions app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,23 @@ import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import androidx.work.Worker
import androidx.work.WorkerParameters
import at.bitfire.dav4jvm.DavResource
import autodagger.AutoInjector
import com.nextcloud.talk.R
import com.nextcloud.talk.activities.MainActivity
import com.nextcloud.talk.api.NcApi
import com.nextcloud.talk.api.NcApiCoroutines
import com.nextcloud.talk.application.NextcloudTalkApplication
import com.nextcloud.talk.dagger.modules.RestModule
import com.nextcloud.talk.data.user.model.User
import com.nextcloud.talk.models.json.chatpostattachment.PostConversationAttachmentResponse
import com.nextcloud.talk.models.json.chatprobeattachmentfolder.ChatProbeAttachmentData
import com.nextcloud.talk.models.json.chatprobeattachmentfolder.ProbeConversationAttachmentRequest
import com.nextcloud.talk.upload.chunked.ChunkedFileUploader
import com.nextcloud.talk.upload.chunked.OnDataTransferProgressListener
import com.nextcloud.talk.upload.normal.FileUploader
import com.nextcloud.talk.users.UserManager
import com.nextcloud.talk.utils.ApiUtils
import com.nextcloud.talk.utils.CapabilitiesUtil
import com.nextcloud.talk.utils.FileUtils
import com.nextcloud.talk.utils.NotificationUtils
Expand All @@ -45,9 +52,16 @@ import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN
import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld
import com.nextcloud.talk.utils.permissions.PlatformPermissionUtil
import com.nextcloud.talk.utils.preferences.AppPreferences
import kotlinx.coroutines.runBlocking
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient
import okhttp3.Protocol
import okhttp3.RequestBody
import okhttp3.Response
import java.io.File
import java.io.IOException
import java.util.UUID
import javax.inject.Inject

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

@Inject
lateinit var ncApiCoroutines: NcApiCoroutines

@Inject
lateinit var currentUserProvider: CurrentUserProviderOld

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

val useConversationSubfolders = CapabilitiesUtil.hasConversationSubfoldersForAttachments(
currentUser.capabilities!!.spreedCapability!!
)
initNotificationSetup()
file?.let { isChunkedUploading = it.length() > CHUNK_UPLOAD_THRESHOLD_SIZE }
val uploadSuccess: Boolean = uploadFile(sourceFileUri, metaData, remotePath)
val uploadSuccess: Boolean = uploadFile(sourceFileUri, metaData, remotePath, useConversationSubfolders)

if (uploadSuccess) {
cancelNotification()
Expand All @@ -129,25 +149,153 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa
}
}

private fun uploadFile(sourceFileUri: Uri, metaData: String?, remotePath: String): Boolean =
private fun uploadFile(
sourceFileUri: Uri,
metaData: String?,
remotePath: String,
useConversationSubfolders: Boolean
): Boolean =
if (file == null) {
false
} else if (useConversationSubfolders) {
uploadUsingConversationSubfolders(sourceFileUri, metaData)
} else if (isChunkedUploading) {
Log.d(TAG, "starting chunked upload because size is " + file!!.length())

initNotificationWithPercentage()
val mimeType = context.contentResolver.getType(sourceFileUri)?.toMediaTypeOrNull()

chunkedFileUploader = ChunkedFileUploader(okHttpClient, currentUser, roomToken, metaData, this)
chunkedFileUploader = ChunkedFileUploader(
okHttpClient,
currentUser,
roomToken,
metaData,
this,
ncApiCoroutines,
useConversationSubfolders
)
chunkedFileUploader!!.upload(file!!, mimeType, remotePath)
} else {
Log.d(TAG, "starting normal upload (not chunked) of $fileName")

FileUploader(okHttpClient, context, currentUser, roomToken, ncApi, file!!)
FileUploader(
okHttpClient,
context,
currentUser,
roomToken,
ncApi,
file!!,
ncApiCoroutines
)
.upload(sourceFileUri, fileName, remotePath, metaData)
.blockingFirst()
}

private fun uploadUsingConversationSubfolders(sourceFileUri: Uri, metaData: String?): Boolean =
runBlocking {
val credentials = ApiUtils.getCredentials(
currentUser.username,
currentUser.token
) ?: return@runBlocking false
val uploadId = UUID.randomUUID().toString()
val fileNames = ProbeConversationAttachmentRequest().apply {
fileNames = listOf(fileName)
}

val probeResponse = ncApiCoroutines.probeConversationAttachmentFolder(
credentials,
ApiUtils.getUrlForChatAttachmentFolder(ApiUtils.API_V1, currentUser.baseUrl, roomToken),
fileNames
)

val draftFolderPath = probeResponse.ocs?.data?.folder
if (draftFolderPath.isNullOrEmpty()) {
Log.e(TAG, "Draft folder path missing in probe response")
return@runBlocking false
}
val predictedName = resolveFinalFileName(fileName, probeResponse.ocs?.data!!)
val tempRemotePath = "/$draftFolderPath/$uploadId-$fileName"

val uploadSuccess = if (isChunkedUploading) {
initNotificationWithPercentage()
val mimeType = context.contentResolver.getType(sourceFileUri)?.toMediaTypeOrNull()
chunkedFileUploader = ChunkedFileUploader(
okHttpClient,
currentUser,
roomToken,
metaData,
this@UploadAndShareFilesWorker,
ncApiCoroutines,
true
)
chunkedFileUploader!!.upload(file!!, mimeType, tempRemotePath)
} else {
FileUploader(okHttpClient, context, currentUser, roomToken, ncApi, file!!, ncApiCoroutines)
.uploadToConversationSubfolder(sourceFileUri, tempRemotePath)
}

if (!uploadSuccess) {
return@runBlocking false
}

val params = PostConversationAttachmentResponse().apply {
filePath = tempRemotePath
referenceId = uploadId
talkMetaData = metaData
fileName = predictedName
}

runCatching {
ncApiCoroutines.postConversationAttachment(
credentials,
ApiUtils.getUrlForChatAttachment(ApiUtils.API_V1, currentUser.baseUrl, roomToken),
params
)
}
.onFailure { Log.e(TAG, "Failed to finalize uploaded attachment", it) }
.isSuccess
}

private fun resolveFinalFileName(originalName: String, probeData: ChatProbeAttachmentData): String =
probeData.renames?.get(originalName) ?: originalName

private fun uploadFileToDraftPathUsingWebDav(sourceFileUri: Uri, remotePath: String): Boolean =
runCatching {
val mimeType = context.contentResolver.getType(sourceFileUri)?.toMediaTypeOrNull()
val requestBody = RequestBody.create(mimeType, file!!)
val uploadUrl = ApiUtils.getUrlForFileUpload(
currentUser.baseUrl!!,
currentUser.userId!!,
remotePath
)
val davResource = DavResource(
createWebDavClientWithAuth(),
uploadUrl.toHttpUrlOrNull()!!
)
davResource.put(requestBody) { response: Response ->
if (!response.isSuccessful) {
throw IOException("Failed to upload file to draft folder. response code: ${response.code}")
}
}
FileUtils.copyFileToCache(context, sourceFileUri, fileName)
true
}
.onFailure { Log.e(TAG, "Failed to upload draft attachment via WebDAV", it) }
.getOrDefault(false)

private fun createWebDavClientWithAuth(): OkHttpClient =
okHttpClient.newBuilder()
.followRedirects(false)
.followSslRedirects(false)
.protocols(listOf(Protocol.HTTP_1_1))
.authenticator(
RestModule.HttpAuthenticator(
ApiUtils.getCredentials(
currentUser.username,
currentUser.token
)!!,
"Authorization"
)
)
.build()

private fun getRemotePath(currentUser: User): String {
val remotePath = CapabilitiesUtil.getAttachmentFolder(
currentUser.capabilities!!.spreedCapability!!
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/*
* Nextcloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/

package com.nextcloud.talk.models.json.chatpostattachment

import android.os.Parcelable
import com.bluelinelabs.logansquare.annotation.JsonField
import com.bluelinelabs.logansquare.annotation.JsonObject
import kotlinx.parcelize.Parcelize

@Parcelize
@JsonObject
data class ChatPostAttachmentData(
@JsonField(name = ["renames"])
var renames: LinkedHashMap<String, String>? = null
) : Parcelable {
// This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject'
constructor() : this(null)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/*
* Nextcloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/

package com.nextcloud.talk.models.json.chatpostattachment

import android.os.Parcelable
import com.bluelinelabs.logansquare.annotation.JsonField
import com.bluelinelabs.logansquare.annotation.JsonObject
import kotlinx.parcelize.Parcelize

@Parcelize
@JsonObject
data class ChatPostAttachmentOCS(
@JsonField(name = ["data"])
var data: ChatPostAttachmentData? = null
) : Parcelable {
// This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject'
constructor() : this(null)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/*
* Nextcloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/

package com.nextcloud.talk.models.json.chatpostattachment

import android.os.Parcelable
import com.bluelinelabs.logansquare.annotation.JsonField
import com.bluelinelabs.logansquare.annotation.JsonObject
import kotlinx.parcelize.Parcelize

@Parcelize
@JsonObject
data class ChatPostAttachmentOverall(
@JsonField(name = ["ocs"])
var ocs: ChatPostAttachmentOCS? = null
) : Parcelable {
// This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject'
constructor() : this(null)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/*
* Nextcloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/

package com.nextcloud.talk.models.json.chatpostattachment

import com.bluelinelabs.logansquare.annotation.JsonField
import com.bluelinelabs.logansquare.annotation.JsonObject

@JsonObject
class PostConversationAttachmentResponse {

@JsonField(name = ["filePath"])
var filePath: String? = null

@JsonField(name = ["referenceId"])
var referenceId: String? = null

@JsonField(name = ["talkMetaData"])
var talkMetaData: String? = null

@JsonField(name = ["fileName"])
var fileName: String? = null
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*
* Nextcloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/

package com.nextcloud.talk.models.json.chatprobeattachmentfolder

import android.os.Parcelable
import com.bluelinelabs.logansquare.annotation.JsonField
import com.bluelinelabs.logansquare.annotation.JsonObject
import kotlinx.parcelize.Parcelize

@Parcelize
@JsonObject
data class ChatProbeAttachmentData(
@JsonField(name = ["folder"])
var folder: String? = null,
@JsonField(name = ["renames"])
var renames: LinkedHashMap<String, String>? = null
) : Parcelable {
// This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject'
constructor() : this(null, null)
}
Loading
Loading