Pārlūkot izejas kodu

add chunked upload for files

Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
Marcel Hibbe 2 gadi atpakaļ
vecāks
revīzija
d230d0faf2
22 mainītis faili ar 1396 papildinājumiem un 451 dzēšanām
  1. 1 0
      app/src/main/java/com/nextcloud/talk/activities/TakePhotoActivity.java
  2. 6 1
      app/src/main/java/com/nextcloud/talk/api/NcApi.java
  3. 73 63
      app/src/main/java/com/nextcloud/talk/controllers/ChatController.kt
  4. 9 21
      app/src/main/java/com/nextcloud/talk/controllers/ConversationsListController.kt
  5. 1 1
      app/src/main/java/com/nextcloud/talk/controllers/ProfileController.kt
  6. 0 115
      app/src/main/java/com/nextcloud/talk/jobs/ShareOperationWorker.java
  7. 116 0
      app/src/main/java/com/nextcloud/talk/jobs/ShareOperationWorker.kt
  8. 193 128
      app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt
  9. 28 0
      app/src/main/java/com/nextcloud/talk/upload/chunked/Chunk.kt
  10. 124 0
      app/src/main/java/com/nextcloud/talk/upload/chunked/ChunkFromFileRequestBody.kt
  11. 396 0
      app/src/main/java/com/nextcloud/talk/upload/chunked/ChunkedFileUploader.kt
  12. 31 0
      app/src/main/java/com/nextcloud/talk/upload/chunked/OnDataTransferProgressListener.kt
  13. 69 0
      app/src/main/java/com/nextcloud/talk/upload/normal/FileUploader.kt
  14. 6 2
      app/src/main/java/com/nextcloud/talk/utils/ApiUtils.java
  15. 0 85
      app/src/main/java/com/nextcloud/talk/utils/FileUtils.java
  16. 172 0
      app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt
  17. 34 6
      app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt
  18. 99 0
      app/src/main/java/com/nextcloud/talk/utils/RemoteFileUtils.kt
  19. 0 28
      app/src/main/java/com/nextcloud/talk/utils/UriUtils.kt
  20. 27 0
      app/src/main/res/drawable/upload_white.xml
  21. 10 0
      app/src/main/res/values/strings.xml
  22. 1 1
      scripts/analysis/findbugs-results.txt

+ 1 - 0
app/src/main/java/com/nextcloud/talk/activities/TakePhotoActivity.java

@@ -321,6 +321,7 @@ public class TakePhotoActivity extends AppCompatActivity {
                         }
                     });
             } catch (Exception e) {
+                Log.e(TAG, "error while taking picture", e);
                 Toast.makeText(this, R.string.take_photo_error_deleting_picture, Toast.LENGTH_SHORT).show();
             }
         });

+ 6 - 1
app/src/main/java/com/nextcloud/talk/api/NcApi.java

@@ -65,6 +65,7 @@ import retrofit2.http.Field;
 import retrofit2.http.FieldMap;
 import retrofit2.http.FormUrlEncoded;
 import retrofit2.http.GET;
+import retrofit2.http.HEAD;
 import retrofit2.http.Header;
 import retrofit2.http.Multipart;
 import retrofit2.http.POST;
