Added plain text file viewer

This commit is contained in:
Sylvain Berfini 2023-12-13 15:17:30 +01:00
parent de2f247c5f
commit 5d3d8eeedc
12 changed files with 122 additions and 35 deletions

View file

@ -30,7 +30,6 @@ import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.graphics.Bitmap import android.graphics.Bitmap
import android.net.Uri import android.net.Uri
import android.webkit.MimeTypeMap
import androidx.annotation.AnyThread import androidx.annotation.AnyThread
import androidx.annotation.MainThread import androidx.annotation.MainThread
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
@ -749,7 +748,7 @@ class NotificationsManager @MainThread constructor(private val context: Context)
val filePath = contentUri.toString() val filePath = contentUri.toString()
val extension = FileUtils.getExtensionFromFileName(filePath) val extension = FileUtils.getExtensionFromFileName(filePath)
if (extension.isNotEmpty()) { if (extension.isNotEmpty()) {
val mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) val mime = FileUtils.getMimeTypeFromExtension(extension)
notifiableMessage.filePath = contentUri notifiableMessage.filePath = contentUri
notifiableMessage.fileMime = mime notifiableMessage.fileMime = mime
Log.i("$TAG Added file $contentUri with MIME $mime to notification") Log.i("$TAG Added file $contentUri with MIME $mime to notification")

View file

@ -28,7 +28,6 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.animation.Animation import android.view.animation.Animation
import android.view.animation.AnimationUtils import android.view.animation.AnimationUtils
import android.webkit.MimeTypeMap
import androidx.annotation.UiThread import androidx.annotation.UiThread
import androidx.core.view.doOnPreDraw import androidx.core.view.doOnPreDraw
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
@ -244,9 +243,9 @@ class ConversationsListFragment : AbstractTopBarFragment() {
if (findNavController().currentDestination?.id == R.id.conversationsListFragment) { if (findNavController().currentDestination?.id == R.id.conversationsListFragment) {
Log.i("$TAG Navigating to file viewer fragment with path [$path]") Log.i("$TAG Navigating to file viewer fragment with path [$path]")
val extension = FileUtils.getExtensionFromFileName(path) val extension = FileUtils.getExtensionFromFileName(path)
val mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) val mime = FileUtils.getMimeTypeFromExtension(extension)
when (FileUtils.getMimeType(mime)) { when (FileUtils.getMimeType(mime)) {
FileUtils.MimeType.Image, FileUtils.MimeType.Video, FileUtils.MimeType.Pdf -> { FileUtils.MimeType.Image, FileUtils.MimeType.Video, FileUtils.MimeType.Pdf, FileUtils.MimeType.PlainText -> {
val action = val action =
FileViewerFragmentDirections.actionGlobalFileViewerFragment(path) FileViewerFragmentDirections.actionGlobalFileViewerFragment(path)
findNavController().navigate(action) findNavController().navigate(action)

View file

@ -1,6 +1,5 @@
package org.linphone.ui.main.chat.model package org.linphone.ui.main.chat.model
import android.webkit.MimeTypeMap
import androidx.annotation.AnyThread import androidx.annotation.AnyThread
import androidx.annotation.UiThread import androidx.annotation.UiThread
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
@ -41,7 +40,7 @@ class FileModel @AnyThread constructor(
val extension = FileUtils.getExtensionFromFileName(file) val extension = FileUtils.getExtensionFromFileName(file)
isPdf = extension == "pdf" isPdf = extension == "pdf"
val mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) val mime = FileUtils.getMimeTypeFromExtension(extension)
mimeType = FileUtils.getMimeType(mime) mimeType = FileUtils.getMimeType(mime)
isImage = mimeType == FileUtils.MimeType.Image isImage = mimeType == FileUtils.MimeType.Image
isVideoPreview = mimeType == FileUtils.MimeType.Video isVideoPreview = mimeType == FileUtils.MimeType.Video

View file

@ -60,6 +60,7 @@ class FileViewerFragment : GenericFragment() {
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
postponeEnterTransition()
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
viewModel = ViewModelProvider(this)[FileViewModel::class.java] viewModel = ViewModelProvider(this)[FileViewModel::class.java]
@ -75,6 +76,21 @@ class FileViewerFragment : GenericFragment() {
goBack() goBack()
} }
viewModel.fileReadyEvent.observe(viewLifecycleOwner) {
it.consume { done ->
if (done) {
(view.parent as? ViewGroup)?.doOnPreDraw {
startPostponedEnterTransition()
}
} else {
(view.parent as? ViewGroup)?.doOnPreDraw {
Log.e("$TAG Failed to open file, going back")
goBack()
}
}
}
}
binding.setShareClickListener { binding.setShareClickListener {
lifecycleScope.launch { lifecycleScope.launch {
val filePath = FileUtils.getProperFilePath(path) val filePath = FileUtils.getProperFilePath(path)

View file

@ -8,14 +8,17 @@ import android.net.Uri
import android.os.Environment import android.os.Environment
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import android.provider.MediaStore import android.provider.MediaStore
import android.webkit.MimeTypeMap import android.text.PrecomputedText
import android.widget.ImageView import android.widget.ImageView
import androidx.annotation.UiThread import androidx.annotation.UiThread
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import java.io.BufferedReader
import java.io.File import java.io.File
import java.io.FileReader
import java.lang.IllegalStateException import java.lang.IllegalStateException
import java.lang.StringBuilder
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -50,6 +53,12 @@ class FileViewModel @UiThread constructor() : ViewModel() {
val isVideoPlaying = MutableLiveData<Boolean>() val isVideoPlaying = MutableLiveData<Boolean>()
val isText = MutableLiveData<Boolean>()
val text = MutableLiveData<String>()
val fileReadyEvent = MutableLiveData<Event<Boolean>>()
val pdfRendererReadyEvent: MutableLiveData<Event<Boolean>> by lazy { val pdfRendererReadyEvent: MutableLiveData<Event<Boolean>> by lazy {
MutableLiveData<Event<Boolean>>() MutableLiveData<Event<Boolean>>()
} }
@ -100,45 +109,34 @@ class FileViewModel @UiThread constructor() : ViewModel() {
fileName.value = name fileName.value = name
val extension = FileUtils.getExtensionFromFileName(name) val extension = FileUtils.getExtensionFromFileName(name)
val mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) val mime = FileUtils.getMimeTypeFromExtension(extension)
when (FileUtils.getMimeType(mime)) { when (FileUtils.getMimeType(mime)) {
FileUtils.MimeType.Pdf -> { FileUtils.MimeType.Pdf -> {
Log.i("$TAG File [$file] seems to be a PDF") Log.i("$TAG File [$file] seems to be a PDF")
isPdf.value = true loadPdf()
viewModelScope.launch {
withContext(Dispatchers.IO) {
val input = ParcelFileDescriptor.open(
File(file),
ParcelFileDescriptor.MODE_READ_ONLY
)
pdfRenderer = PdfRenderer(input)
val count = pdfRenderer.pageCount
Log.i("$TAG $count pages in file $file")
pdfPages.postValue(count.toString())
pdfCurrentPage.postValue("1")
pdfRendererReadyEvent.postValue(Event(true))
}
}
} }
FileUtils.MimeType.Image -> { FileUtils.MimeType.Image -> {
Log.i("$TAG File [$file] seems to be an image") Log.i("$TAG File [$file] seems to be an image")
isImage.value = true isImage.value = true
path.value = file path.value = file
fileReadyEvent.value = Event(true)
} }
FileUtils.MimeType.Video -> { FileUtils.MimeType.Video -> {
Log.i("$TAG File [$file] seems to be a video") Log.i("$TAG File [$file] seems to be a video")
isVideo.value = true isVideo.value = true
isVideoPlaying.value = false isVideoPlaying.value = false
fileReadyEvent.value = Event(true)
} }
FileUtils.MimeType.Audio -> { FileUtils.MimeType.Audio -> {
// TODO: handle audio files // TODO: handle audio files
fileReadyEvent.value = Event(true)
} }
FileUtils.MimeType.PlainText -> { FileUtils.MimeType.PlainText -> {
// TODO: handle plain text files Log.i("$TAG File [$file] seems to be plain text")
loadPlainText()
} }
else -> { else -> {
// TODO: open native app for unsupported files fileReadyEvent.value = Event(false)
} }
} }
} }
@ -261,6 +259,51 @@ class FileViewModel @UiThread constructor() : ViewModel() {
} }
} }
private fun loadPdf() {
isPdf.value = true
viewModelScope.launch {
withContext(Dispatchers.IO) {
val input = ParcelFileDescriptor.open(
File(filePath),
ParcelFileDescriptor.MODE_READ_ONLY
)
pdfRenderer = PdfRenderer(input)
val count = pdfRenderer.pageCount
Log.i("$TAG $count pages in file $filePath")
pdfPages.postValue(count.toString())
pdfCurrentPage.postValue("1")
pdfRendererReadyEvent.postValue(Event(true))
fileReadyEvent.postValue(Event(true))
}
}
}
private fun loadPlainText() {
isText.value = true
viewModelScope.launch {
withContext(Dispatchers.IO) {
try {
val br = BufferedReader(FileReader(filePath))
var line: String?
val textBuilder = StringBuilder()
while (br.readLine().also { line = it } != null) {
textBuilder.append(line)
textBuilder.append('\n')
}
br.close()
text.postValue(textBuilder.toString())
Log.i("$TAG Finished reading file [$filePath]")
fileReadyEvent.postValue(Event(true))
// TODO FIXME : improve performances !
} catch (e: Exception) {
Log.e("$TAG Exception trying to read file [$filePath] as text: $e")
}
}
}
}
@UiThread @UiThread
private suspend fun addContentToMediaStore( private suspend fun addContentToMediaStore(
path: String path: String
@ -285,7 +328,7 @@ class FileViewModel @UiThread constructor() : ViewModel() {
val relativePath = "$directory/$appName" val relativePath = "$directory/$appName"
val fileName = FileUtils.getNameFromFilePath(path) val fileName = FileUtils.getNameFromFilePath(path)
val extension = FileUtils.getExtensionFromFileName(fileName) val extension = FileUtils.getExtensionFromFileName(fileName)
val mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) val mime = FileUtils.getMimeTypeFromExtension(extension)
val context = coreContext.context val context = coreContext.context
val mediaStoreFilePath = when { val mediaStoreFilePath = when {

View file

@ -65,21 +65,21 @@ class FileUtils {
@AnyThread @AnyThread
fun isExtensionImage(path: String): Boolean { fun isExtensionImage(path: String): Boolean {
val extension = getExtensionFromFileName(path) val extension = getExtensionFromFileName(path)
val type = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) val type = getMimeTypeFromExtension(extension)
return getMimeType(type) == MimeType.Image return getMimeType(type) == MimeType.Image
} }
@AnyThread @AnyThread
fun isExtensionVideo(path: String): Boolean { fun isExtensionVideo(path: String): Boolean {
val extension = getExtensionFromFileName(path) val extension = getExtensionFromFileName(path)
val type = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) val type = getMimeTypeFromExtension(extension)
return getMimeType(type) == MimeType.Video return getMimeType(type) == MimeType.Video
} }
@AnyThread @AnyThread
fun isExtensionAudio(path: String): Boolean { fun isExtensionAudio(path: String): Boolean {
val extension = getExtensionFromFileName(path) val extension = getExtensionFromFileName(path)
val type = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) val type = getMimeTypeFromExtension(extension)
return getMimeType(type) == MimeType.Audio return getMimeType(type) == MimeType.Audio
} }
@ -96,12 +96,18 @@ class FileUtils {
return extension.lowercase(Locale.getDefault()) return extension.lowercase(Locale.getDefault())
} }
@AnyThread
fun getMimeTypeFromExtension(extension: String): String {
return MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) ?: "file/$extension"
}
@AnyThread @AnyThread
fun getMimeType(type: String?): MimeType { fun getMimeType(type: String?): MimeType {
if (type.isNullOrEmpty()) return MimeType.Unknown if (type.isNullOrEmpty()) return MimeType.Unknown
return when { return when {
type.startsWith("image/") -> MimeType.Image type.startsWith("image/") -> MimeType.Image
type.startsWith("text/plain") -> MimeType.PlainText type.startsWith("text/") -> MimeType.PlainText
type.endsWith("/log") -> MimeType.PlainText
type.startsWith("video/") -> MimeType.Video type.startsWith("video/") -> MimeType.Video
type.startsWith("audio/") -> MimeType.Audio type.startsWith("audio/") -> MimeType.Audio
type.startsWith("application/pdf") -> MimeType.Pdf type.startsWith("application/pdf") -> MimeType.Pdf
@ -220,7 +226,7 @@ class FileUtils {
} }
val extension = getExtensionFromFileName(name) val extension = getExtensionFromFileName(name)
val type = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) val type = getMimeTypeFromExtension(extension)
val isImage = getMimeType(type) == MimeType.Image val isImage = getMimeType(type) == MimeType.Image
try { try {

View file

@ -190,7 +190,7 @@
android:layout_height="@dimen/chat_bubble_big_image_max_size" android:layout_height="@dimen/chat_bubble_big_image_max_size"
android:adjustViewBounds="true" android:adjustViewBounds="true"
android:scaleType="fitCenter" android:scaleType="fitCenter"
android:visibility="@{model.filesList.size() == 1 &amp;&amp; model.firstImagePath.length() >= 0 ? View.VISIBLE : View.GONE, default=gone}" android:visibility="@{model.filesList.size() == 1 &amp;&amp; model.firstImagePath.length() > 0 ? View.VISIBLE : View.GONE, default=gone}"
coilBubble="@{model.firstImagePath}"/> coilBubble="@{model.firstImagePath}"/>
<ViewStub <ViewStub

View file

@ -153,7 +153,7 @@
android:layout_height="@dimen/chat_bubble_big_image_max_size" android:layout_height="@dimen/chat_bubble_big_image_max_size"
android:adjustViewBounds="true" android:adjustViewBounds="true"
android:scaleType="fitCenter" android:scaleType="fitCenter"
android:visibility="@{model.filesList.size() == 1 &amp;&amp; model.firstImagePath.length() >= 0 ? View.VISIBLE : View.GONE, default=gone}" android:visibility="@{model.filesList.size() == 1 &amp;&amp; model.firstImagePath.length() > 0 ? View.VISIBLE : View.GONE, default=gone}"
coilBubble="@{model.firstImagePath}"/> coilBubble="@{model.firstImagePath}"/>
<ViewStub <ViewStub

View file

@ -82,6 +82,27 @@
coilFile="@{viewModel.path}" coilFile="@{viewModel.path}"
android:visibility="@{viewModel.isImage ? View.VISIBLE : View.GONE, default=gone}" /> android:visibility="@{viewModel.isImage ? View.VISIBLE : View.GONE, default=gone}" />
<ScrollView
android:id="@+id/text"
android:background="?attr/color_main2_000"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="@{viewModel.isText ? View.VISIBLE : View.GONE, default=gone}" >
<TextView
style="@style/default_text_style"
android:onClick="@{() -> viewModel.toggleFullScreen()}"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:layout_marginEnd="10dp"
android:singleLine="false"
android:textIsSelectable="true"
android:textColor="?attr/color_main2_900"
android:text="@{viewModel.text}"/>
</ScrollView>
<ImageView <ImageView
android:id="@+id/back" android:id="@+id/back"
android:onClick="@{backClickListener}" android:onClick="@{backClickListener}"
@ -159,4 +180,5 @@
app:layout_constraintEnd_toEndOf="parent"/> app:layout_constraintEnd_toEndOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>
</layout> </layout>

View file

@ -28,6 +28,7 @@
<item name="color_main2_600">@color/gray_main2_400</item> <item name="color_main2_600">@color/gray_main2_400</item>
<item name="color_main2_700">@color/gray_main2_300</item> <item name="color_main2_700">@color/gray_main2_300</item>
<item name="color_main2_800">@color/gray_main2_200</item> <item name="color_main2_800">@color/gray_main2_200</item>
<item name="color_main2_900">@color/white</item>
<item name="color_grey_100">@color/gray_900</item> <item name="color_grey_100">@color/gray_900</item>
<item name="color_grey_200">@color/gray_800</item> <item name="color_grey_200">@color/gray_800</item>

View file

@ -25,6 +25,7 @@
<attr name="color_main2_600" format="color"/> <attr name="color_main2_600" format="color"/>
<attr name="color_main2_700" format="color"/> <attr name="color_main2_700" format="color"/>
<attr name="color_main2_800" format="color"/> <attr name="color_main2_800" format="color"/>
<attr name="color_main2_900" format="color"/>
<attr name="color_grey_100" format="color"/> <attr name="color_grey_100" format="color"/>
<attr name="color_grey_200" format="color"/> <attr name="color_grey_200" format="color"/>

View file

@ -29,6 +29,7 @@
<item name="color_main2_600">@color/gray_main2_600</item> <item name="color_main2_600">@color/gray_main2_600</item>
<item name="color_main2_700">@color/gray_main2_700</item> <item name="color_main2_700">@color/gray_main2_700</item>
<item name="color_main2_800">@color/gray_main2_800</item> <item name="color_main2_800">@color/gray_main2_800</item>
<item name="color_main2_900">@color/black</item>
<item name="color_grey_100">@color/gray_100</item> <item name="color_grey_100">@color/gray_100</item>
<item name="color_grey_200">@color/gray_200</item> <item name="color_grey_200">@color/gray_200</item>