Added dialog asking user whether to open or export file that app can't display

This commit is contained in:
Sylvain Berfini 2024-05-10 11:13:58 +02:00
parent 405596d291
commit d062910133
7 changed files with 307 additions and 52 deletions

View file

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

View file

@ -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<Event<String>>()
}
val showGreenToastEvent: MutableLiveData<Event<Pair<String, Int>>> by lazy {
MutableLiveData<Event<Pair<String, Int>>>()
}
val showRedToastEvent: MutableLiveData<Event<Pair<String, Int>>> by lazy {
MutableLiveData<Event<Pair<String, Int>>>()
}
@ -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)))
}
}
}
}
}

View file

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

View file

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

View file

@ -0,0 +1,104 @@
<?xml version="1.0" encoding="utf-8"?>
<layout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<import type="android.view.View" />
<import type="android.graphics.Typeface" />
<variable
name="viewModel"
type="org.linphone.ui.main.history.model.ConfirmationDialogModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:onClick="@{() -> viewModel.dismiss()}"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/dialog_background"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="12dp"
android:layout_marginEnd="12dp"
android:layout_marginBottom="2dp"
android:src="@drawable/shape_dialog_background"
android:contentDescription="@null"
app:layout_constraintWidth_max="@dimen/dialog_max_width"
app:layout_constraintBottom_toBottomOf="@id/anchor"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/title" />
<androidx.appcompat.widget.AppCompatTextView
style="@style/section_header_style"
android:id="@+id/title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="15dp"
android:paddingTop="@dimen/dialog_top_bottom_margin"
android:text="@string/conversation_dialog_open_or_export_file_title"
app:layout_constraintVertical_chainStyle="packed"
app:layout_constraintBottom_toTopOf="@id/message"
app:layout_constraintStart_toStartOf="@id/dialog_background"
app:layout_constraintEnd_toEndOf="@id/dialog_background"
app:layout_constraintTop_toTopOf="parent" />
<androidx.appcompat.widget.AppCompatTextView
style="@style/default_text_style"
android:id="@+id/message"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="15dp"
android:layout_marginEnd="15dp"
android:layout_marginTop="10dp"
android:text="@string/conversation_dialog_open_or_export_file_message"
android:textSize="14sp"
app:layout_constraintBottom_toTopOf="@id/cancel"
app:layout_constraintStart_toStartOf="@id/dialog_background"
app:layout_constraintEnd_toEndOf="@id/dialog_background"
app:layout_constraintTop_toBottomOf="@id/title" />
<androidx.appcompat.widget.AppCompatTextView
android:onClick="@{() -> viewModel.cancel()}"
style="@style/primary_button_label_style"
android:id="@+id/cancel"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:layout_marginStart="15dp"
android:layout_marginEnd="15dp"
android:text="@string/conversation_dialog_open_file_label"
app:layout_constraintStart_toStartOf="@id/dialog_background"
app:layout_constraintEnd_toEndOf="@id/dialog_background"
app:layout_constraintTop_toBottomOf="@id/message"
app:layout_constraintBottom_toTopOf="@id/confirm"/>
<androidx.appcompat.widget.AppCompatTextView
android:onClick="@{() -> viewModel.confirm()}"
style="@style/primary_button_label_style"
android:id="@+id/confirm"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginStart="15dp"
android:layout_marginEnd="15dp"
android:text="@string/conversation_dialog_export_file_label"
app:layout_constraintStart_toStartOf="@id/dialog_background"
app:layout_constraintEnd_toEndOf="@id/dialog_background"
app:layout_constraintTop_toBottomOf="@id/cancel"
app:layout_constraintBottom_toTopOf="@id/anchor"/>
<View
android:id="@+id/anchor"
android:layout_width="wrap_content"
android:layout_height="@dimen/dialog_top_bottom_margin"
app:layout_constraintTop_toBottomOf="@id/confirm"
app:layout_constraintStart_toStartOf="@id/dialog_background"
app:layout_constraintEnd_toEndOf="@id/dialog_background"
app:layout_constraintBottom_toBottomOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View file

@ -437,6 +437,10 @@
<string name="conversation_dialog_edit_subject">Renommer la conversation</string>
<string name="conversation_dialog_subject_hint">Nom de la conversation</string>
<string name="conversation_dialog_edit_subject_confirm_button">Confirmer</string>
<string name="conversation_dialog_open_or_export_file_title">Ouvrir ou sauvegarder le fichier ?</string>
<string name="conversation_dialog_open_or_export_file_message">&appName; ne peut ouvrir ce fichier.\n\nVoulez-vous l\'ouvrir dans une autre app (si possible), ou le sauvegarder sur votre appareil ?</string>
<string name="conversation_dialog_open_file_label">Ouvrir le fichier</string>
<string name="conversation_dialog_export_file_label">Sauvegarder le fichier</string>
<string name="conversation_message_deleted_toast">Le message a été supprimé</string>
<string name="conversation_info_participants_list_title">Membres du groupe</string>

View file

@ -473,6 +473,10 @@
<string name="conversation_dialog_edit_subject">Edit conversation subject</string>
<string name="conversation_dialog_subject_hint">Conversation subject</string>
<string name="conversation_dialog_edit_subject_confirm_button">Confirm</string>
<string name="conversation_dialog_open_or_export_file_title">Open or export file?</string>
<string name="conversation_dialog_open_or_export_file_message">&appName; can\'t open this file.\n\nDo you want to open it in another app (if possible), or export it on your device?</string>
<string name="conversation_dialog_open_file_label">Open file</string>
<string name="conversation_dialog_export_file_label">Export file</string>
<string name="conversation_message_deleted_toast">Message has been deleted</string>
<string name="conversation_info_participants_list_title">Group members</string>