diff --git a/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationFragment.kt b/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationFragment.kt index ddcbe32de..ef7af2a7e 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationFragment.kt @@ -20,6 +20,7 @@ package org.linphone.ui.main.chat.fragment import android.Manifest +import android.app.Activity import android.app.Dialog import android.content.ActivityNotFoundException import android.content.ClipData @@ -87,6 +88,8 @@ import org.linphone.ui.main.chat.viewmodel.ConversationViewModel import org.linphone.ui.main.chat.viewmodel.ConversationViewModel.Companion.SCROLLING_POSITION_NOT_SET import org.linphone.ui.main.chat.viewmodel.SendMessageInConversationViewModel import org.linphone.ui.main.fragment.SlidingPaneChildFragment +import org.linphone.ui.main.history.model.ConfirmationDialogModel +import org.linphone.utils.DialogUtils import org.linphone.utils.Event import org.linphone.utils.FileUtils import org.linphone.utils.RecyclerViewHeaderDecoration @@ -102,6 +105,8 @@ import org.linphone.utils.showKeyboard class ConversationFragment : SlidingPaneChildFragment() { companion object { private const val TAG = "[Conversation Fragment]" + + private const val EXPORT_FILE_AS_DOCUMENT = 10 } private lateinit var binding: ChatConversationFragmentBinding @@ -120,6 +125,8 @@ class ConversationFragment : SlidingPaneChildFragment() { private var bottomSheetDialog: BottomSheetDialogFragment? = null + private var filePathToExport: String? = null + private val pickMedia = registerForActivityResult( ActivityResultContracts.PickMultipleVisualMedia() ) { list -> @@ -655,6 +662,14 @@ class ConversationFragment : SlidingPaneChildFragment() { } } + viewModel.showGreenToastEvent.observe(viewLifecycleOwner) { + it.consume { pair -> + val message = pair.first + val icon = pair.second + (requireActivity() as GenericActivity).showGreenToast(message, icon) + } + } + viewModel.showRedToastEvent.observe(viewLifecycleOwner) { it.consume { pair -> val message = pair.first @@ -832,6 +847,29 @@ class ConversationFragment : SlidingPaneChildFragment() { currentChatMessageModelForBottomSheet = null } + @Deprecated("Deprecated in Java") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (requestCode == EXPORT_FILE_AS_DOCUMENT) { + if (resultCode == Activity.RESULT_OK) { + val filePath = filePathToExport + if (filePath != null) { + data?.data?.also { documentUri -> + Log.i( + "$TAG Exported file [$filePath] should be stored in URI [$documentUri]" + ) + viewModel.copyFileToUri(filePath, documentUri) + filePathToExport = null + } + } else { + Log.e("$TAG No file path waiting to be exported!") + } + } else { + Log.w("$TAG Export file activity result is [$resultCode], aborting") + } + } + super.onActivityResult(requestCode, resultCode, data) + } + private fun scrollToFirstUnreadMessageOrBottom() { if (adapter.itemCount == 0) { Log.w("$TAG No item in adapter yet, do not scroll") @@ -893,21 +931,7 @@ class ConversationFragment : SlidingPaneChildFragment() { sharedViewModel.displayFileEvent.value = Event(bundle) } else -> { - val intent = Intent(Intent.ACTION_VIEW) - val contentUri: Uri = - FileUtils.getPublicFilePath(requireContext(), path) - intent.setDataAndType(contentUri, mime) - intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - try { - requireContext().startActivity(intent) - } catch (anfe: ActivityNotFoundException) { - Log.e("$TAG Can't open file [$path] in third party app: $anfe") - val message = getString( - R.string.toast_no_app_registered_to_handle_content_type_error - ) - val icon = R.drawable.file - (requireActivity() as MainActivity).showRedToast(message, icon) - } + showOpenOrExportFileDialog(path, mime) } } } @@ -1268,4 +1292,66 @@ class ConversationFragment : SlidingPaneChildFragment() { ) bottomSheetDialog = e2eEncryptionDetailsBottomSheet } + + private fun showOpenOrExportFileDialog(path: String, mime: String) { + val model = ConfirmationDialogModel() + val dialog = DialogUtils.getOpenOrExportFileDialog( + requireActivity(), + model + ) + + model.dismissEvent.observe(viewLifecycleOwner) { + it.consume { + dialog.dismiss() + } + } + + model.cancelEvent.observe(viewLifecycleOwner) { + it.consume { + openFileInAnotherApp(path, mime) + dialog.dismiss() + } + } + + model.confirmEvent.observe(viewLifecycleOwner) { + it.consume { + exportFile(path, mime) + dialog.dismiss() + } + } + + dialog.show() + } + + private fun openFileInAnotherApp(path: String, mime: String) { + val intent = Intent(Intent.ACTION_VIEW) + val contentUri: Uri = + FileUtils.getPublicFilePath(requireContext(), path) + intent.setDataAndType(contentUri, mime) + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + try { + Log.i("$TAG Trying to start ACTION_VIEW intent for file [$path]") + requireContext().startActivity(intent) + } catch (anfe: ActivityNotFoundException) { + Log.e("$TAG Can't open file [$path] in third party app: $anfe") + val message = getString( + R.string.toast_no_app_registered_to_handle_content_type_error + ) + val icon = R.drawable.file + (requireActivity() as MainActivity).showRedToast(message, icon) + } + } + + private fun exportFile(path: String, mime: String) { + filePathToExport = path + + Log.i("$TAG Asking where to save file [$filePathToExport] on device") + val name = FileUtils.getNameFromFilePath(path) + val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = mime + putExtra(Intent.EXTRA_TITLE, name) + } + startActivityForResult(intent, EXPORT_FILE_AS_DOCUMENT) + } } diff --git a/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationViewModel.kt b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationViewModel.kt index 827c78406..59b66e23e 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationViewModel.kt @@ -19,9 +19,14 @@ */ package org.linphone.ui.main.chat.viewmodel +import android.net.Uri import androidx.annotation.UiThread import androidx.annotation.WorkerThread import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.R import org.linphone.core.Address @@ -43,6 +48,7 @@ import org.linphone.ui.main.contacts.model.ContactAvatarModel import org.linphone.ui.main.model.isEndToEndEncryptionMandatory import org.linphone.utils.AppUtils import org.linphone.utils.Event +import org.linphone.utils.FileUtils import org.linphone.utils.LinphoneUtils class ConversationViewModel @UiThread constructor() : AbstractConversationViewModel() { @@ -110,6 +116,10 @@ class ConversationViewModel @UiThread constructor() : AbstractConversationViewMo MutableLiveData>() } + val showGreenToastEvent: MutableLiveData>> by lazy { + MutableLiveData>>() + } + val showRedToastEvent: MutableLiveData>> by lazy { MutableLiveData>>() } @@ -836,4 +846,30 @@ class ConversationViewModel @UiThread constructor() : AbstractConversationViewMo // Will trigger the conference creation/update automatically conferenceScheduler.info = conferenceInfo } + + @UiThread + fun copyFileToUri(filePath: String, dest: Uri) { + val source = Uri.parse(FileUtils.getProperFilePath(filePath)) + Log.i("$TAG Copying file URI [$source] to [$dest]") + viewModelScope.launch { + withContext(Dispatchers.IO) { + val result = FileUtils.copyFile(source, dest) + if (result) { + Log.i( + "$TAG File [$filePath] has been successfully exported to documents" + ) + val message = AppUtils.getString( + R.string.toast_file_successfully_exported_to_documents + ) + showGreenToastEvent.postValue(Event(Pair(message, R.drawable.check))) + } else { + Log.e("$TAG Failed to export file [$filePath] to documents!") + val message = AppUtils.getString( + R.string.toast_export_file_to_documents_error + ) + showRedToastEvent.postValue(Event(Pair(message, R.drawable.warning_circle))) + } + } + } + } } diff --git a/app/src/main/java/org/linphone/ui/main/file_media_viewer/fragment/MediaListViewerFragment.kt b/app/src/main/java/org/linphone/ui/main/file_media_viewer/fragment/MediaListViewerFragment.kt index dc4fe77a6..78949b651 100644 --- a/app/src/main/java/org/linphone/ui/main/file_media_viewer/fragment/MediaListViewerFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/file_media_viewer/fragment/MediaListViewerFragment.kt @@ -164,43 +164,7 @@ class MediaListViewerFragment : GenericFragment() { } binding.setExportClickListener { - val list = viewModel.mediaList.value.orEmpty() - val currentItem = binding.mediaViewPager.currentItem - val model = if (currentItem >= 0 && currentItem < list.size) list[currentItem] else null - if (model != null) { - val filePath = model.file - lifecycleScope.launch { - withContext(Dispatchers.IO) { - Log.i("$TAG Export file [$filePath] to Android's MediaStore") - val mediaStorePath = FileUtils.addContentToMediaStore(filePath) - if (mediaStorePath.isNotEmpty()) { - Log.i( - "$TAG File [$filePath] has been successfully exported to MediaStore" - ) - val message = AppUtils.getString( - R.string.toast_file_successfully_exported_to_media_store - ) - (requireActivity() as GenericActivity).showGreenToast( - message, - R.drawable.check - ) - } else { - Log.e("$TAG Failed to export file [$filePath] to MediaStore!") - val message = AppUtils.getString( - R.string.toast_export_file_to_media_store_error - ) - (requireActivity() as GenericActivity).showRedToast( - message, - R.drawable.warning_circle - ) - } - } - } - } else { - Log.e( - "$TAG Failed to get FileModel at index [$currentItem], only [${list.size}] items in list" - ) - } + exportFile() } sharedViewModel.mediaViewerFullScreenMode.observe(viewLifecycleOwner) { @@ -228,6 +192,46 @@ class MediaListViewerFragment : GenericFragment() { super.onDestroy() } + private fun exportFile() { + val list = viewModel.mediaList.value.orEmpty() + val currentItem = binding.mediaViewPager.currentItem + val model = if (currentItem >= 0 && currentItem < list.size) list[currentItem] else null + if (model != null) { + val filePath = model.file + lifecycleScope.launch { + withContext(Dispatchers.IO) { + Log.i("$TAG Export file [$filePath] to Android's MediaStore") + val mediaStorePath = FileUtils.addContentToMediaStore(filePath) + if (mediaStorePath.isNotEmpty()) { + Log.i( + "$TAG File [$filePath] has been successfully exported to MediaStore" + ) + val message = AppUtils.getString( + R.string.toast_file_successfully_exported_to_media_store + ) + (requireActivity() as GenericActivity).showGreenToast( + message, + R.drawable.check + ) + } else { + Log.e("$TAG Failed to export file [$filePath] to MediaStore!") + val message = AppUtils.getString( + R.string.toast_export_file_to_media_store_error + ) + (requireActivity() as GenericActivity).showRedToast( + message, + R.drawable.warning_circle + ) + } + } + } + } else { + Log.e( + "$TAG Failed to get FileModel at index [$currentItem], only [${list.size}] items in list" + ) + } + } + private fun shareFile() { val list = viewModel.mediaList.value.orEmpty() val currentItem = binding.mediaViewPager.currentItem diff --git a/app/src/main/java/org/linphone/utils/DialogUtils.kt b/app/src/main/java/org/linphone/utils/DialogUtils.kt index 89d5ac90f..a08298f6f 100644 --- a/app/src/main/java/org/linphone/utils/DialogUtils.kt +++ b/app/src/main/java/org/linphone/utils/DialogUtils.kt @@ -45,6 +45,7 @@ import org.linphone.databinding.DialogDeleteContactBinding import org.linphone.databinding.DialogKickFromConferenceBinding import org.linphone.databinding.DialogManageAccountInternationalPrefixHelpBinding import org.linphone.databinding.DialogMergeCallsIntoConferenceBinding +import org.linphone.databinding.DialogOpenExportFileBinding import org.linphone.databinding.DialogPickNumberOrAddressBinding import org.linphone.databinding.DialogRemoveAccountBinding import org.linphone.databinding.DialogRemoveAllCallLogsBinding @@ -292,6 +293,22 @@ class DialogUtils { return getDialog(context, binding) } + @UiThread + fun getOpenOrExportFileDialog( + context: Context, + viewModel: ConfirmationDialogModel + ): Dialog { + val binding: DialogOpenExportFileBinding = DataBindingUtil.inflate( + LayoutInflater.from(context), + R.layout.dialog_open_export_file, + null, + false + ) + binding.viewModel = viewModel + + return getDialog(context, binding) + } + @UiThread fun getUpdateAvailableDialog( context: Context, diff --git a/app/src/main/res/layout/dialog_open_export_file.xml b/app/src/main/res/layout/dialog_open_export_file.xml new file mode 100644 index 000000000..ffabd415d --- /dev/null +++ b/app/src/main/res/layout/dialog_open_export_file.xml @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 4999867f3..d3ee55750 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -437,6 +437,10 @@ Renommer la conversation Nom de la conversation Confirmer + Ouvrir ou sauvegarder le fichier ? + &appName; ne peut ouvrir ce fichier.\n\nVoulez-vous l\'ouvrir dans une autre app (si possible), ou le sauvegarder sur votre appareil ? + Ouvrir le fichier + Sauvegarder le fichier Le message a été supprimé Membres du groupe diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2a26d1a64..7e8d3df2e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -473,6 +473,10 @@ Edit conversation subject Conversation subject Confirm + Open or export file? + &appName; can\'t open this file.\n\nDo you want to open it in another app (if possible), or export it on your device? + Open file + Export file Message has been deleted Group members