Let user try to open file as plain text if no app on the device is registered to handle MIME type (useful for logs file without extension)

This commit is contained in:
Sylvain Berfini 2025-01-16 10:45:20 +01:00
parent 375c020b9b
commit e85c97837f
10 changed files with 225 additions and 22 deletions

View file

@ -84,6 +84,14 @@ class FileViewerActivity : GenericActivity() {
finish()
}
viewModel.showRedToastEvent.observe(this) {
it.consume { pair ->
val message = getString(pair.first)
val icon = pair.second
showRedToast(message, icon)
}
}
viewModel.fileReadyEvent.observe(this) {
it.consume { done ->
if (!done) {

View file

@ -138,6 +138,10 @@ class FileViewModel
Log.d("$TAG File [$file] seems to be plain text")
loadPlainText()
}
FileUtils.MimeType.Unknown -> {
Log.w("$TAG Unknown MIME type for file at [$file], opening it as plain text")
loadPlainText()
}
else -> {
Log.e("$TAG Unexpected MIME type [$mimeType] for file at [$file] with extension [$extension]")
fileReadyEvent.value = Event(false)
@ -333,6 +337,14 @@ class FileViewModel
// TODO FIXME : improve performances !
} catch (e: Exception) {
Log.e("$TAG Exception trying to read file [$filePath] as text: $e")
showRedToastEvent.postValue(
Event(
Pair(
R.string.conversation_file_cant_be_opened_error_toast,
R.drawable.warning_circle
)
)
)
}
}
}

View file

@ -31,14 +31,14 @@ import androidx.lifecycle.ViewModelProvider
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.LinearLayoutManager
import org.linphone.R
import org.linphone.core.tools.Log
import org.linphone.databinding.ChatDocumentsFragmentBinding
import org.linphone.ui.GenericActivity
import org.linphone.ui.main.chat.adapter.ConversationsFilesAdapter
import org.linphone.ui.main.chat.model.FileModel
import org.linphone.ui.main.chat.viewmodel.ConversationDocumentsListViewModel
import org.linphone.ui.main.fragment.SlidingPaneChildFragment
import org.linphone.utils.ConfirmationDialogModel
import org.linphone.utils.DialogUtils
import org.linphone.utils.Event
import org.linphone.utils.FileUtils
import org.linphone.utils.RecyclerViewHeaderDecoration
@ -149,10 +149,10 @@ class ConversationDocumentsListFragment : SlidingPaneChildFragment() {
putBoolean("isEncrypted", fileModel.isEncrypted)
putLong("timestamp", fileModel.fileCreationTimestamp)
putString("originalPath", fileModel.originalPath)
putBoolean("isMedia", false)
}
when (FileUtils.getMimeType(mime)) {
FileUtils.MimeType.Pdf, FileUtils.MimeType.PlainText -> {
bundle.putBoolean("isMedia", false)
sharedViewModel.displayFileEvent.value = Event(bundle)
}
else -> {
@ -165,13 +165,38 @@ class ConversationDocumentsListFragment : SlidingPaneChildFragment() {
requireContext().startActivity(intent)
} catch (anfe: ActivityNotFoundException) {
Log.e("$TAG Can't open file [$path] in third party app: $anfe")
val message = getString(
R.string.conversation_no_app_registered_to_handle_content_type_error_toast
)
val icon = R.drawable.file
(requireActivity() as GenericActivity).showRedToast(message, icon)
showOpenAsPlainTextDialog(bundle)
}
}
}
}
private fun showOpenAsPlainTextDialog(bundle: Bundle) {
val model = ConfirmationDialogModel()
val dialog = DialogUtils.getOpenAsPlainTextDialog(
requireActivity(),
model
)
model.dismissEvent.observe(viewLifecycleOwner) {
it.consume {
dialog.dismiss()
}
}
model.cancelEvent.observe(viewLifecycleOwner) {
it.consume {
dialog.dismiss()
}
}
model.confirmEvent.observe(viewLifecycleOwner) {
it.consume {
sharedViewModel.displayFileEvent.value = Event(bundle)
dialog.dismiss()
}
}
dialog.show()
}
}

View file