@@ -393,7 +394,7 @@ public interface NcApi {
 
     @FormUrlEncoded
     @POST
-    Observable<Void> createRemoteShare(@Nullable @Header("Authorization") String authorization, @Url String url,
+    Observable<GenericOverall> createRemoteShare(@Nullable @Header("Authorization") String authorization, @Url String url,
                                        @Field("path") String remotePath,
                                        @Field("shareWith") String roomToken,
                                        @Field("shareType") String shareType,
@@ -420,6 +421,10 @@ public interface NcApi {
                                                     @Url String url,
                                                     @Body RequestBody body);
 
+    @HEAD
+    Observable<Response<Void>> checkIfFileExists(@Header("Authorization") String authorization,
+                                               @Url String url);
+
     @GET
     Call<ResponseBody> downloadFile(@Header("Authorization") String authorization,
                                     @Url String url);

+ 73 - 63
app/src/main/java/com/nextcloud/talk/controllers/ChatController.kt

@@ -162,10 +162,10 @@ import com.nextcloud.talk.utils.ConductorRemapping.remapChatController
 import com.nextcloud.talk.utils.ContactUtils
 import com.nextcloud.talk.utils.DateUtils
 import com.nextcloud.talk.utils.DisplayUtils
+import com.nextcloud.talk.utils.FileUtils
 import com.nextcloud.talk.utils.ImageEmojiEditText
 import com.nextcloud.talk.utils.MagicCharPolicy
 import com.nextcloud.talk.utils.NotificationUtils
-import com.nextcloud.talk.utils.UriUtils
 import com.nextcloud.talk.utils.bundle.BundleKeys
 import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ACTIVE_CONVERSATION
 import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_CONVERSATION_NAME
@@ -286,6 +286,8 @@ class ChatController(args: Bundle) :
 
     var hasChatPermission: Boolean = false
 
+    private var videoURI: Uri? = null
+
     init {
         Log.d(TAG, "init ChatController: " + System.identityHashCode(this).toString())
 
@@ -736,7 +738,7 @@ class ChatController(args: Bundle) :
         // Image keyboard support
         // See: https://developer.android.com/guide/topics/text/image-keyboard
         (binding.messageInputView.inputEditText as ImageEmojiEditText).onCommitContentListener = {
-            uploadFiles(mutableListOf(it.toString()), false)
+            uploadFile(it.toString(), false)
         }
 
         showMicrophoneButton(true)
@@ -1175,7 +1177,7 @@ class ChatController(args: Bundle) :
     private fun stopAndSendAudioRecording() {
         stopAudioRecording()
         val uri = Uri.fromFile(File(currentVoiceRecordFile))
-        uploadFiles(mutableListOf(uri.toString()), true)
+        uploadFile(uri.toString(), true)
     }
 
     private fun stopAndDiscardAudioRecording() {
@@ -1361,6 +1363,7 @@ class ChatController(args: Bundle) :
         }
     }
 
+    @Throws(IllegalStateException::class)
     override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) {
         if (resultCode != RESULT_OK && (requestCode != REQUEST_CODE_MESSAGE_SEARCH)) {
             Log.e(TAG, "resultCode for received intent was != ok")
@@ -1405,7 +1408,7 @@ class ChatController(args: Bundle) :
                     val filenamesWithLineBreaks = StringBuilder("\n")
 
                     for (file in filesToUpload) {
-                        val filename = UriUtils.getFileName(Uri.parse(file), context)
+                        val filename = FileUtils.getFileName(Uri.parse(file), context)
                         filenamesWithLineBreaks.append(filename).append("\n")
                     }
 
@@ -1423,7 +1426,7 @@ class ChatController(args: Bundle) :
                         .setMessage(filenamesWithLineBreaks.toString())
                         .setPositiveButton(R.string.nc_yes) { _, _ ->
                             if (UploadAndShareFilesWorker.isStoragePermissionGranted(context)) {
-                                uploadFiles(filesToUpload, false)
+                                uploadFiles(filesToUpload)
                             } else {
                                 UploadAndShareFilesWorker.requestStoragePermission(this)
                             }
@@ -1468,25 +1471,31 @@ class ChatController(args: Bundle) :
                         BuildConfig.APPLICATION_ID,
                         File(file.absolutePath)
                     )
-                    uploadFiles(mutableListOf(shareUri.toString()), false)
+                    uploadFile(shareUri.toString(), false)
                 }
                 cursor?.close()
             }
             REQUEST_CODE_PICK_CAMERA -> {
                 if (resultCode == RESULT_OK) {
                     try {
-                        checkNotNull(intent)
                         filesToUpload.clear()
-                        run {
-                            checkNotNull(intent.data)
-                            intent.data.let {
-                                filesToUpload.add(intent.data.toString())
+
+                        if (intent != null && intent.data != null) {
+                            run {
+                                intent.data.let {
+                                    filesToUpload.add(intent.data.toString())
+                                }
                             }
+                            require(filesToUpload.isNotEmpty())
+                        } else if (videoURI != null) {
+                            filesToUpload.add(videoURI.toString())
+                            videoURI = null
+                        } else {
+                            throw IllegalStateException("Failed to get data from intent and uri")
                         }
-                        require(filesToUpload.isNotEmpty())
 
                         if (UploadAndShareFilesWorker.isStoragePermissionGranted(context)) {
-                            uploadFiles(filesToUpload, false)
+                            uploadFiles(filesToUpload)
                         } else {
                             UploadAndShareFilesWorker.requestStoragePermission(this)
                         }
@@ -1549,7 +1558,7 @@ class ChatController(args: Bundle) :
             if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
                 Log.d(ConversationsListController.TAG, "upload starting after permissions were granted")
                 if (filesToUpload.isNotEmpty()) {
-                    uploadFiles(filesToUpload, false)
+                    uploadFiles(filesToUpload)
                 }
             } else {
                 Toast
@@ -1579,8 +1588,9 @@ class ChatController(args: Bundle) :
             }
         } else if (requestCode == REQUEST_CAMERA_PERMISSION) {
             if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
-                Log.d(TAG, "launch cam activity since permission for cam has been granted")
-                startActivityForResult(TakePhotoActivity.createIntent(context), REQUEST_CODE_PICK_CAMERA)
+                Toast
+                    .makeText(context, context.getString(R.string.camera_permission_granted), Toast.LENGTH_LONG)
+                    .show()
             } else {
                 Toast
                     .makeText(context, context.getString(R.string.take_photo_permission), Toast.LENGTH_LONG)
@@ -1589,11 +1599,17 @@ class ChatController(args: Bundle) :
         }
     }
 
-    private fun uploadFiles(files: MutableList<String>, isVoiceMessage: Boolean) {
+    private fun uploadFiles(files: MutableList<String>) {
+        for (file in files) {
+            uploadFile(file, false)
+        }
+    }
+
+    private fun uploadFile(fileUri: String, isVoiceMessage: Boolean) {
         var metaData = ""
 
         if (!hasChatPermission) {
-            Log.w(TAG, "uploading file(s) is forbidden because of missing attendee permissions")
+            Log.w(TAG, "uploading file is forbidden because of missing attendee permissions")
             return
         }
 
@@ -1602,28 +1618,13 @@ class ChatController(args: Bundle) :
         }
 
         try {
-            require(files.isNotEmpty())
-            val data: Data = Data.Builder()
-                .putStringArray(UploadAndShareFilesWorker.DEVICE_SOURCEFILES, files.toTypedArray())
-                .putString(
-                    UploadAndShareFilesWorker.NC_TARGETPATH,
-                    CapabilitiesUtilNew.getAttachmentFolder(conversationUser!!)
-                )
-                .putString(UploadAndShareFilesWorker.ROOM_TOKEN, roomToken)
-                .putString(UploadAndShareFilesWorker.META_DATA, metaData)
-                .build()
-            val uploadWorker: OneTimeWorkRequest = OneTimeWorkRequest.Builder(UploadAndShareFilesWorker::class.java)
-                .setInputData(data)
-                .build()
-            WorkManager.getInstance().enqueue(uploadWorker)
-
-            if (!isVoiceMessage) {
-                Toast.makeText(
-                    context,
-                    context.getString(R.string.nc_upload_in_progess),
-                    Toast.LENGTH_LONG
-                ).show()
-            }
+            require(fileUri.isNotEmpty())
+            UploadAndShareFilesWorker.upload(
+                fileUri,
+                roomToken!!,
+                currentConversation?.displayName!!,
+                metaData
+            )
         } catch (e: IllegalArgumentException) {
             Toast.makeText(context, context.resources?.getString(R.string.nc_upload_failed), Toast.LENGTH_LONG).show()
             Log.e(javaClass.simpleName, "Something went wrong when trying to upload file", e)
@@ -2277,16 +2278,18 @@ class ChatController(args: Bundle) :
             val messagesToDelete: ArrayList<ChatMessage> = ArrayList()
             val systemTime = System.currentTimeMillis() / ONE_SECOND_IN_MILLIS
 
-            for (itemWrapper in adapter?.items!!) {
-                if (itemWrapper.item is ChatMessage) {
-                    val chatMessage = itemWrapper.item as ChatMessage
-                    if (chatMessage.expirationTimestamp != 0 && chatMessage.expirationTimestamp < systemTime) {
-                        messagesToDelete.add(chatMessage)
+            if (adapter?.items != null) {
+                for (itemWrapper in adapter?.items!!) {
+                    if (itemWrapper.item is ChatMessage) {
+                        val chatMessage = itemWrapper.item as ChatMessage
+                        if (chatMessage.expirationTimestamp != 0 && chatMessage.expirationTimestamp < systemTime) {
+                            messagesToDelete.add(chatMessage)
+                        }
                     }
                 }
+                adapter!!.delete(messagesToDelete)
+                adapter!!.notifyDataSetChanged()
             }
-            adapter!!.delete(messagesToDelete)
-            adapter!!.notifyDataSetChanged()
         }
 
         if (CapabilitiesUtilNew.hasSpreedFeatureCapability(conversationUser, "message-expiration")) {
@@ -3255,22 +3258,30 @@ class ChatController(args: Bundle) :
     }
 
     fun sendVideoFromCamIntent() {
-        Intent(MediaStore.ACTION_VIDEO_CAPTURE).also { takeVideoIntent ->
-            takeVideoIntent.resolveActivity(activity!!.packageManager)?.also {
-                val videoFile: File? = try {
-                    val outputDir = context.cacheDir
-                    val dateFormat = SimpleDateFormat(FILE_DATE_PATTERN, Locale.ROOT)
-                    val date = dateFormat.format(Date())
-                    File("$outputDir/$VIDEO_PREFIX_PART$date$VIDEO_SUFFIX")
-                } catch (e: IOException) {
-                    Log.e(TAG, "error while creating video file", e)
-                    null
-                }
+        if (!permissionUtil.isCameraPermissionGranted()) {
+            requestCameraPermissions()
+        } else {
+            Intent(MediaStore.ACTION_VIDEO_CAPTURE).also { takeVideoIntent ->
+                takeVideoIntent.resolveActivity(activity!!.packageManager)?.also {
+                    val videoFile: File? = try {
+                        val outputDir = context.cacheDir
+                        val dateFormat = SimpleDateFormat(FILE_DATE_PATTERN, Locale.ROOT)
+                        val date = dateFormat.format(Date())
+                        val videoName = String.format(
+                            context.resources.getString(R.string.nc_video_filename),
+                            date
+                        )
+                        File("$outputDir/$videoName$VIDEO_SUFFIX")
+                    } catch (e: IOException) {
+                        Log.e(TAG, "error while creating video file", e)
+                        null
+                    }
 
-                videoFile?.also {
-                    val videoURI: Uri = FileProvider.getUriForFile(context, context.packageName, it)
-                    takeVideoIntent.putExtra(MediaStore.EXTRA_OUTPUT, videoURI)
-                    startActivityForResult(takeVideoIntent, REQUEST_CODE_PICK_CAMERA)
+                    videoFile?.also {
+                        videoURI = FileProvider.getUriForFile(context, context.packageName, it)
+                        takeVideoIntent.putExtra(MediaStore.EXTRA_OUTPUT, videoURI)
+                        startActivityForResult(takeVideoIntent, REQUEST_CODE_PICK_CAMERA)
+                    }
                 }
             }
         }
@@ -3312,7 +3323,6 @@ class ChatController(args: Bundle) :
         private const val VOICE_MESSAGE_META_DATA = "{\"messageType\":\"voice-message\"}"
         private const val VOICE_MESSAGE_FILE_SUFFIX = ".mp3"
         private const val FILE_DATE_PATTERN = "yyyy-MM-dd HH-mm-ss"
-        private const val VIDEO_PREFIX_PART = "Talk Video "
         private const val VIDEO_SUFFIX = ".mp4"
         private const val SHORT_VIBRATE: Long = 20
         private const val FULLY_OPAQUE_INT: Int = 255

+ 9 - 21
app/src/main/java/com/nextcloud/talk/controllers/ConversationsListController.kt

@@ -107,8 +107,8 @@ import com.nextcloud.talk.utils.AttendeePermissionsUtil
 import com.nextcloud.talk.utils.ClosedInterfaceImpl
 import com.nextcloud.talk.utils.ConductorRemapping.remapChatController
 import com.nextcloud.talk.utils.DisplayUtils
+import com.nextcloud.talk.utils.FileUtils
 import com.nextcloud.talk.utils.Mimetype
-import com.nextcloud.talk.utils.UriUtils.Companion.getFileName
 import com.nextcloud.talk.utils.bundle.BundleKeys
 import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ACTIVE_CONVERSATION
 import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_FORWARD_HIDE_SOURCE_ROOM
@@ -121,7 +121,6 @@ import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_ID
 import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN
 import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_SHARED_TEXT
 import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_USER_ENTITY
-import com.nextcloud.talk.utils.database.user.CapabilitiesUtilNew.getAttachmentFolder
 import com.nextcloud.talk.utils.database.user.CapabilitiesUtilNew.hasSpreedFeatureCapability
 import com.nextcloud.talk.utils.database.user.CapabilitiesUtilNew.isServerEOL
 import com.nextcloud.talk.utils.database.user.CapabilitiesUtilNew.isUnifiedSearchAvailable
@@ -924,7 +923,7 @@ class ConversationsListController(bundle: Bundle) :
         if (isStoragePermissionGranted(context)) {
             val fileNamesWithLineBreaks = StringBuilder("\n")
             for (file in filesToShare!!) {
-                val filename = getFileName(Uri.parse(file), context)
+                val filename = FileUtils.getFileName(Uri.parse(file), context)
                 fileNamesWithLineBreaks.append(filename).append("\n")
             }
             val confirmationQuestion: String = if (filesToShare!!.size == 1) {
@@ -1042,25 +1041,14 @@ class ConversationsListController(bundle: Bundle) :
             return
         }
         try {
-            var filesToShareArray: Array<String?> = arrayOfNulls(filesToShare!!.size)
-            filesToShareArray = filesToShare!!.toArray(filesToShareArray)
-            val data = Data.Builder()
-                .putStringArray(UploadAndShareFilesWorker.DEVICE_SOURCEFILES, filesToShareArray)
-                .putString(
-                    UploadAndShareFilesWorker.NC_TARGETPATH,
-                    getAttachmentFolder(currentUser!!)
+            filesToShare?.forEach {
+                UploadAndShareFilesWorker.upload(
+                    it,
+                    selectedConversation!!.token!!,
+                    selectedConversation!!.displayName!!,
+                    null
                 )
-                .putString(UploadAndShareFilesWorker.ROOM_TOKEN, selectedConversation!!.token)
-                .build()
-            val uploadWorker = OneTimeWorkRequest.Builder(UploadAndShareFilesWorker::class.java)
-                .setInputData(data)
-                .build()
-            WorkManager.getInstance().enqueue(uploadWorker)
-            Toast.makeText(
-                context,
-                context.resources.getString(R.string.nc_upload_in_progess),
-                Toast.LENGTH_LONG
-            ).show()
+            }
         } catch (e: IllegalArgumentException) {
             Toast.makeText(context, context.resources.getString(R.string.nc_upload_failed), Toast.LENGTH_LONG).show()
             Log.e(TAG, "Something went wrong when trying to upload file", e)

+ 1 - 1
app/src/main/java/com/nextcloud/talk/controllers/ProfileController.kt

@@ -572,7 +572,7 @@ class ProfileController : BaseController(R.layout.controller_profile) {
         return null
     }
 
-    private fun createTempFileForAvatar(): File? {
+    private fun createTempFileForAvatar(): File {
         FileUtils.removeTempCacheFile(
             this.context,
             AVATAR_PATH

+ 0 - 115
app/src/main/java/com/nextcloud/talk/jobs/ShareOperationWorker.java

@@ -1,115 +0,0 @@
-/*
- * Nextcloud Talk application
- *
- * @author Mario Danic
- * Copyright (C) 2017-2018 Mario Danic <mario@lovelyhq.com>
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-package com.nextcloud.talk.jobs;
-
-import android.content.Context;
-import android.util.Log;
-
-import com.nextcloud.talk.api.NcApi;
-import com.nextcloud.talk.application.NextcloudTalkApplication;
-import com.nextcloud.talk.data.user.model.User;
-import com.nextcloud.talk.users.UserManager;
-import com.nextcloud.talk.utils.ApiUtils;
-import com.nextcloud.talk.utils.bundle.BundleKeys;
-
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-
-import javax.inject.Inject;
-
-import androidx.annotation.NonNull;
-import androidx.work.Data;
-import androidx.work.Worker;
-import androidx.work.WorkerParameters;
-import autodagger.AutoInjector;
-import io.reactivex.Observer;
-import io.reactivex.disposables.Disposable;
-import io.reactivex.schedulers.Schedulers;
-
-@AutoInjector(NextcloudTalkApplication.class)
-public class ShareOperationWorker extends Worker {
-
-    @Inject
-    UserManager userManager;
-
-    @Inject
-    NcApi ncApi;
-
-    private final String TAG = "ShareOperationWorker";
-    private long userId;
-    private String roomToken;
-    private List<String> filesArray = new ArrayList<>();
-    private String credentials;
-    private String baseUrl;
-    private String metaData;
-
-    public ShareOperationWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) {
-        super(context, workerParams);
-        NextcloudTalkApplication.Companion.getSharedApplication().getComponentApplication().inject(this);
-        Data data = workerParams.getInputData();
-        userId = data.getLong(BundleKeys.INSTANCE.getKEY_INTERNAL_USER_ID(), 0);
-        roomToken = data.getString(BundleKeys.INSTANCE.getKEY_ROOM_TOKEN());
-        metaData = data.getString(BundleKeys.INSTANCE.getKEY_META_DATA());
-        Collections.addAll(filesArray, data.getStringArray(BundleKeys.INSTANCE.getKEY_FILE_PATHS()));
-        User operationsUser = userManager.getUserWithId(userId).blockingGet();
-        credentials = ApiUtils.getCredentials(operationsUser.getUsername(), operationsUser.getToken());
-        baseUrl = operationsUser.getBaseUrl();
-    }
-
-    @NonNull
-    @Override
-    public Result doWork() {
-
-        for (int i = 0; i < filesArray.size(); i++) {
-            ncApi.createRemoteShare(credentials,
-                    ApiUtils.getSharingUrl(baseUrl),
-                    filesArray.get(i),
-                    roomToken,
-                    "10",
-                     metaData)
-                    .subscribeOn(Schedulers.io())
-                    .blockingSubscribe(new Observer<Void>() {
-                        @Override
-                        public void onSubscribe(Disposable d) {
-                            // unused atm
-                        }
-
-                        @Override
-                        public void onNext(Void aVoid) {
-                            // unused atm
-                        }
-
-                        @Override
-                        public void onError(Throwable e) {
-                            Log.w(TAG, "error while creating RemoteShare", e);
-                        }
-
-                        @Override
-                        public void onComplete() {
-                            // unused atm
-                        }
-                    });
-        }
-
-        return Result.success();
-    }
-}

+ 116 - 0
app/src/main/java/com/nextcloud/talk/jobs/ShareOperationWorker.kt

@@ -0,0 +1,116 @@
+/*
+ * Nextcloud Talk application
+ *
+ * @author Marcel Hibbe
+ * @author Mario Danic
+ * Copyright (C) 2022 Marcel Hibbe <dev@mhibbe.de>
+ * Copyright (C) 2017-2018 Mario Danic <mario@lovelyhq.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.talk.jobs
+
+import android.content.Context
+import android.util.Log
+import androidx.work.Data
+import androidx.work.OneTimeWorkRequest
+import androidx.work.WorkManager
+import androidx.work.Worker
+import androidx.work.WorkerParameters
+import autodagger.AutoInjector
+import com.nextcloud.talk.api.NcApi
+import com.nextcloud.talk.application.NextcloudTalkApplication
+import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
+import com.nextcloud.talk.data.user.model.User
+import com.nextcloud.talk.users.UserManager
+import com.nextcloud.talk.utils.ApiUtils
+import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_FILE_PATHS
+import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_INTERNAL_USER_ID
+import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_META_DATA
+import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN
+import io.reactivex.schedulers.Schedulers
+import javax.inject.Inject
+
+@AutoInjector(NextcloudTalkApplication::class)
+class ShareOperationWorker(context: Context, workerParams: WorkerParameters) : Worker(context, workerParams) {
+
+    @Inject
+    lateinit var userManager: UserManager
+
+    @Inject
+    lateinit var ncApi: NcApi
+
+    private val userId: Long
+    private val roomToken: String?
+    private val filesArray: MutableList<String?> = ArrayList()
+    private val credentials: String
+    private val baseUrl: String?
+    private val metaData: String?
+
+    override fun doWork(): Result {
+        for (filePath in filesArray) {
+            ncApi.createRemoteShare(
+                credentials,
+                ApiUtils.getSharingUrl(baseUrl),
+                filePath,
+                roomToken,
+                "10",
+                metaData
+            )
+                .subscribeOn(Schedulers.io())
+                .blockingSubscribe(
+                    {}, { e -> Log.w(TAG, "error while creating RemoteShare", e) }
+                )
+        }
+        return Result.success()
+    }
+
+    init {
+        sharedApplication!!.componentApplication.inject(this)
+        val data = workerParams.inputData
+        userId = data.getLong(KEY_INTERNAL_USER_ID, 0)
+        roomToken = data.getString(KEY_ROOM_TOKEN)
+        metaData = data.getString(KEY_META_DATA)
+        data.getStringArray(KEY_FILE_PATHS)?.let { filesArray.addAll(it.toList()) }
+
+        val operationsUser = userManager.getUserWithId(userId).blockingGet()
+        baseUrl = operationsUser.baseUrl
+        credentials = ApiUtils.getCredentials(operationsUser.username, operationsUser.token)
+    }
+
+    companion object {
+        private val TAG = ShareOperationWorker::class.simpleName
+
+        fun shareFile(
+            roomToken: String?,
+            currentUser: User,
+            remotePath: String,
+            metaData: String?
+        ) {
+            val paths: MutableList<String> = ArrayList()
+            paths.add(remotePath)
+
+            val data = Data.Builder()
+                .putLong(KEY_INTERNAL_USER_ID, currentUser.id!!)
+                .putString(KEY_ROOM_TOKEN, roomToken)
+                .putStringArray(KEY_FILE_PATHS, paths.toTypedArray())
+                .putString(KEY_META_DATA, metaData)
+                .build()
+            val shareWorker = OneTimeWorkRequest.Builder(ShareOperationWorker::class.java)
+                .setInputData(data)
+                .build()
+            WorkManager.getInstance().enqueue(shareWorker)
+        }
+    }
+}

+ 193 - 128
app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt

@@ -2,7 +2,7 @@
  * Nextcloud Talk application
  *
  * @author Marcel Hibbe
- * Copyright (C) 2021 Marcel Hibbe <dev@mhibbe.de>
+ * Copyright (C) 2021-2022 Marcel Hibbe <dev@mhibbe.de>
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU General Public License as published by
@@ -21,46 +21,50 @@
 package com.nextcloud.talk.jobs
 
 import android.Manifest
+import android.app.Notification
+import android.app.NotificationManager
+import android.app.PendingIntent
 import android.content.Context
+import android.content.Intent
 import android.net.Uri
 import android.os.Build
+import android.os.Bundle
+import android.os.SystemClock
 import android.util.Log
+import androidx.core.app.NotificationCompat
 import androidx.core.content.PermissionChecker
 import androidx.work.Data
+import androidx.work.ExistingWorkPolicy
 import androidx.work.OneTimeWorkRequest
 import androidx.work.WorkManager
 import androidx.work.Worker
 import androidx.work.WorkerParameters
 import autodagger.AutoInjector
 import com.bluelinelabs.conductor.Controller
+import com.nextcloud.talk.R
+import com.nextcloud.talk.activities.MainActivity
 import com.nextcloud.talk.api.NcApi
 import com.nextcloud.talk.application.NextcloudTalkApplication
 import com.nextcloud.talk.data.user.model.User
-import com.nextcloud.talk.models.json.generic.GenericOverall
+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.UriUtils
-import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_FILE_PATHS
-import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_INTERNAL_USER_ID
-import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_META_DATA
+import com.nextcloud.talk.utils.FileUtils
+import com.nextcloud.talk.utils.NotificationUtils
+import com.nextcloud.talk.utils.RemoteFileUtils
+import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_FROM_NOTIFICATION_START_CALL
 import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN
+import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_USER_ENTITY
+import com.nextcloud.talk.utils.database.user.CapabilitiesUtilNew
 import com.nextcloud.talk.utils.preferences.AppPreferences
-import io.reactivex.Observer
-import io.reactivex.android.schedulers.AndroidSchedulers
-import io.reactivex.disposables.Disposable
-import io.reactivex.schedulers.Schedulers
 import okhttp3.MediaType.Companion.toMediaTypeOrNull
-import okhttp3.RequestBody
-import retrofit2.Response
-import java.io.File
-import java.io.FileNotFoundException
-import java.io.FileOutputStream
-import java.io.InputStream
+import okhttp3.OkHttpClient
 import javax.inject.Inject
 
 @AutoInjector(NextcloudTalkApplication::class)
 class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerParameters) :
-    Worker(context, workerParameters) {
+    Worker(context, workerParameters), OnDataTransferProgressListener {
 
     @Inject
     lateinit var ncApi: NcApi
@@ -71,6 +75,21 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa
     @Inject
     lateinit var appPreferences: AppPreferences
 
+    @Inject
+    lateinit var okHttpClient: OkHttpClient
+
+    lateinit var fileName: String
+
+    private var mNotifyManager: NotificationManager? = null
+    private var mBuilder: NotificationCompat.Builder? = null
+    private lateinit var notification: Notification
+    private var notificationId: Int = 0
+
+    lateinit var roomToken: String
+    lateinit var conversationName: String
+    lateinit var currentUser: User
+
+    @Suppress("Detekt.TooGenericExceptionCaught")
     override fun doWork(): Result {
         NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this)
 
@@ -85,139 +104,173 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa
         }
 
         return try {
-            val currentUser = userManager.currentUser.blockingGet()
-            val sourcefiles = inputData.getStringArray(DEVICE_SOURCEFILES)
-            val ncTargetpath = inputData.getString(NC_TARGETPATH)
-            val roomToken = inputData.getString(ROOM_TOKEN)
+            currentUser = userManager.currentUser.blockingGet()
+            val sourceFile = inputData.getString(DEVICE_SOURCE_FILE)
+            roomToken = inputData.getString(ROOM_TOKEN)!!
+            conversationName = inputData.getString(CONVERSATION_NAME)!!
             val metaData = inputData.getString(META_DATA)
 
             checkNotNull(currentUser)
-            checkNotNull(sourcefiles)
-            require(sourcefiles.isNotEmpty())
-            checkNotNull(ncTargetpath)
+            checkNotNull(sourceFile)
+            require(sourceFile.isNotEmpty())
             checkNotNull(roomToken)
 
-            for (index in sourcefiles.indices) {
-                val sourceFileUri = Uri.parse(sourcefiles[index])
-                uploadFile(
-                    currentUser!!,
-                    UploadItem(
-                        sourceFileUri,
-                        UriUtils.getFileName(sourceFileUri, context),
-                        createRequestBody(sourceFileUri)
-                    ),
-                    ncTargetpath,
+            val sourceFileUri = Uri.parse(sourceFile)
+            fileName = FileUtils.getFileName(sourceFileUri, context)
+            val file = FileUtils.getFileFromUri(context, sourceFileUri)
+            val remotePath = getRemotePath(currentUser)
+            val uploadSuccess: Boolean
+
+            if (file != null && file.length() > CHUNK_UPLOAD_THRESHOLD_SIZE) {
+                Log.d(TAG, "starting chunked upload because size is " + file.length())
+
+                initNotification()
+                val mimeType = context.contentResolver.getType(sourceFileUri)?.toMediaTypeOrNull()
+
+                uploadSuccess = ChunkedFileUploader(
+                    okHttpClient!!,
+                    currentUser,
                     roomToken,
-                    metaData
+                    metaData,
+                    this
+                ).upload(
+                    file,
+                    mimeType,
+                    remotePath
                 )
+            } else {
+                Log.d(TAG, "starting normal upload (not chunked)")
+
+                uploadSuccess = FileUploader(
+                    context,
+                    currentUser,
+                    roomToken,
+                    ncApi
+                ).upload(
+                    sourceFileUri,
+                    fileName,
+                    remotePath,
+                    metaData
+                ).blockingFirst()
             }
-            Result.success()
-        } catch (e: IllegalStateException) {
-            Log.e(javaClass.simpleName, "Something went wrong when trying to upload file", e)
-            Result.failure()
-        } catch (e: IllegalArgumentException) {
-            Log.e(javaClass.simpleName, "Something went wrong when trying to upload file", e)
-            Result.failure()
-        }
-    }
 
-    @Suppress("Detekt.TooGenericExceptionCaught")
-    private fun createRequestBody(sourcefileUri: Uri): RequestBody? {
-        var requestBody: RequestBody? = null
-        try {
-            val input: InputStream = context.contentResolver.openInputStream(sourcefileUri)!!
-            val buf = ByteArray(input.available())
-            while (input.read(buf) != -1)
-                requestBody = RequestBody.create("application/octet-stream".toMediaTypeOrNull(), buf)
+            if (uploadSuccess) {
+                mNotifyManager?.cancel(notificationId)
+                return Result.success()
+            }
+
+            Log.e(TAG, "Something went wrong when trying to upload file")
+            showFailedToUploadNotification()
+            return Result.failure()
         } catch (e: Exception) {
-            Log.e(javaClass.simpleName, "failed to create RequestBody for $sourcefileUri", e)
+            Log.e(TAG, "Something went wrong when trying to upload file", e)
+            showFailedToUploadNotification()
+            return Result.failure()
         }
-        return requestBody
     }
 
-    private fun uploadFile(
-        currentUser: User,
-        uploadItem: UploadItem,
-        ncTargetPath: String?,
-        roomToken: String?,
-        metaData: String?
-    ) {
-        ncApi.uploadFile(
-            ApiUtils.getCredentials(currentUser.username, currentUser.token),
-            ApiUtils.getUrlForFileUpload(currentUser.baseUrl, currentUser.userId, ncTargetPath, uploadItem.fileName),
-            uploadItem.requestBody
+    private fun getRemotePath(currentUser: User): String {
+        var remotePath = CapabilitiesUtilNew.getAttachmentFolder(currentUser)!! + "/" + fileName
+        remotePath = RemoteFileUtils.getNewPathIfFileExists(
+            ncApi,
+            currentUser,
+            remotePath
         )
-            .subscribeOn(Schedulers.io())
-            .observeOn(AndroidSchedulers.mainThread())
-            .subscribe(object : Observer<Response<GenericOverall>> {
-                override fun onSubscribe(d: Disposable) {
-                    // unused atm
-                }
+        return remotePath
+    }
 
-                override fun onNext(t: Response<GenericOverall>) {
-                    // unused atm
-                }
+    override fun onTransferProgress(
+        percentage: Int
+    ) {
+        notification = mBuilder!!
+            .setProgress(HUNDRED_PERCENT, percentage, false)
+            .setContentText(getNotificationContentText(percentage))
+            .build()
 
-                override fun onError(e: Throwable) {
-                    Log.e(TAG, "failed to upload file ${uploadItem.fileName}")
-                }
+        mNotifyManager!!.notify(notificationId, notification)
+    }
 
-                override fun onComplete() {
-                    shareFile(roomToken, currentUser, ncTargetPath, uploadItem.fileName, metaData)
-                    copyFileToCache(uploadItem.uri, uploadItem.fileName)
-                }
-            })
+    private fun initNotification() {
+        mNotifyManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+        mBuilder = NotificationCompat.Builder(context, NotificationUtils.NOTIFICATION_CHANNEL_UPLOADS)
+
+        notification = mBuilder!!
+            .setContentTitle(context.resources.getString(R.string.nc_upload_in_progess))
+            .setContentText(getNotificationContentText(ZERO_PERCENT))
+            .setSmallIcon(R.drawable.upload_white)
+            .setOngoing(true)
+            .setProgress(HUNDRED_PERCENT, ZERO_PERCENT, false)
+            .setPriority(NotificationCompat.PRIORITY_LOW)
+            .setContentIntent(getIntentToOpenConversation())
+            .build()
+
+        notificationId = SystemClock.uptimeMillis().toInt()
+        mNotifyManager!!.notify(notificationId, notification)
     }
 
-    private fun copyFileToCache(sourceFileUri: Uri, filename: String) {
-        val cachedFile = File(context.cacheDir, filename)
+    private fun getNotificationContentText(percentage: Int): String {
+        return String.format(
+            context.resources.getString(R.string.nc_upload_notification_text),
+            getShortenedFileName(),
+            conversationName,
+            percentage
+        )
+    }
 
-        if (cachedFile.exists()) {
-            Log.d(TAG, "file is already in cache")
+    private fun getShortenedFileName(): String {
+        return if (fileName.length > NOTIFICATION_FILE_NAME_MAX_LENGTH) {
+            THREE_DOTS + fileName.takeLast(NOTIFICATION_FILE_NAME_MAX_LENGTH)
         } else {
-            val outputStream = FileOutputStream(cachedFile)
-            try {
-                val inputStream: InputStream? = context.contentResolver.openInputStream(sourceFileUri)
-                inputStream?.use { input ->
-                    outputStream.use { output ->
-                        input.copyTo(output)
-                    }
-                }
-            } catch (e: FileNotFoundException) {
-                Log.w(TAG, "failed to copy file to cache", e)
-            }
+            fileName
         }
     }
 
-    private fun shareFile(
-        roomToken: String?,
-        currentUser: User,
-        ncTargetpath: String?,
-        filename: String?,
-        metaData: String?
-    ) {
-        val paths: MutableList<String> = ArrayList()
-        paths.add("$ncTargetpath/$filename")
-
-        val data = Data.Builder()
-            .putLong(KEY_INTERNAL_USER_ID, currentUser.id!!)
-            .putString(KEY_ROOM_TOKEN, roomToken)
-            .putStringArray(KEY_FILE_PATHS, paths.toTypedArray())
-            .putString(KEY_META_DATA, metaData)
-            .build()
-        val shareWorker = OneTimeWorkRequest.Builder(ShareOperationWorker::class.java)
-            .setInputData(data)
+    private fun getIntentToOpenConversation(): PendingIntent? {
+        val bundle = Bundle()
+        val intent = Intent(context, MainActivity::class.java)
+        intent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
+
+        bundle.putString(KEY_ROOM_TOKEN, roomToken)
+        bundle.putParcelable(KEY_USER_ENTITY, currentUser)
+        bundle.putBoolean(KEY_FROM_NOTIFICATION_START_CALL, false)
+
+        intent.putExtras(bundle)
+
+        val requestCode = System.currentTimeMillis().toInt()
+        val intentFlag: Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+            PendingIntent.FLAG_MUTABLE
+        } else {
+            0
+        }
+        return PendingIntent.getActivity(context, requestCode, intent, intentFlag)
+    }
+
+    private fun showFailedToUploadNotification() {
+        val failureTitle = context.resources.getString(R.string.nc_upload_failed_notification_title)
+        val failureText = String.format(
+            context.resources.getString(R.string.nc_upload_failed_notification_text),
+            fileName
+        )
+        notification = mBuilder!!
+            .setContentTitle(failureTitle)
+            .setContentText(failureText)
             .build()
-        WorkManager.getInstance().enqueue(shareWorker)
+
+        mNotifyManager!!.notify(notificationId, notification)
     }
 
     companion object {
-        const val TAG = "UploadFileWorker"
+        private val TAG = UploadAndShareFilesWorker::class.simpleName
+        private const val DEVICE_SOURCE_FILE = "DEVICE_SOURCE_FILE"
+        private const val ROOM_TOKEN = "ROOM_TOKEN"
+        private const val CONVERSATION_NAME = "CONVERSATION_NAME"
+        private const val META_DATA = "META_DATA"
+        private const val CHUNK_UPLOAD_THRESHOLD_SIZE: Long = 1024000
+        private const val NOTIFICATION_FILE_NAME_MAX_LENGTH = 20
+        private const val THREE_DOTS = "…"
+        private const val HUNDRED_PERCENT = 100
+        private const val ZERO_PERCENT = 0
         const val REQUEST_PERMISSION = 3123
-        const val DEVICE_SOURCEFILES = "DEVICE_SOURCEFILES"
-        const val NC_TARGETPATH = "NC_TARGETPATH"
-        const val ROOM_TOKEN = "ROOM_TOKEN"
-        const val META_DATA = "META_DATA"
 
         fun isStoragePermissionGranted(context: Context): Boolean {
             return when {
@@ -276,11 +329,23 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa
                 }
             }
         }
-    }
 
-    private data class UploadItem(
-        val uri: Uri,
-        val fileName: String,
-        val requestBody: RequestBody?
-    )
+        fun upload(
+            fileUri: String,
+            roomToken: String,
+            conversationName: String,
+            metaData: String?
+        ) {
+            val data: Data = Data.Builder()
+                .putString(DEVICE_SOURCE_FILE, fileUri)
+                .putString(ROOM_TOKEN, roomToken)
+                .putString(CONVERSATION_NAME, conversationName)
+                .putString(META_DATA, metaData)
+                .build()
+            val uploadWorker: OneTimeWorkRequest = OneTimeWorkRequest.Builder(UploadAndShareFilesWorker::class.java)
+                .setInputData(data)
+                .build()
+            WorkManager.getInstance().enqueueUniqueWork(fileUri, ExistingWorkPolicy.KEEP, uploadWorker)
+        }
+    }
 }

+ 28 - 0
app/src/main/java/com/nextcloud/talk/upload/chunked/Chunk.kt

@@ -0,0 +1,28 @@
+/*
+ * Nextcloud Talk application
+ *
+ * @author Tobias Kaminsky
+ * @author Marcel Hibbe
+ * Copyright (C) 2022 Marcel Hibbe <dev@mhibbe.de>
+ * Copyright (C) 2019 Tobias Kaminsky
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.talk.upload.chunked
+
+data class Chunk(var start: Long, var end: Long) {
+    fun length(): Long {
+        return end - start + 1
+    }
+}

+ 124 - 0
app/src/main/java/com/nextcloud/talk/upload/chunked/ChunkFromFileRequestBody.kt

@@ -0,0 +1,124 @@
+/*
+ *   Nextcloud Talk application
+ *
+ *   Copyright (C) 2020 ownCloud GmbH.
+ *   Copyright (C) 2022 Nextcloud GmbH
+ *
+ *   Permission is hereby granted, free of charge, to any person obtaining a copy
+ *   of this software and associated documentation files (the "Software"), to deal
+ *   in the Software without restriction, including without limitation the rights
+ *   to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ *   copies of the Software, and to permit persons to whom the Software is
+ *   furnished to do so, subject to the following conditions:
+ *
+ *   The above copyright notice and this permission notice shall be included in
+ *   all copies or substantial portions of the Software.
+ *
+ *   THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ *   EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+ *   MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ *   NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+ *   BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+ *   ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+ *   CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ *   THE SOFTWARE.
+ *
+ */
+package com.nextcloud.talk.upload.chunked
+
+import okhttp3.MediaType
+import okhttp3.RequestBody
+import okio.BufferedSink
+import java.io.File
+import java.io.IOException
+import java.nio.ByteBuffer
+import java.nio.channels.FileChannel
+
+/**
+ * A Request body that represents a file chunk and include information about the progress when uploading it
+ *
+ * @author David González Verdugo
+ */
+class ChunkFromFileRequestBody(
+    file: File,
+    contentType: MediaType?,
+    channel: FileChannel?,
+    chunkSize: Long,
+    offset: Long,
+    listener: OnDataTransferProgressListener
+) : RequestBody() {
+    private val mFile: File
+    private val mContentType: MediaType?
+    private val mChannel: FileChannel
+    private val mChunkSize: Long
+    private val mOffset: Long
+    private var mTransferred: Long
+    private var mDataTransferListener: OnDataTransferProgressListener
+    private val mBuffer = ByteBuffer.allocate(BUFFER_CAPACITY)
+    override fun contentLength(): Long {
+        return try {
+            mChunkSize.coerceAtMost(mChannel.size() - mOffset)
+        } catch (e: IOException) {
+            mChunkSize
+        }
+    }
+
+    @Throws(IOException::class)
+    override fun writeTo(sink: BufferedSink) {
+        var readCount: Int
+        try {
+            mChannel.position(mOffset)
+            var size = mFile.length()
+            if (size == 0L) {
+                size = -1
+            }
+            val maxCount = (mOffset + mChunkSize - 1).coerceAtMost(mChannel.size())
+            var percentageOld = 0
+            while (mChannel.position() < maxCount) {
+                readCount = mChannel.read(mBuffer)
+                sink.buffer.write(mBuffer.array(), 0, readCount)
+                mBuffer.clear()
+                if (mTransferred < maxCount) { // condition to avoid accumulate progress for repeated chunks
+                    mTransferred += readCount.toLong()
+                }
+
+                val percentage =
+                    if (size > ZERO_PERCENT) (mTransferred * HUNDRED_PERCENT / size).toInt() else ZERO_PERCENT
+                if (percentage > percentageOld) {
+                    percentageOld = percentage
+                    mDataTransferListener.onTransferProgress(
+                        percentage
+                    )
+                }
+            }
+        } catch (io: IOException) {
+            // any read problem will be handled as if the file is not there
+            val fnf = java.io.FileNotFoundException("Exception reading source file")
+            fnf.initCause(io)
+            throw fnf
+        }
+    }
+
+    override fun contentType(): MediaType? {
+        return mContentType
+    }
+
+    companion object {
+        private val TAG = ChunkFromFileRequestBody::class.java.simpleName
+        private const val BUFFER_CAPACITY = 4096
+        private const val HUNDRED_PERCENT = 100
+        private const val ZERO_PERCENT = 0
+    }
+
+    init {
+        requireNotNull(channel) { "File may not be null" }
+        require(chunkSize > 0) { "Chunk size must be greater than zero" }
+        mFile = file
+        mChannel = channel
+        mChunkSize = chunkSize
+        mOffset = offset
+        mTransferred = offset
+        mDataTransferListener = listener
+        mContentType = contentType
+    }
+}

+ 396 - 0
app/src/main/java/com/nextcloud/talk/upload/chunked/ChunkedFileUploader.kt

@@ -0,0 +1,396 @@
+/*
+ *   Nextcloud Talk application
+ *
+ *   Copyright (C) 2015 ownCloud Inc.
+ *   Copyright (C) 2022 Nextcloud GmbH
+ *
+ *   Permission is hereby granted, free of charge, to any person obtaining a copy
+ *   of this software and associated documentation files (the "Software"), to deal
+ *   in the Software without restriction, including without limitation the rights
+ *   to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ *   copies of the Software, and to permit persons to whom the Software is
+ *   furnished to do so, subject to the following conditions:
+ *
+ *   The above copyright notice and this permission notice shall be included in
+ *   all copies or substantial portions of the Software.
+ *
+ *   THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ *   EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+ *   MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ *   NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+ *   BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+ *   ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+ *   CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ *   THE SOFTWARE.
+ *
+ */
+
+package com.nextcloud.talk.upload.chunked
+
+import android.net.Uri
+import android.text.TextUtils
+import android.util.Log
+import at.bitfire.dav4jvm.DavResource
+import at.bitfire.dav4jvm.Property
+import at.bitfire.dav4jvm.exception.DavException
+import at.bitfire.dav4jvm.exception.HttpException
+import at.bitfire.dav4jvm.property.DisplayName
+import at.bitfire.dav4jvm.property.GetContentType
+import at.bitfire.dav4jvm.property.GetLastModified
+import at.bitfire.dav4jvm.property.ResourceType
+import autodagger.AutoInjector
+import com.nextcloud.talk.application.NextcloudTalkApplication
+import com.nextcloud.talk.components.filebrowser.models.DavResponse
+import com.nextcloud.talk.components.filebrowser.models.properties.NCEncrypted
+import com.nextcloud.talk.components.filebrowser.models.properties.NCPermission
+import com.nextcloud.talk.components.filebrowser.models.properties.NCPreview
+import com.nextcloud.talk.components.filebrowser.models.properties.OCFavorite
+import com.nextcloud.talk.components.filebrowser.models.properties.OCId
+import com.nextcloud.talk.components.filebrowser.models.properties.OCSize
+import com.nextcloud.talk.dagger.modules.RestModule
+import com.nextcloud.talk.data.user.model.User
+import com.nextcloud.talk.jobs.ShareOperationWorker
+import com.nextcloud.talk.remotefilebrowser.model.RemoteFileBrowserItem
+import com.nextcloud.talk.utils.ApiUtils
+import com.nextcloud.talk.utils.FileUtils
+import com.nextcloud.talk.utils.Mimetype
+import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
+import okhttp3.MediaType
+import okhttp3.OkHttpClient
+import okhttp3.Response
+import java.io.File
+import java.io.IOException
+import java.io.RandomAccessFile
+import java.nio.channels.FileChannel
+import java.util.Locale
+
+@AutoInjector(NextcloudTalkApplication::class)
+class ChunkedFileUploader(
+    okHttpClient: OkHttpClient,
+    val currentUser: User,
+    val roomToken: String,
+    val metaData: String?,
+    val listener: OnDataTransferProgressListener
+) {
+
+    private var okHttpClientNoRedirects: OkHttpClient? = null
+    private var remoteChunkUrl: String
+
+    init {
+        initHttpClient(okHttpClient, currentUser)
+        remoteChunkUrl = ApiUtils.getUrlForChunkedUpload(currentUser.baseUrl, currentUser.userId)
+    }
+
+    @Suppress("Detekt.TooGenericExceptionCaught")
+    fun upload(
+        localFile: File,
+        mimeType: MediaType?,
+        targetPath: String
+    ): Boolean {
+        try {
+            val uploadFolderUri: String = remoteChunkUrl + "/" + FileUtils.md5Sum(localFile)
+            val davResource = DavResource(
+                okHttpClientNoRedirects!!,
+                uploadFolderUri.toHttpUrlOrNull()!!
+            )
+
+            createFolder(davResource)
+
+            val chunksOnServer: MutableList<Chunk> = getUploadedChunks(davResource, uploadFolderUri)
+            Log.d(TAG, "chunksOnServer: " + chunksOnServer.size)
+
+            val missingChunks: List<Chunk> = checkMissingChunks(chunksOnServer, localFile.length())
+            Log.d(TAG, "missingChunks: " + missingChunks.size)
+
+            for (missingChunk in missingChunks) {
+                uploadChunk(localFile, uploadFolderUri, mimeType, missingChunk, missingChunk.length())
+            }
+
+            assembleChunks(uploadFolderUri, targetPath)
+            return true
+        } catch (e: Exception) {
+            Log.e(TAG, "Something went wrong in ChunkedFileUploader", e)
+            return false
+        }
+    }
+
+    @Suppress("Detekt.ThrowsCount")
+    private fun createFolder(davResource: DavResource) {
+        try {
+            davResource.mkCol(
+                xmlBody = null,
+            ) { response: Response ->
+                if (!response.isSuccessful) {
+                    throw IOException("failed to create folder. response code: " + response.code)
+                }
+            }
+        } catch (e: IOException) {
+            throw IOException("failed to create folder", e)
+        } catch (e: HttpException) {
+            if (e.code == METHOD_NOT_ALLOWED_CODE) {
+                Log.d(TAG, "Folder most probably already exists, that's okay, just continue..")
+            } else {
+                throw IOException("failed to create folder", e)
+            }
+        }
+    }
+
+    @Suppress("Detekt.ComplexMethod")
+    private fun getUploadedChunks(
+        davResource: DavResource,
+        uploadFolderUri: String
+    ): MutableList<Chunk> {
+        val davResponse = DavResponse()
+        val memberElements: MutableList<at.bitfire.dav4jvm.Response> = ArrayList()
+        val rootElement = arrayOfNulls<at.bitfire.dav4jvm.Response>(1)
+        val remoteFiles: MutableList<RemoteFileBrowserItem> = ArrayList()
+        try {
+            davResource.propfind(
+                1
+            ) { response: at.bitfire.dav4jvm.Response, hrefRelation: at.bitfire.dav4jvm.Response.HrefRelation? ->
+                davResponse.setResponse(response)
+                when (hrefRelation) {
+                    at.bitfire.dav4jvm.Response.HrefRelation.MEMBER -> memberElements.add(response)
+                    at.bitfire.dav4jvm.Response.HrefRelation.SELF -> rootElement[0] = response
+                    at.bitfire.dav4jvm.Response.HrefRelation.OTHER -> {}
+                    else -> {}
+                }
+                Unit
+            }
+        } catch (e: IOException) {
+            throw IOException("Error reading remote path", e)
+        } catch (e: DavException) {
+            throw IOException("Error reading remote path", e)
+        }
+        for (memberElement in memberElements) {
+            remoteFiles.add(
+                getModelFromResponse(
+                    memberElement,
+                    memberElement
+                        .href
+                        .toString()
+                        .substring(uploadFolderUri.length)
+                )
+            )
+        }
+
+        val chunksOnServer: MutableList<Chunk> = ArrayList()
+
+        for (remoteFile in remoteFiles) {
+            if (!".file".equals(remoteFile.displayName, ignoreCase = true) && remoteFile.isFile) {
+                val part: List<String> = remoteFile.displayName!!.split("-")
+                chunksOnServer.add(
+                    Chunk(
+                        part[0].toLong(),
+                        part[1].toLong()
+                    )
+                )
+            }
+        }
+        return chunksOnServer
+    }
+
+    private fun checkMissingChunks(chunks: List<Chunk>, length: Long): List<Chunk> {
+        val missingChunks: MutableList<Chunk> = java.util.ArrayList()
+        var start: Long = 0
+        while (start <= length) {
+            val nextChunk: Chunk? = findNextFittingChunk(chunks, start)
+            if (nextChunk == null) {
+                // create new chunk
+                val end: Long = if (start + CHUNK_SIZE <= length) {
+                    start + CHUNK_SIZE - 1
+                } else {
+                    length
+                }
+                missingChunks.add(Chunk(start, end))
+                start = end + 1
+            } else if (nextChunk.start == start) {
+                // go to next
+                start += nextChunk.length()
+            } else {
+                // fill the gap
+                missingChunks.add(Chunk(start, nextChunk.start - 1))
+                start = nextChunk.start
+            }
+        }
+        return missingChunks
+    }
+
+    private fun findNextFittingChunk(chunks: List<Chunk>, start: Long): Chunk? {
+        for (chunk in chunks) {
+            if (chunk.start >= start && chunk.start - start <= CHUNK_SIZE) {
+                return chunk
+            }
+        }
+        return null
+    }
+
+    private fun uploadChunk(
+        localFile: File,
+        uploadFolderUri: String,
+        mimeType: MediaType?,
+        chunk: Chunk,
+        chunkSize: Long
+    ) {
+        val startString = java.lang.String.format(Locale.ROOT, "%016d", chunk.start)
+        val endString = java.lang.String.format(Locale.ROOT, "%016d", chunk.end)
+
+        var raf: RandomAccessFile? = null
+        var channel: FileChannel? = null
+        try {
+            raf = RandomAccessFile(localFile, "r")
+            channel = raf.channel
+
+            // Log.d(TAG, "chunkSize:$chunkSize")
+            // Log.d(TAG, "chunk.length():${chunk.length()}")
+            // Log.d(TAG, "chunk.start:${chunk.start}")
+            // Log.d(TAG, "chunk.end:${chunk.end}")
+
+            val chunkFromFileRequestBody = ChunkFromFileRequestBody(
+                localFile,
+                mimeType,
+                channel,
+                chunkSize,
+                chunk.start,
+                listener
+            )
+
+            val chunkUri = "$uploadFolderUri/$startString-$endString"
+
+            val davResource = DavResource(
+                okHttpClientNoRedirects!!,
+                chunkUri.toHttpUrlOrNull()!!
+            )
+            davResource.put(
+                chunkFromFileRequestBody
+            ) { response: Response ->
+                if (!response.isSuccessful) {
+                    throw IOException("Failed to upload chunk. response code: " + response.code)
+                }
+            }
+        } finally {
+            if (channel != null) try {
+                channel.close()
+            } catch (e: IOException) {
+                Log.e(TAG, "Error closing file channel!", e)
+            }
+            if (raf != null) {
+                try {
+                    raf.close()
+                } catch (e: IOException) {
+                    Log.e(TAG, "Error closing file access!", e)
+                }
+            }
+        }
+    }
+
+    // @RequiresApi(Build.VERSION_CODES.O)
+    private fun initHttpClient(okHttpClient: OkHttpClient, currentUser: User) {
+        val okHttpClientBuilder: OkHttpClient.Builder = okHttpClient.newBuilder()
+        okHttpClientBuilder.followRedirects(false)
+        okHttpClientBuilder.followSslRedirects(false)
+        // okHttpClientBuilder.readTimeout(Duration.ofMinutes(30)) // TODO set timeout
+        okHttpClientBuilder.authenticator(
+            RestModule.MagicAuthenticator(
+                ApiUtils.getCredentials(
+                    currentUser.username,
+                    currentUser.token
+                ),
+                "Authorization"
+            )
+        )
+        this.okHttpClientNoRedirects = okHttpClientBuilder.build()
+    }
+
+    private fun assembleChunks(uploadFolderUri: String, targetPath: String) {
+
+        val destinationUri: String = ApiUtils.getUrlForFileUpload(
+            currentUser.baseUrl,
+            currentUser.userId,
+            targetPath
+        )
+        val originUri = "$uploadFolderUri/.file"
+
+        DavResource(
+            okHttpClientNoRedirects!!,
+            originUri.toHttpUrlOrNull()!!
+        ).move(
+            destinationUri.toHttpUrlOrNull()!!,
+            true
+        ) { response: Response ->
+            if (response.isSuccessful) {
+                ShareOperationWorker.shareFile(
+                    roomToken,
+                    currentUser,
+                    targetPath,
+                    metaData
+                )
+            } else {
+                throw IOException("Failed to assemble chunks. response code: " + response.code)
+            }
+        }
+    }
+
+    private fun getModelFromResponse(response: at.bitfire.dav4jvm.Response, remotePath: String): RemoteFileBrowserItem {
+        val remoteFileBrowserItem = RemoteFileBrowserItem()
+        remoteFileBrowserItem.path = Uri.decode(remotePath)
+        remoteFileBrowserItem.displayName = Uri.decode(File(remotePath).name)
+        val properties = response.properties
+        for (property in properties) {
+            mapPropertyToBrowserFile(property, remoteFileBrowserItem)
+        }
+        if (remoteFileBrowserItem.permissions != null &&
+            remoteFileBrowserItem.permissions!!.contains(READ_PERMISSION)
+        ) {
+            remoteFileBrowserItem.isAllowedToReShare = true
+        }
+        if (TextUtils.isEmpty(remoteFileBrowserItem.mimeType) && !remoteFileBrowserItem.isFile) {
+            remoteFileBrowserItem.mimeType = Mimetype.FOLDER
+        }
+
+        return remoteFileBrowserItem
+    }
+
+    @Suppress("Detekt.ComplexMethod")
+    private fun mapPropertyToBrowserFile(property: Property, remoteFileBrowserItem: RemoteFileBrowserItem) {
+        when (property) {
+            is OCId -> {
+                remoteFileBrowserItem.remoteId = property.ocId
+            }
+            is ResourceType -> {
+                remoteFileBrowserItem.isFile = !property.types.contains(ResourceType.COLLECTION)
+            }
+            is GetLastModified -> {
+                remoteFileBrowserItem.modifiedTimestamp = property.lastModified
+            }
+            is GetContentType -> {
+                remoteFileBrowserItem.mimeType = property.type
+            }
+            is OCSize -> {
+                remoteFileBrowserItem.size = property.ocSize
+            }
+            is NCPreview -> {
+                remoteFileBrowserItem.hasPreview = property.isNcPreview
+            }
+            is OCFavorite -> {
+                remoteFileBrowserItem.isFavorite = property.isOcFavorite
+            }
+            is DisplayName -> {
+                remoteFileBrowserItem.displayName = property.displayName
+            }
+            is NCEncrypted -> {
+                remoteFileBrowserItem.isEncrypted = property.isNcEncrypted
+            }
+            is NCPermission -> {
+                remoteFileBrowserItem.permissions = property.ncPermission
+            }
+        }
+    }
+
+    companion object {
+        private val TAG = ChunkedFileUploader::class.simpleName
+        private const val READ_PERMISSION = "R"
+        private const val CHUNK_SIZE: Long = 1024000
+        private const val METHOD_NOT_ALLOWED_CODE: Int = 405
+    }
+}

+ 31 - 0
app/src/main/java/com/nextcloud/talk/upload/chunked/OnDataTransferProgressListener.kt

@@ -0,0 +1,31 @@
+/* ownCloud Android Library is available under MIT license
+ *   Copyright (C) 2015 ownCloud Inc.
+ *   Copyright (C) 2012  Bartek Przybylski
+ *
+ *   Permission is hereby granted, free of charge, to any person obtaining a copy
+ *   of this software and associated documentation files (the "Software"), to deal
+ *   in the Software without restriction, including without limitation the rights
+ *   to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ *   copies of the Software, and to permit persons to whom the Software is
+ *   furnished to do so, subject to the following conditions:
+ *
+ *   The above copyright notice and this permission notice shall be included in
+ *   all copies or substantial portions of the Software.
+ *
+ *   THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ *   EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+ *   MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ *   NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+ *   BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+ *   ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+ *   CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ *   THE SOFTWARE.
+ *
+ */
+package com.nextcloud.talk.upload.chunked
+
+interface OnDataTransferProgressListener {
+    fun onTransferProgress(
+        percentage: Int
+    )
+}

+ 69 - 0
app/src/main/java/com/nextcloud/talk/upload/normal/FileUploader.kt

@@ -0,0 +1,69 @@
+package com.nextcloud.talk.upload.normal
+
+import android.content.Context
+import android.net.Uri
+import android.util.Log
+import com.nextcloud.talk.api.NcApi
+import com.nextcloud.talk.data.user.model.User
+import com.nextcloud.talk.jobs.ShareOperationWorker
+import com.nextcloud.talk.utils.ApiUtils
+import com.nextcloud.talk.utils.FileUtils
+import io.reactivex.Observable
+import io.reactivex.android.schedulers.AndroidSchedulers
+import io.reactivex.schedulers.Schedulers
+import okhttp3.MediaType.Companion.toMediaTypeOrNull
+import okhttp3.RequestBody
+import java.io.InputStream
+
+class FileUploader(
+    val context: Context,
+    val currentUser: User,
+    val roomToken: String,
+    val ncApi: NcApi
+) {
+    fun upload(
+        sourceFileUri: Uri,
+        fileName: String,
+        remotePath: String,
+        metaData: String?
+    ): Observable<Boolean> {
+        return ncApi.uploadFile(
+            ApiUtils.getCredentials(currentUser.username, currentUser.token),
+            ApiUtils.getUrlForFileUpload(currentUser.baseUrl, currentUser.userId, remotePath),
+            createRequestBody(sourceFileUri)
+        )
+            .subscribeOn(Schedulers.io())
+            .observeOn(AndroidSchedulers.mainThread()).map { response ->
+                if (response.isSuccessful) {
+                    ShareOperationWorker.shareFile(
+                        roomToken,
+                        currentUser,
+                        remotePath,
+                        metaData
+                    )
+                    FileUtils.copyFileToCache(context, sourceFileUri, fileName)
+                    true
+                } else {
+                    false
+                }
+            }
+    }
+
+    @Suppress("Detekt.TooGenericExceptionCaught")
+    private fun createRequestBody(sourceFileUri: Uri): RequestBody? {
+        var requestBody: RequestBody? = null
+        try {
+            val input: InputStream = context.contentResolver.openInputStream(sourceFileUri)!!
+            val buf = ByteArray(input.available())
+            while (input.read(buf) != -1)
+                requestBody = RequestBody.create("application/octet-stream".toMediaTypeOrNull(), buf)
+        } catch (e: Exception) {
+            Log.e(TAG, "failed to create RequestBody for $sourceFileUri", e)
+        }
+        return requestBody
+    }
+
+    companion object {
+        private val TAG = FileUploader::class.simpleName
+    }
+}

+ 6 - 2
app/src/main/java/com/nextcloud/talk/utils/ApiUtils.java

@@ -400,8 +400,12 @@ public class ApiUtils {
         return baseUrl + ocsApiVersion + "/cloud/users/search/by-phone";
     }
 
-    public static String getUrlForFileUpload(String baseUrl, String user, String attachmentFolder, String filename) {
-        return baseUrl + "/remote.php/dav/files/" + user + attachmentFolder + "/" + filename;
+    public static String getUrlForFileUpload(String baseUrl, String user, String remotePath) {
+        return baseUrl + "/remote.php/dav/files/" + user + remotePath;
+    }
+
+    public static String getUrlForChunkedUpload(String baseUrl, String user) {
+        return baseUrl + "/remote.php/dav/uploads/" + user;
     }
 
     public static String getUrlForFileDownload(String baseUrl, String user, String remotePath) {

+ 0 - 85
app/src/main/java/com/nextcloud/talk/utils/FileUtils.java

@@ -1,85 +0,0 @@
-/*
- * Nextcloud Talk application
- *
- * @author Andy Scherzinger
- * @author Stefan Niedermann
- * Copyright (C) 2021 Andy Scherzinger <info@andy-scherzinger.de>
- * Copyright (C) 2021 Stefan Niedermann <info@niedermann.it>
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-package com.nextcloud.talk.utils;
-
-import android.content.Context;
-import android.util.Log;
-
-import java.io.File;
-import java.io.FileNotFoundException;
-import java.io.IOException;
-
-import androidx.annotation.NonNull;
-
-public class FileUtils {
-    private static final String TAG = FileUtils.class.getSimpleName();
-
-    /**
-     * Creates a new {@link File}
-     */
-    public static File getTempCacheFile(@NonNull Context context, String fileName) throws IOException {
-        File cacheFile = new File(context.getApplicationContext().getFilesDir().getAbsolutePath() + "/" + fileName);
-
-        Log.v(TAG, "Full path for new cache file:" + cacheFile.getAbsolutePath());
-
-        final File tempDir = cacheFile.getParentFile();
-        if (tempDir == null) {
-            throw new FileNotFoundException("could not cacheFile.getParentFile()");
-        }
-        if (!tempDir.exists()) {
-            Log.v(TAG,
-                  "The folder in which the new file should be created does not exist yet. Trying to create it…");
-            if (tempDir.mkdirs()) {
-                Log.v(TAG, "Creation successful");
-            } else {
-                throw new IOException("Directory for temporary file does not exist and could not be created.");
-            }
-        }
-
-        Log.v(TAG, "- Try to create actual cache file");
-        if (cacheFile.createNewFile()) {
-            Log.v(TAG, "Successfully created cache file");
-        } else {
-            throw new IOException("Failed to create cacheFile");
-        }
-
-        return cacheFile;
-    }
-
-    /**
-     * Creates a new {@link File}
-     */
-    public static void removeTempCacheFile(@NonNull Context context, String fileName) throws IOException {
-        File cacheFile = new File(context.getApplicationContext().getFilesDir().getAbsolutePath() + "/" + fileName);
-
-        Log.v(TAG, "Full path for new cache file:" + cacheFile.getAbsolutePath());
-
-        if (cacheFile.exists()) {
-            if(cacheFile.delete()) {
-                Log.v(TAG, "Deletion successful");
-            } else {
-                throw new IOException("Directory for temporary file does not exist and could not be created.");
-            }
-        }
-    }
-}

+ 172 - 0
app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt

@@ -0,0 +1,172 @@
+package com.nextcloud.talk.utils
+
+/*
+ * Nextcloud Talk application
+ * @author Marcel Hibbe
+ * @author Andy Scherzinger
+ * @author Stefan Niedermann
+ * @author David A. Velasco
+ * @author Chris Narkiewicz
+ * Copyright (C) 2021 Andy Scherzinger <info@andy-scherzinger.de>
+ * Copyright (C) 2021 Stefan Niedermann <info@niedermann.it>
+ * Copyright (C) 2021 Marcel Hibbe <dev@mhibbe.de>
+ * Copyright (C) 2016 ownCloud GmbH.
+ * Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+import android.content.ContentResolver
+import android.content.Context
+import android.database.Cursor
+import android.net.Uri
+import android.provider.OpenableColumns
+import android.util.Log
+import java.io.File
+import java.io.FileNotFoundException
+import java.io.FileOutputStream
+import java.io.IOException
+import java.io.InputStream
+import java.math.BigInteger
+import java.security.MessageDigest
+
+object FileUtils {
+    private val TAG = FileUtils::class.java.simpleName
+    private const val RADIX: Int = 16
+    private const val MD5_LENGTH: Int = 32
+
+    /**
+     * Creates a new [File]
+     */
+    @Suppress("ThrowsCount")
+    @JvmStatic
+    fun getTempCacheFile(context: Context, fileName: String): File {
+        val cacheFile = File(context.applicationContext.filesDir.absolutePath + "/" + fileName)
+        Log.v(TAG, "Full path for new cache file:" + cacheFile.absolutePath)
+        val tempDir = cacheFile.parentFile ?: throw FileNotFoundException("could not cacheFile.getParentFile()")
+        if (!tempDir.exists()) {
+            Log.v(
+                TAG,
+                "The folder in which the new file should be created does not exist yet. Trying to create it…"
+            )
+            if (tempDir.mkdirs()) {
+                Log.v(TAG, "Creation successful")
+            } else {
+                throw IOException("Directory for temporary file does not exist and could not be created.")
+            }
+        }
+        Log.v(TAG, "- Try to create actual cache file")
+        if (cacheFile.createNewFile()) {
+            Log.v(TAG, "Successfully created cache file")
+        } else {
+            throw IOException("Failed to create cacheFile")
+        }
+        return cacheFile
+    }
+
+    /**
+     * Creates a new [File]
+     */
+    fun removeTempCacheFile(context: Context, fileName: String) {
+        val cacheFile = File(context.applicationContext.filesDir.absolutePath + "/" + fileName)
+        Log.v(TAG, "Full path for new cache file:" + cacheFile.absolutePath)
+        if (cacheFile.exists()) {
+            if (cacheFile.delete()) {
+                Log.v(TAG, "Deletion successful")
+            } else {
+                throw IOException("Directory for temporary file does not exist and could not be created.")
+            }
+        }
+    }
+
+    @Suppress("ThrowsCount")
+    fun getFileFromUri(context: Context, sourceFileUri: Uri): File? {
+        val fileName = getFileName(sourceFileUri, context)
+        val scheme = sourceFileUri.scheme
+
+        val file = if (scheme == null) {
+            Log.d(TAG, "relative uri: " + sourceFileUri.path)
+            throw IllegalArgumentException("relative paths are not supported")
+        } else if (ContentResolver.SCHEME_CONTENT == scheme) {
+            copyFileToCache(context, sourceFileUri, fileName)
+        } else if (ContentResolver.SCHEME_FILE == scheme) {
+            if (sourceFileUri.path != null) {
+                sourceFileUri.path?.let { File(it) }
+            } else {
+                throw IllegalArgumentException("uri does not contain path")
+            }
+        } else {
+            throw IllegalArgumentException("unsupported scheme: " + sourceFileUri.path)
+        }
+        return file
+    }
+
+    @Suppress("NestedBlockDepth")
+    fun copyFileToCache(context: Context, sourceFileUri: Uri, filename: String): File {
+        val cachedFile = File(context.cacheDir, filename)
+
+        if (cachedFile.exists()) {
+            Log.d(TAG, "file is already in cache")
+        } else {
+            val outputStream = FileOutputStream(cachedFile)
+            try {
+                val inputStream: InputStream? = context.contentResolver.openInputStream(sourceFileUri)
+                inputStream?.use { input ->
+                    outputStream.use { output ->
+                        input.copyTo(output)
+                    }
+                }
+                outputStream.flush()
+            } catch (e: FileNotFoundException) {
+                Log.w(TAG, "failed to copy file to cache", e)
+            }
+        }
+        return cachedFile
+    }
+
+    fun getFileName(uri: Uri, context: Context?): String {
+        var filename: String? = null
+        if (uri.scheme == "content" && context != null) {
+            val cursor: Cursor? = context.contentResolver.query(uri, null, null, null, null)
+            try {
+                if (cursor != null && cursor.moveToFirst()) {
+                    filename = cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME))
+                }
+            } finally {
+                cursor?.close()
+            }
+        }
+        // if it was no content uri, read filename from path
+        if (filename == null) {
+            filename = uri.path
+            val lastIndexOfSlash = filename!!.lastIndexOf('/')
+            if (lastIndexOfSlash != -1) {
+                filename = filename.substring(lastIndexOfSlash + 1)
+            }
+        }
+        return filename
+    }
+
+    @JvmStatic
+    fun md5Sum(file: File): String {
+        val temp = file.name + file.lastModified() + file.length()
+        val messageDigest = MessageDigest.getInstance("MD5")
+        messageDigest.update(temp.toByteArray())
+        val digest = messageDigest.digest()
+        val md5String = StringBuilder(BigInteger(1, digest).toString(RADIX))
+        while (md5String.length < MD5_LENGTH) {
+            md5String.insert(0, "0")
+        }
+        return md5String.toString()
+    }
+}

+ 34 - 6
app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt

@@ -61,6 +61,7 @@ object NotificationUtils {
 
     const val NOTIFICATION_CHANNEL_MESSAGES_V4 = "NOTIFICATION_CHANNEL_MESSAGES_V4"
     const val NOTIFICATION_CHANNEL_CALLS_V4 = "NOTIFICATION_CHANNEL_CALLS_V4"
+    const val NOTIFICATION_CHANNEL_UPLOADS = "NOTIFICATION_CHANNEL_UPLOADS"
 
     const val DEFAULT_CALL_RINGTONE_URI =
         "android.resource://" + BuildConfig.APPLICATION_ID + "/raw/librem_by_feandesign_call"
@@ -75,7 +76,7 @@ object NotificationUtils {
         context: Context,
         notificationChannel: Channel,
         sound: Uri?,
-        audioAttributes: AudioAttributes
+        audioAttributes: AudioAttributes?
     ) {
 
         val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
@@ -84,9 +85,16 @@ object NotificationUtils {
             Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
             notificationManager.getNotificationChannel(notificationChannel.id) == null
         ) {
-            val channel = NotificationChannel(
-                notificationChannel.id, notificationChannel.name,
+            val importance = if (notificationChannel.isImportant) {
                 NotificationManager.IMPORTANCE_HIGH
+            } else {
+                NotificationManager.IMPORTANCE_LOW
+            }
+
+            val channel = NotificationChannel(
+                notificationChannel.id,
+                notificationChannel.name,
+                importance
             )
 
             channel.description = notificationChannel.description
@@ -115,7 +123,8 @@ object NotificationUtils {
             Channel(
                 NOTIFICATION_CHANNEL_CALLS_V4,
                 context.resources.getString(R.string.nc_notification_channel_calls),
-                context.resources.getString(R.string.nc_notification_channel_calls_description)
+                context.resources.getString(R.string.nc_notification_channel_calls_description),
+                true
             ),
             soundUri,
             audioAttributes
@@ -138,19 +147,37 @@ object NotificationUtils {
             Channel(
                 NOTIFICATION_CHANNEL_MESSAGES_V4,
                 context.resources.getString(R.string.nc_notification_channel_messages),
-                context.resources.getString(R.string.nc_notification_channel_messages_description)
+                context.resources.getString(R.string.nc_notification_channel_messages_description),
+                true
             ),
             soundUri,
             audioAttributes
         )
     }
 
+    private fun createUploadsNotificationChannel(
+        context: Context
+    ) {
+        createNotificationChannel(
+            context,
+            Channel(
+                NOTIFICATION_CHANNEL_UPLOADS,
+                context.resources.getString(R.string.nc_notification_channel_uploads),
+                context.resources.getString(R.string.nc_notification_channel_uploads_description),
+                false
+            ),
+            null,
+            null
+        )
+    }
+
     fun registerNotificationChannels(
         context: Context,
         appPreferences: AppPreferences
     ) {
         createCallsNotificationChannel(context, appPreferences)
         createMessagesNotificationChannel(context, appPreferences)
+        createUploadsNotificationChannel(context)
     }
 
     @TargetApi(Build.VERSION_CODES.O)
@@ -327,6 +354,7 @@ object NotificationUtils {
     private data class Channel(
         val id: String,
         val name: String,
-        val description: String
+        val description: String,
+        val isImportant: Boolean
     )
 }

+ 99 - 0
app/src/main/java/com/nextcloud/talk/utils/RemoteFileUtils.kt

@@ -0,0 +1,99 @@
+package com.nextcloud.talk.utils
+
+/*
+ * Nextcloud Talk application
+ * @author Marcel Hibbe
+ * @author David A. Velasco
+ * @author Chris Narkiewicz
+ * Copyright (C) 2021 Marcel Hibbe <dev@mhibbe.de>
+ * Copyright (C) 2016 ownCloud GmbH.
+ * Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+import com.nextcloud.talk.api.NcApi
+import com.nextcloud.talk.data.user.model.User
+import io.reactivex.Observable
+import io.reactivex.android.schedulers.AndroidSchedulers
+import io.reactivex.schedulers.Schedulers
+
+object RemoteFileUtils {
+    private val TAG = RemoteFileUtils::class.java.simpleName
+
+    fun getNewPathIfFileExists(
+        ncApi: NcApi,
+        currentUser: User,
+        remotePath: String
+    ): String {
+        var finalPath = remotePath
+        val fileExists = doesFileExist(
+            ncApi,
+            currentUser,
+            remotePath,
+        ).blockingFirst()
+
+        if (fileExists) {
+            finalPath = getFileNameWithoutCollision(
+                ncApi,
+                currentUser,
+                remotePath
+            )
+        }
+        return finalPath
+    }
+
+    private fun doesFileExist(
+        ncApi: NcApi,
+        currentUser: User,
+        remotePath: String
+    ): Observable<Boolean> {
+        return ncApi.checkIfFileExists(
+            ApiUtils.getCredentials(currentUser.username, currentUser.token),
+            ApiUtils.getUrlForFileUpload(
+                currentUser.baseUrl,
+                currentUser.userId,
+                remotePath
+            )
+        )
+            .subscribeOn(Schedulers.io())
+            .observeOn(AndroidSchedulers.mainThread()).map { response ->
+                response.isSuccessful
+            }
+    }
+
+    private fun getFileNameWithoutCollision(
+        ncApi: NcApi,
+        currentUser: User,
+        remotePath: String
+    ): String {
+        val extPos = remotePath.lastIndexOf('.')
+        var suffix: String
+        var extension = ""
+        var remotePathWithoutExtension = ""
+        if (extPos >= 0) {
+            extension = remotePath.substring(extPos + 1)
+            remotePathWithoutExtension = remotePath.substring(0, extPos)
+        }
+        var count = 2
+        var exists: Boolean
+        var newPath: String
+        do {
+            suffix = " ($count)"
+            newPath = if (extPos >= 0) "$remotePathWithoutExtension$suffix.$extension" else remotePath + suffix
+            exists = doesFileExist(ncApi, currentUser, newPath).blockingFirst()
+            count++
+        } while (exists)
+        return newPath
+    }
+}

+ 0 - 28
app/src/main/java/com/nextcloud/talk/utils/UriUtils.kt

@@ -22,36 +22,8 @@
 
 package com.nextcloud.talk.utils
 
-import android.content.Context
-import android.database.Cursor
-import android.net.Uri
-import android.provider.OpenableColumns
-import android.util.Log
-
 class UriUtils {
     companion object {
-        fun getFileName(uri: Uri, context: Context?): String {
-            var filename: String? = null
-            if (uri.scheme == "content" && context != null) {
-                val cursor: Cursor? = context.contentResolver.query(uri, null, null, null, null)
-                try {
-                    if (cursor != null && cursor.moveToFirst()) {
-                        filename = cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME))
-                    }
-                } finally {
-                    cursor?.close()
-                }
-            }
-            if (filename == null) {
-                Log.d("UriUtils", "failed to get DISPLAY_NAME from uri. using fallback.")
-                filename = uri.path
-                val lastIndexOfSlash = filename!!.lastIndexOf('/')
-                if (lastIndexOfSlash != -1) {
-                    filename = filename.substring(lastIndexOfSlash + 1)
-                }
-            }
-            return filename
-        }
 
         fun hasHttpProtocollPrefixed(uri: String): Boolean {
             return uri.startsWith("http://") || uri.startsWith("https://")

+ 27 - 0
app/src/main/res/drawable/upload_white.xml

@@ -0,0 +1,27 @@
+<!--
+    @author Google LLC
+    Copyright (C) 2018 Google LLC
+
+    Licensed under the Apache License, Version 2.0 (the "License");
+    you may not use this file except in compliance with the License.
+    You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+    Unless required by applicable law or agreed to in writing, software
+    distributed under the License is distributed on an "AS IS" BASIS,
+    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+    See the License for the specific language governing permissions and
+    limitations under the License.
+-->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+        android:width="24dp"
+        android:height="24dp"
+        android:viewportWidth="24"
+        android:viewportHeight="24">
+
+    <path
+        android:fillColor="#ffffff"
+        android:strokeWidth="1"
+        android:pathData="M 9,16 V 10 H 5 l 7,-7 7,7 h -4 v 6 H 9 m -4,4 v -2 h 14 v 2 z"/>
+</vector>

+ 10 - 0
app/src/main/res/values/strings.xml

@@ -219,8 +219,10 @@
     <string name="nc_notification_channel">%1$s on %2$s notification channel</string>
     <string name="nc_notification_channel_calls">Calls</string>
     <string name="nc_notification_channel_messages">Messages</string>
+    <string name="nc_notification_channel_uploads">Uploads</string>
     <string name="nc_notification_channel_calls_description">Notify about incoming calls</string>
     <string name="nc_notification_channel_messages_description">Notify about incoming messages</string>
+    <string name="nc_notification_channel_uploads_description">Notify about upload progress</string>
     <string name="nc_notification_settings">Notification settings</string>
     <string name="nc_plain_old_messages">Messages</string>
     <string name="nc_notify_me_always">Always notify</string>
@@ -426,6 +428,13 @@
     <string name="nc_upload_in_progess">Uploading</string>
     <string name="nc_upload_from_device">Upload from device</string>
 
+    <string name="nc_upload_notification_text">%1$s to %2$s - %3$s\%%</string>
+    <string name="nc_upload_failed_notification_title">Failure</string>
+    <string name="nc_upload_failed_notification_text">Failed to upload %1$s</string>
+
+    <!-- Video -->
+    <string name="nc_video_filename">Video recording from %1$s</string>
+
     <!-- location sharing -->
     <string name="nc_share_location">Share location</string>
     <string name="nc_location_permission_required">location permission is required</string>
@@ -524,6 +533,7 @@
     <string name="take_photo_send">Send</string>
     <string name="take_photo_error_deleting_picture">Error taking picture</string>
     <string name="take_photo_permission">Taking a photo is not possible without permissions</string>
+    <string name="camera_permission_granted">Camera permission granted. Please choose camera again.</string>
 
     <!-- Audio selection -->
     <string name="audio_output_bluetooth">Bluetooth</string>

+ 1 - 1
scripts/analysis/findbugs-results.txt

@@ -1 +1 @@
-138
+136