Merge pull request #2351 from nextcloud/feature/1587/directVideoUpload2

direct video upload + chunked upload
This commit is contained in:
Marcel Hibbe 2022-09-16 08:36:04 +02:00 committed by GitHub
commit 120219a711
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 1463 additions and 440 deletions

View File

@ -253,6 +253,9 @@
<action android:name="android.intent.action.SEND" />
<data android:mimeType="*/*" />
</intent>
<intent>
<action android:name="android.media.action.VIDEO_CAPTURE" />
</intent>
</queries>
</manifest>

View File

@ -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();
}
});

View File

@ -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);

View File

@ -51,6 +51,7 @@ import android.os.SystemClock
import android.os.VibrationEffect
import android.os.Vibrator
import android.provider.ContactsContract
import android.provider.MediaStore
import android.text.Editable
import android.text.InputFilter
import android.text.TextUtils
@ -161,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
@ -204,6 +205,7 @@ import java.io.IOException
import java.net.HttpURLConnection
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.Objects
import java.util.concurrent.ExecutionException
import javax.inject.Inject
@ -284,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())
@ -734,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)
@ -1089,8 +1093,7 @@ class ChatController(args: Bundle) :
@SuppressLint("SimpleDateFormat")
private fun setVoiceRecordFileName() {
val pattern = "yyyy-MM-dd HH-mm-ss"
val simpleDateFormat = SimpleDateFormat(pattern)
val simpleDateFormat = SimpleDateFormat(FILE_DATE_PATTERN)
val date: String = simpleDateFormat.format(Date())
val fileNameWithoutSuffix = String.format(
@ -1174,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() {
@ -1360,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")
@ -1404,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")
}
@ -1422,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)
}
@ -1467,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)
}
@ -1548,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
@ -1578,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)
@ -1588,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
}
@ -1601,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)
@ -2276,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")) {
@ -3253,6 +3257,36 @@ class ChatController(args: Bundle) :
}
}
fun sendVideoFromCamIntent() {
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 {
videoURI = FileProvider.getUriForFile(context, context.packageName, it)
takeVideoIntent.putExtra(MediaStore.EXTRA_OUTPUT, videoURI)
startActivityForResult(takeVideoIntent, REQUEST_CODE_PICK_CAMERA)
}
}
}
}
}
fun createPoll() {
val pollVoteDialog = PollCreateDialogFragment.newInstance(
roomToken!!
@ -3288,6 +3322,8 @@ class ChatController(args: Bundle) :
private const val VOICE_RECORD_CANCEL_SLIDER_X: Int = -50
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_SUFFIX = ".mp4"
private const val SHORT_VIBRATE: Long = 20
private const val FULLY_OPAQUE_INT: Int = 255
private const val SEMI_TRANSPARENT_INT: Int = 99

View File

@ -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)

View File

@ -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

View File

@ -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();
}
}

View File

@ -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)
}
}
}

View File

@ -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
)
}
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()
}
}
} else {
Log.d(TAG, "starting normal upload (not chunked)")
@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)
uploadSuccess = FileUploader(
context,
currentUser,
roomToken,
ncApi
).upload(
sourceFileUri,
fileName,
remotePath,
metaData
).blockingFirst()
}
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
}
override fun onNext(t: Response<GenericOverall>) {
// unused atm
}
override fun onError(e: Throwable) {
Log.e(TAG, "failed to upload file ${uploadItem.fileName}")
}
override fun onComplete() {
shareFile(roomToken, currentUser, ncTargetPath, uploadItem.fileName, metaData)
copyFileToCache(uploadItem.uri, uploadItem.fileName)
}
})
return remotePath
}
private fun copyFileToCache(sourceFileUri: Uri, filename: String) {
val cachedFile = File(context.cacheDir, filename)
override fun onTransferProgress(
percentage: Int
) {
notification = mBuilder!!
.setProgress(HUNDRED_PERCENT, percentage, false)
.setContentText(getNotificationContentText(percentage))
.build()
if (cachedFile.exists()) {
Log.d(TAG, "file is already in cache")
mNotifyManager!!.notify(notificationId, notification)
}
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 getNotificationContentText(percentage: Int): String {
return String.format(
context.resources.getString(R.string.nc_upload_notification_text),
getShortenedFileName(),
conversationName,
percentage
)
}
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")
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
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)
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()
val shareWorker = OneTimeWorkRequest.Builder(ShareOperationWorker::class.java)
.setInputData(data)
.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)
}
}
}

View File

@ -23,6 +23,7 @@
package com.nextcloud.talk.ui.dialog
import android.app.Activity
import android.content.pm.PackageManager
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
@ -81,6 +82,10 @@ class AttachmentDialog(val activity: Activity, var chatController: ChatControlle
if (!CapabilitiesUtilNew.hasSpreedFeatureCapability(chatController.conversationUser, "talk-polls")) {
dialogAttachmentBinding.menuAttachPoll.visibility = View.GONE
}
if (!context.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY)) {
dialogAttachmentBinding.menuAttachVideoFromCam.visibility = View.GONE
}
}
private fun initItemsClickListeners() {
@ -99,6 +104,11 @@ class AttachmentDialog(val activity: Activity, var chatController: ChatControlle
dismiss()
}
dialogAttachmentBinding.menuAttachVideoFromCam.setOnClickListener {
chatController.sendVideoFromCamIntent()
dismiss()
}
dialogAttachmentBinding.menuAttachPoll.setOnClickListener {
chatController.createPoll()
dismiss()

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
)
}

View File

@ -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
}
}

View File

@ -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) {

View File

@ -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.");
}
}
}
}

View File

@ -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()
}
}

View File

@ -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
)
}

View File

@ -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
}
}

View File

@ -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://")

View File

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M17,10.5V7c0,-0.55 -0.45,-1 -1,-1H4c-0.55,0 -1,0.45 -1,1v10c0,0.55 0.45,1 1,1h12c0.55,0 1,-0.45 1,-1v-3.5l4,4v-11l-4,4z"/>
</vector>

View File

@ -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>

View File

@ -171,6 +171,39 @@
</LinearLayout>
<LinearLayout
android:id="@+id/menu_attach_video_from_cam"
android:layout_width="match_parent"
android:layout_height="@dimen/bottom_sheet_item_height"
android:background="?android:attr/selectableItemBackground"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="@dimen/standard_padding"
android:paddingEnd="@dimen/standard_padding"
tools:ignore="UseCompoundDrawables">
<ImageView
android:id="@+id/menu_icon_attach_video_from_cam"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@null"
android:src="@drawable/ic_baseline_videocam_24"
app:tint="@color/high_emphasis_menu_icon" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/txt_attach_video_from_cam"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="start|center_vertical"
android:paddingStart="@dimen/standard_double_padding"
android:paddingEnd="@dimen/zero"
android:text="@string/nc_upload_video_from_cam"
android:textAlignment="viewStart"
android:textColor="@color/high_emphasis_text"
android:textSize="@dimen/bottom_sheet_text_size" />
</LinearLayout>
<LinearLayout
android:id="@+id/menu_attach_file_from_local"
android:layout_width="match_parent"

View File

@ -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>
@ -416,6 +418,7 @@
<!-- Upload -->
<string name="nc_add_file">Add to conversation</string>
<string name="nc_upload_picture_from_cam">Take photo</string>
<string name="nc_upload_video_from_cam">Take video</string>
<string name="nc_create_poll">Create poll</string>
<string name="nc_upload_from_cloud">Share from %1$s</string>
<string name="nc_upload_failed">Sorry, upload failed</string>
@ -425,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>
@ -523,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>

View File

@ -1 +1 @@
138
136