@ -69,7 +69,6 @@ import org.linphone.core.tools.Log
import org.linphone.databinding.ChatConversationFragmentBinding
import org.linphone.databinding.ChatConversationPopupMenuBinding
import org.linphone.ui.GenericActivity
import org.linphone.ui.main.MainActivity
import org.linphone.ui.main.chat.ConversationScrollListener
import org.linphone.ui.main.chat.adapter.ConversationEventAdapter
import org.linphone.ui.main.chat.adapter.MessageBottomSheetAdapter
@ -1093,7 +1092,11 @@ open class ConversationFragment : SlidingPaneChildFragment() {
val extension = FileUtils.getExtensionFromFileName(path)
val mime = FileUtils.getMimeTypeFromExtension(extension)
val mimeType = FileUtils.getMimeType(mime)
Log.i("$TAG Extension for file [$path] is [$extension], associated MIME type is [$mimeType]")
if (mimeType == FileUtils.MimeType.Unknown && extension.contains("/")) {
Log.w("$TAG Slash character found in 'extension' [$extension] deduced from file path [$path]; MIME type will be Unknown")
} else {
Log.i("$TAG Extension for file [$path] is [$extension], associated MIME type is [$mimeType]")
}
val bundle = Bundle()
bundle.apply {
@ -1114,7 +1117,8 @@ open class ConversationFragment : SlidingPaneChildFragment() {
sharedViewModel.displayFileEvent.value = Event(bundle)
}
else -> {
showOpenOrExportFileDialog(path, mime)
bundle.putBoolean("isMedia", false)
showOpenOrExportFileDialog(path, mime, bundle)
}
}
}
@ -1413,7 +1417,7 @@ open class ConversationFragment : SlidingPaneChildFragment() {
bottomSheetDialog = unsafeConversationDetailsBottomSheet
}
private fun showOpenOrExportFileDialog(path: String, mime: String) {
private fun showOpenOrExportFileDialog(path: String, mime: String, bundle: Bundle) {
val model = ConfirmationDialogModel()
val dialog = DialogUtils.getOpenOrExportFileDialog(
requireActivity(),
@ -1428,7 +1432,7 @@ open class ConversationFragment : SlidingPaneChildFragment() {
model.cancelEvent.observe(viewLifecycleOwner) {
it.consume {
openFileInAnotherApp(path, mime)
openFileInAnotherApp(path, mime, bundle)
dialog.dismiss()
}
}
@ -1466,7 +1470,7 @@ open class ConversationFragment : SlidingPaneChildFragment() {
dialog.show()
}
private fun openFileInAnotherApp(path: String, mime: String) {
private fun openFileInAnotherApp(path: String, mime: String, bundle: Bundle) {
val intent = Intent(Intent.ACTION_VIEW)
val contentUri: Uri =
FileUtils.getPublicFilePath(requireContext(), path)
@ -1477,14 +1481,39 @@ open class ConversationFragment : SlidingPaneChildFragment() {
requireContext().startActivity(intent)
} catch (anfe: ActivityNotFoundException) {
Log.e("$TAG Can't open file [$path] in third party app: $anfe")
val message = getString(
R.string.conversation_no_app_registered_to_handle_content_type_error_toast
)
val icon = R.drawable.file
(requireActivity() as MainActivity).showRedToast(message, icon)
showOpenAsPlainTextDialog(bundle)
}
}
private fun showOpenAsPlainTextDialog(bundle: Bundle) {
val model = ConfirmationDialogModel()
val dialog = DialogUtils.getOpenAsPlainTextDialog(
requireActivity(),
model
)
model.dismissEvent.observe(viewLifecycleOwner) {
it.consume {
dialog.dismiss()
}
}
model.cancelEvent.observe(viewLifecycleOwner) {
it.consume {
dialog.dismiss()
}
}
model.confirmEvent.observe(viewLifecycleOwner) {
it.consume {
sharedViewModel.displayFileEvent.value = Event(bundle)
dialog.dismiss()
}
}
dialog.show()
}
private fun exportFile(path: String, mime: String) {
filePathToExport = path

View file

@ -178,10 +178,10 @@ class ConversationMediaListFragment : SlidingPaneChildFragment() {
putBoolean("isEncrypted", fileModel.isEncrypted)
putLong("timestamp", fileModel.fileCreationTimestamp)
putString("originalPath", fileModel.originalPath)
putBoolean("isMedia", true)
}
when (FileUtils.getMimeType(mime)) {
FileUtils.MimeType.Image, FileUtils.MimeType.Video, FileUtils.MimeType.Audio -> {
bundle.putBoolean("isMedia", true)
sharedViewModel.displayFileEvent.value = Event(bundle)
}
else -> {

View file

@ -47,6 +47,7 @@ import org.linphone.databinding.DialogKickFromConferenceBinding
import org.linphone.databinding.DialogManageAccountInternationalPrefixHelpBinding
import org.linphone.databinding.DialogMergeCallsIntoConferenceBinding
import org.linphone.databinding.DialogOpenExportFileBinding
import org.linphone.databinding.DialogOpenPlainTextBinding
import org.linphone.databinding.DialogPickNumberOrAddressBinding
import org.linphone.databinding.DialogRemoveAccountBinding
import org.linphone.databinding.DialogRemoveAllCallLogsBinding
@ -343,6 +344,22 @@ class DialogUtils {
return getDialog(context, binding)
}
@UiThread
fun getOpenAsPlainTextDialog(
context: Context,
viewModel: ConfirmationDialogModel
): Dialog {
val binding: DialogOpenPlainTextBinding = DataBindingUtil.inflate(
LayoutInflater.from(context),
R.layout.dialog_open_plain_text,
null,
false
)
binding.viewModel = viewModel
return getDialog(context, binding)
}
@UiThread
fun getUpdateAvailableDialog(
context: Context,

View file

@ -115,8 +115,9 @@ class FileUtils {
type.endsWith("/log") -> MimeType.PlainText
type.startsWith("video/") -> MimeType.Video
type.startsWith("audio/") -> MimeType.Audio
type.startsWith("application/pdf") -> MimeType.Pdf
type.startsWith("application/json") -> MimeType.PlainText
type == "application/pdf" -> MimeType.Pdf
type == "application/json" -> MimeType.PlainText
type == "application/xml" -> MimeType.PlainText
else -> MimeType.Unknown
}
Log.d("$TAG MIME type for [$type] is [$mime]")

View file

@ -0,0 +1,103 @@
<?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" />
<variable
name="viewModel"
type="org.linphone.utils.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_no_app_registered_to_handle_content_type_dialog_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_no_app_registered_to_handle_content_type_dialog_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/secondary_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/dialog_cancel"
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_open_plain_text_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

@ -463,6 +463,9 @@
<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_no_app_registered_to_handle_content_type_dialog_title">Ouvrir comme texte brut ?</string>
<string name="conversation_no_app_registered_to_handle_content_type_dialog_message">Aucune application trouvée pour lire ce fichier.\n\nVoulez-vous essayer de l\'ouvrir en tant que texte brut ?</string>
<string name="conversation_dialog_open_plain_text_label">Ouvrir comme texte brut</string>
<string name="conversation_failed_to_play_voice_recording_message">Impossible de lire le message vocal !</string>
<string name="conversation_message_deleted_toast">Message supprimé</string>
<string name="conversation_failed_to_create_toast">Échec de création de la conversation !</string>
@ -483,6 +486,7 @@
<string name="conversation_take_picture_label">Prendre une photo</string>
<string name="conversation_pick_file_from_gallery_label">Ouvrir la gallerie</string>
<string name="conversation_pick_any_file_label">Choisir un fichier</string>
<string name="conversation_file_cant_be_opened_error_toast">Impossible d\'ouvrir le fichier!</string>
<string name="conversation_info_participants_list_title">Participants (%s)</string>
<string name="conversation_info_add_participants_label">Ajouter des participants</string>

View file

@ -503,6 +503,9 @@
<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_no_app_registered_to_handle_content_type_dialog_title">Open as plain text?</string>
<string name="conversation_no_app_registered_to_handle_content_type_dialog_message">No app found to open this kind of file.\n\nWould you like to try opening it as plain text?</string>
<string name="conversation_dialog_open_plain_text_label">Open as plain text</string>
<string name="conversation_failed_to_play_voice_recording_message">Voice recording cannot be played!</string>
<string name="conversation_message_deleted_toast">Message has been deleted</string>
<string name="conversation_failed_to_create_toast">Failed to create conversation!</string>
@ -523,6 +526,7 @@
<string name="conversation_take_picture_label">Take picture</string>
<string name="conversation_pick_file_from_gallery_label">Open gallery</string>
<string name="conversation_pick_any_file_label">Pick file</string>
<string name="conversation_file_cant_be_opened_error_toast">File can\'t be opened!</string>
<string name="conversation_info_participants_list_title">Group members (%s)</string>
<string name="conversation_info_add_participants_label">Add participants</string>