From b20fed58dbd809ea0545afcf60e44bc37d4f9669 Mon Sep 17 00:00:00 2001 From: Evgenii Kozlov Date: Sat, 21 Dec 2024 17:04:49 +0100 Subject: [PATCH] DROID-3098 Chats | Enhancement | Basics for uploading file attachments (#1950) --- .../di/feature/discussions/DiscussionsDI.kt | 13 +++ .../core_utils/common/DefaultFileInfo.kt | 7 ++ .../anytype/domain/auth/CreateAccountTest.kt | 6 +- .../presentation/DiscussionView.kt | 4 +- .../presentation/DiscussionViewModel.kt | 46 +++++--- .../DiscussionViewModelFactory.kt | 7 +- .../ui/DiscussionScreen.kt | 102 +++++++++++++----- localization/src/main/res/values/strings.xml | 1 + .../presentation/util/CopyFileToCache.kt | 43 ++++++++ 9 files changed, 180 insertions(+), 49 deletions(-) create mode 100644 core-utils/src/main/java/com/anytypeio/anytype/core_utils/common/DefaultFileInfo.kt diff --git a/app/src/main/java/com/anytypeio/anytype/di/feature/discussions/DiscussionsDI.kt b/app/src/main/java/com/anytypeio/anytype/di/feature/discussions/DiscussionsDI.kt index 3ba632cbd3..19d54d66ce 100644 --- a/app/src/main/java/com/anytypeio/anytype/di/feature/discussions/DiscussionsDI.kt +++ b/app/src/main/java/com/anytypeio/anytype/di/feature/discussions/DiscussionsDI.kt @@ -1,5 +1,6 @@ package com.anytypeio.anytype.di.feature.discussions +import android.content.Context import androidx.lifecycle.ViewModelProvider import com.anytypeio.anytype.analytics.base.Analytics import com.anytypeio.anytype.core_utils.di.scope.PerScreen @@ -21,11 +22,14 @@ import com.anytypeio.anytype.feature_discussions.presentation.DiscussionViewMode import com.anytypeio.anytype.feature_discussions.presentation.DiscussionViewModelFactory import com.anytypeio.anytype.middleware.EventProxy import com.anytypeio.anytype.presentation.common.BaseViewModel +import com.anytypeio.anytype.presentation.util.CopyFileToCacheDirectory +import com.anytypeio.anytype.presentation.util.DefaultCopyFileToCacheDirectory import com.anytypeio.anytype.ui.home.HomeScreenFragment import dagger.Binds import dagger.BindsInstance import dagger.Component import dagger.Module +import dagger.Provides @Component( dependencies = [DiscussionComponentDependencies::class], @@ -68,6 +72,14 @@ interface SpaceLevelChatComponent { @Module object DiscussionModule { + + @JvmStatic + @Provides + @PerScreen + fun provideCopyFileToCache( + context: Context + ): CopyFileToCacheDirectory = DefaultCopyFileToCacheDirectory(context) + @Module interface Declarations { @PerScreen @@ -93,4 +105,5 @@ interface DiscussionComponentDependencies : ComponentDependencies { fun members(): ActiveSpaceMemberSubscriptionContainer fun spaceViewSubscriptionContainer(): SpaceViewSubscriptionContainer fun storeOfObjectTypes(): StoreOfObjectTypes + fun context(): Context } \ No newline at end of file diff --git a/core-utils/src/main/java/com/anytypeio/anytype/core_utils/common/DefaultFileInfo.kt b/core-utils/src/main/java/com/anytypeio/anytype/core_utils/common/DefaultFileInfo.kt new file mode 100644 index 0000000000..77b874c288 --- /dev/null +++ b/core-utils/src/main/java/com/anytypeio/anytype/core_utils/common/DefaultFileInfo.kt @@ -0,0 +1,7 @@ +package com.anytypeio.anytype.core_utils.common + +data class DefaultFileInfo( + val name: String, + val uri: String, + val size: Int +) \ No newline at end of file diff --git a/domain/src/test/java/com/anytypeio/anytype/domain/auth/CreateAccountTest.kt b/domain/src/test/java/com/anytypeio/anytype/domain/auth/CreateAccountTest.kt index 5987f3426b..dde7854031 100644 --- a/domain/src/test/java/com/anytypeio/anytype/domain/auth/CreateAccountTest.kt +++ b/domain/src/test/java/com/anytypeio/anytype/domain/auth/CreateAccountTest.kt @@ -97,8 +97,7 @@ class CreateAccountTest { name = name, avatarPath = path, icon = icon, - networkMode = NetworkMode.DEFAULT, - preferYamuxTransport = false + networkMode = NetworkMode.DEFAULT ) onBlocking { createAccount(command) } doReturn setup } @@ -114,8 +113,7 @@ class CreateAccountTest { name = name, avatarPath = path, icon = icon, - networkMode = NetworkMode.DEFAULT, - preferYamuxTransport = false + networkMode = NetworkMode.DEFAULT ) verify(repo, times(1)).getNetworkMode() verify(repo, times(1)).createAccount(command) diff --git a/feature-discussions/src/main/java/com/anytypeio/anytype/feature_discussions/presentation/DiscussionView.kt b/feature-discussions/src/main/java/com/anytypeio/anytype/feature_discussions/presentation/DiscussionView.kt index afc6f8b25e..f17b5605e8 100644 --- a/feature-discussions/src/main/java/com/anytypeio/anytype/feature_discussions/presentation/DiscussionView.kt +++ b/feature-discussions/src/main/java/com/anytypeio/anytype/feature_discussions/presentation/DiscussionView.kt @@ -61,7 +61,9 @@ sealed interface DiscussionView { val uri: String ): ChatBoxAttachment() data class File( - val uri: String + val uri: String, + val name: String, + val size: Int ): ChatBoxAttachment() data class Link( val target: Id, diff --git a/feature-discussions/src/main/java/com/anytypeio/anytype/feature_discussions/presentation/DiscussionViewModel.kt b/feature-discussions/src/main/java/com/anytypeio/anytype/feature_discussions/presentation/DiscussionViewModel.kt index cc709fdbdf..9f18f8c5ea 100644 --- a/feature-discussions/src/main/java/com/anytypeio/anytype/feature_discussions/presentation/DiscussionViewModel.kt +++ b/feature-discussions/src/main/java/com/anytypeio/anytype/feature_discussions/presentation/DiscussionViewModel.kt @@ -9,6 +9,7 @@ import com.anytypeio.anytype.core_models.Relations import com.anytypeio.anytype.core_models.chats.Chat import com.anytypeio.anytype.core_models.primitives.Space import com.anytypeio.anytype.core_ui.text.splitByMarks +import com.anytypeio.anytype.core_utils.common.DefaultFileInfo import com.anytypeio.anytype.core_utils.ext.withLatestFrom import com.anytypeio.anytype.domain.auth.interactor.GetAccount import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers @@ -35,6 +36,7 @@ import com.anytypeio.anytype.presentation.home.navigation import com.anytypeio.anytype.presentation.mapper.objectIcon import com.anytypeio.anytype.presentation.objects.ObjectIcon import com.anytypeio.anytype.presentation.search.GlobalSearchItemView +import com.anytypeio.anytype.presentation.util.CopyFileToCacheDirectory import java.sql.Types import javax.inject.Inject import kotlinx.coroutines.delay @@ -44,6 +46,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combineLatest import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import timber.log.Timber class DiscussionViewModel @Inject constructor( @@ -61,7 +64,8 @@ class DiscussionViewModel @Inject constructor( private val spaceViews: SpaceViewSubscriptionContainer, private val dispatchers: AppCoroutineDispatchers, private val uploadFile: UploadFile, - private val storeOfObjectTypes: StoreOfObjectTypes + private val storeOfObjectTypes: StoreOfObjectTypes, + private val copyFileToCacheDirectory: CopyFileToCacheDirectory ) : BaseViewModel() { val name = MutableStateFlow(null) @@ -265,18 +269,26 @@ class DiscussionViewModel @Inject constructor( } } is DiscussionView.Message.ChatBoxAttachment.File -> { - uploadFile.async( - UploadFile.Params( - space = vmParams.space, - path = attachment.uri - ) - ).onSuccess { file -> - add( - Chat.Message.Attachment( - target = file.id, - type = Chat.Message.Attachment.Type.Image + val path = withContext(dispatchers.io) { + copyFileToCacheDirectory.copy(attachment.uri) + } + if (path != null) { + uploadFile.async( + UploadFile.Params( + space = vmParams.space, + path = path ) - ) + ).onSuccess { file -> + // TODO delete file. + add( + Chat.Message.Attachment( + target = file.id, + type = Chat.Message.Attachment.Type.File + ) + ) + }.onFailure { + Timber.e(it, "Error while uploading file as attachment") + } } } } @@ -469,11 +481,13 @@ class DiscussionViewModel @Inject constructor( } } - fun onChatBoxFilePicked(uris: List) { - Timber.d("onChatBoxFilePicked: $uris") - chatBoxAttachments.value = chatBoxAttachments.value + uris.map { + fun onChatBoxFilePicked(infos: List) { + Timber.d("onChatBoxFilePicked: $infos") + chatBoxAttachments.value = chatBoxAttachments.value + infos.map { info -> DiscussionView.Message.ChatBoxAttachment.File( - uri = it + uri = info.uri, + name = info.name, + size = info.size ) } } diff --git a/feature-discussions/src/main/java/com/anytypeio/anytype/feature_discussions/presentation/DiscussionViewModelFactory.kt b/feature-discussions/src/main/java/com/anytypeio/anytype/feature_discussions/presentation/DiscussionViewModelFactory.kt index 61df433255..1dc10d16a0 100644 --- a/feature-discussions/src/main/java/com/anytypeio/anytype/feature_discussions/presentation/DiscussionViewModelFactory.kt +++ b/feature-discussions/src/main/java/com/anytypeio/anytype/feature_discussions/presentation/DiscussionViewModelFactory.kt @@ -18,6 +18,7 @@ import com.anytypeio.anytype.domain.`object`.OpenObject import com.anytypeio.anytype.domain.`object`.SetObjectDetails import com.anytypeio.anytype.domain.objects.StoreOfObjectTypes import com.anytypeio.anytype.presentation.common.BaseViewModel +import com.anytypeio.anytype.presentation.util.CopyFileToCacheDirectory import javax.inject.Inject class DiscussionViewModelFactory @Inject constructor( @@ -35,7 +36,8 @@ class DiscussionViewModelFactory @Inject constructor( private val spaceViews: SpaceViewSubscriptionContainer, private val dispatchers: AppCoroutineDispatchers, private val uploadFile: UploadFile, - private val storeOfObjectTypes: StoreOfObjectTypes + private val storeOfObjectTypes: StoreOfObjectTypes, + private val copyFileToCacheDirectory: CopyFileToCacheDirectory ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T = DiscussionViewModel( @@ -53,6 +55,7 @@ class DiscussionViewModelFactory @Inject constructor( spaceViews = spaceViews, dispatchers = dispatchers, uploadFile = uploadFile, - storeOfObjectTypes = storeOfObjectTypes + storeOfObjectTypes = storeOfObjectTypes, + copyFileToCacheDirectory = copyFileToCacheDirectory ) as T } \ No newline at end of file diff --git a/feature-discussions/src/main/java/com/anytypeio/anytype/feature_discussions/ui/DiscussionScreen.kt b/feature-discussions/src/main/java/com/anytypeio/anytype/feature_discussions/ui/DiscussionScreen.kt index 7295d10237..0917d466df 100644 --- a/feature-discussions/src/main/java/com/anytypeio/anytype/feature_discussions/ui/DiscussionScreen.kt +++ b/feature-discussions/src/main/java/com/anytypeio/anytype/feature_discussions/ui/DiscussionScreen.kt @@ -1,7 +1,9 @@ package com.anytypeio.anytype.feature_discussions.ui +import android.content.ContentResolver import android.content.res.Configuration import android.net.Uri +import android.provider.OpenableColumns import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts @@ -122,6 +124,7 @@ import com.anytypeio.anytype.core_ui.views.Relations2 import com.anytypeio.anytype.core_ui.views.Relations3 import com.anytypeio.anytype.core_ui.views.fontIBM import com.anytypeio.anytype.core_ui.widgets.ListWidgetObjectIcon +import com.anytypeio.anytype.core_utils.common.DefaultFileInfo import com.anytypeio.anytype.core_utils.const.DateConst.TIME_H24 import com.anytypeio.anytype.core_utils.ext.formatTimeInMillis import com.anytypeio.anytype.core_utils.ext.parseImagePath @@ -209,7 +212,28 @@ fun DiscussionScreenWrapper( vm.onChatBoxMediaPicked(uris.map { it.parseImagePath(context = context) }) }, onChatBoxFilePicked = { uris -> - // TODO parse path and path it vm. + val infos = uris.mapNotNull { uri -> + val cursor = context.contentResolver.query( + uri, + null, + null, + null, + null + ) + if (cursor != null) { + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE) + cursor.moveToFirst() + DefaultFileInfo( + uri = uri.toString(), + name = cursor.getString(nameIndex), + size = cursor.getLong(sizeIndex).toInt() + ) + } else { + null + } + } + vm.onChatBoxFilePicked(infos) } ) LaunchedEffect(Unit) { @@ -548,12 +572,8 @@ private fun ChatBox( painter = painterResource(R.drawable.ic_clear_chatbox_attachment), contentDescription = "Clear attachment icon", modifier = Modifier - .align( - Alignment.TopEnd - ) - .padding( - top = 6.dp - ) + .align(Alignment.TopEnd) + .padding(top = 6.dp) .noRippleClickable { onClearAttachmentClicked(attachment) } @@ -563,7 +583,37 @@ private fun ChatBox( } is DiscussionView.Message.ChatBoxAttachment.File -> { item { - Text(text = attachment.uri) + Box { + AttachedObject( + modifier = Modifier + .padding( + top = 12.dp, + end = 4.dp + ) + .width(216.dp), + title = attachment.name, + type = stringResource(R.string.file), + icon = ObjectIcon.File( + mime = null, + fileName = null + ), + onAttachmentClicked = { + // TODO + } + ) + Image( + painter = painterResource(id = R.drawable.ic_clear_chatbox_attachment), + contentDescription = "Close icon", + modifier = Modifier + .align( + Alignment.TopEnd + ) + .padding(top = 6.dp) + .noRippleClickable { + onClearAttachmentClicked(attachment) + } + ) + } } } } @@ -726,24 +776,24 @@ private fun ChatBox( ) } ) -// Divider( -// paddingStart = 0.dp, -// paddingEnd = 0.dp -// ) -// DropdownMenuItem( -// text = { -// Text( -// text = stringResource(R.string.chat_attachment_file), -// color = colorResource(id = R.color.text_primary) -// ) -// }, -// onClick = { -// showDropdownMenu = false -// uploadFileLauncher.launch( -// arrayOf("*/*") -// ) -// } -// ) + Divider( + paddingStart = 0.dp, + paddingEnd = 0.dp + ) + DropdownMenuItem( + text = { + Text( + text = stringResource(R.string.chat_attachment_file), + color = colorResource(id = R.color.text_primary) + ) + }, + onClick = { + showDropdownMenu = false + uploadFileLauncher.launch( + arrayOf("*/*") + ) + } + ) } } } diff --git a/localization/src/main/res/values/strings.xml b/localization/src/main/res/values/strings.xml index 163be429e3..41a81431aa 100644 --- a/localization/src/main/res/values/strings.xml +++ b/localization/src/main/res/values/strings.xml @@ -1847,5 +1847,6 @@ Please provide specific details of your needs here. Friday Saturday Sunday + File \ No newline at end of file diff --git a/presentation/src/main/java/com/anytypeio/anytype/presentation/util/CopyFileToCache.kt b/presentation/src/main/java/com/anytypeio/anytype/presentation/util/CopyFileToCache.kt index e28d72505c..bcfceeeef0 100644 --- a/presentation/src/main/java/com/anytypeio/anytype/presentation/util/CopyFileToCache.kt +++ b/presentation/src/main/java/com/anytypeio/anytype/presentation/util/CopyFileToCache.kt @@ -29,6 +29,8 @@ interface CopyFileToCacheDirectory { */ fun execute(uri: Uri, scope: CoroutineScope, listener: CopyFileToCacheStatus) + suspend fun copy(uri: String): String? + /** * Cancels the ongoing file copying operation. */ @@ -83,6 +85,10 @@ class DefaultCopyFileToCacheDirectory(context: Context) : CopyFileToCacheDirecto ) } + override suspend fun copy(uri: String): String? { + return copyFileToCacheDir(uri) + } + override fun cancel() { job?.cancel() mContext?.get()?.deleteTemporaryFolder() @@ -145,6 +151,39 @@ class DefaultCopyFileToCacheDirectory(context: Context) : CopyFileToCacheDirecto return null } + private fun copyFileToCacheDir( + uri: String + ): String? { + var newFile: File? = null + mContext?.get()?.let { context: Context -> + val cacheDir = context.getExternalFilesDirTemp() + if (cacheDir != null && !cacheDir.exists()) { + cacheDir.mkdirs() + } + try { + val inputStream = context.contentResolver.openInputStream(Uri.parse(uri)) + inputStream?.use { input -> + newFile = File(cacheDir?.path + "/" + getFileName(context, Uri.parse(uri))); + Timber.d("Start copy file to cache : ${newFile.path}") + FileOutputStream(newFile).use { output -> + val buffer = ByteArray(1024) + var read: Int = input.read(buffer) + while (read != -1) { + output.write(buffer, 0, read) + read = input.read(buffer) + } + } + return newFile.path + } + } catch (e: Exception) { + val deleteResult = newFile?.deleteRecursively() + Timber.d("Get exception while copying file, deleteRecursively success: $deleteResult") + Timber.e(e, "Error while coping file") + } + } + return null + } + private fun getFileName(context: Context, uri: Uri): String? { var result: String? = null if (uri.scheme == SCHEME_CONTENT) { @@ -199,6 +238,10 @@ class NetworkModeCopyFileToCacheDirectory(context: Context) : CopyFileToCacheDir ) } + override suspend fun copy(uri: String): String? { + throw UnsupportedOperationException() + } + override fun cancel() { job?.cancel() }