Added back auto export media to native gallery feature

This commit is contained in:
Sylvain Berfini 2025-02-18 11:31:03 +01:00
parent 17511a4c26
commit 875198164d
21 changed files with 208 additions and 13 deletions

View file

@ -37,6 +37,7 @@ Group changes to describe their impact on the project, as follows:
- If next message is also a voice recording, playback will automatically start after the currently playing one ends.
- Chat while in call: a shortcut to a conversation screen with the remote.
- Chat while in a conference: if the conference has a text stream enabled, you can chat with the other participants of the conference while it lasts. At the end, you'll find the messages history in the call history (and not in the list of conversations).
- Auto export of media to native gallery even when auto download is enabled (but still not if VFS is enabled nor for ephemeral messages).
- Notification showing upload/download of files shared through chat will let user know the progress and keep the app alive during that process.
- Screen sharing in conference: only desktop app starting with 6.0 version is able to start it, but on mobiles you'll be able to see it.
- You can choose whatever ringtone you'd like for incoming calls (in Android notification channel settings).

View file

@ -46,6 +46,7 @@ import org.linphone.ui.call.CallActivity
import org.linphone.utils.ActivityMonitor
import org.linphone.utils.AppUtils
import org.linphone.utils.Event
import org.linphone.utils.FileUtils
import org.linphone.utils.LinphoneUtils
class CoreContext
@ -112,6 +113,11 @@ class CoreContext
MutableLiveData<Event<Boolean>>()
}
private var filesToExportToNativeMediaGallery = arrayListOf<String>()
val filesToExportToNativeMediaGalleryEvent: MutableLiveData<Event<List<String>>> by lazy {
MutableLiveData<Event<List<String>>>()
}
@SuppressLint("HandlerLeak")
private lateinit var coreThread: Handler
@ -155,6 +161,42 @@ class CoreContext
private var previousCallState = Call.State.Idle
private val coreListener = object : CoreListenerStub() {
@WorkerThread
override fun onMessagesReceived(
core: Core,
chatRoom: ChatRoom,
messages: Array<out ChatMessage?>
) {
if (corePreferences.makePublicMediaFilesDownloaded && core.maxSizeForAutoDownloadIncomingFiles >= 0) {
for (message in messages) {
// Never do auto media export for ephemeral messages!
if (message?.isEphemeral == true) continue
for (content in message?.contents.orEmpty()) {
if (content.isFile) {
val path = content.filePath
if (path.isNullOrEmpty()) continue
val mime = "${content.type}/${content.subtype}"
val mimeType = FileUtils.getMimeType(mime)
when (mimeType) {
FileUtils.MimeType.Image, FileUtils.MimeType.Video, FileUtils.MimeType.Audio -> {
Log.i("$TAG Added file path [$path] to the list of media to export to native media gallery")
filesToExportToNativeMediaGallery.add(path)
}
else -> {}
}
}
}
}
}
if (filesToExportToNativeMediaGallery.isNotEmpty()) {
Log.i("$TAG Creating event with [${filesToExportToNativeMediaGallery.size}] files to export to native media gallery")
filesToExportToNativeMediaGalleryEvent.postValue(Event(filesToExportToNativeMediaGallery))
}
}
@WorkerThread
override fun onGlobalStateChanged(core: Core, state: GlobalState, message: String) {
Log.i("$TAG Global state changed [$state]")
@ -628,6 +670,11 @@ class CoreContext
return found != null
}
@WorkerThread
fun clearFilesToExportToNativeGallery() {
filesToExportToNativeMediaGallery.clear()
}
@WorkerThread
fun startAudioCall(
address: Address,

View file

@ -149,6 +149,13 @@ class CorePreferences
config.setBool("app", "mark_as_read_notif_dismissal", value)
}
var makePublicMediaFilesDownloaded: Boolean
// Keep old name for backward compatibility
get() = config.getBool("app", "make_downloaded_images_public_in_gallery", false)
set(value) {
config.setBool("app", "make_downloaded_images_public_in_gallery", value)
}
// Conference related
@get:WorkerThread @set:WorkerThread

View file

@ -27,6 +27,7 @@ import android.util.Base64
import android.util.Pair
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import org.linphone.LinphoneApplication.Companion.corePreferences
import java.math.BigInteger
import java.nio.charset.StandardCharsets
import java.security.KeyStore
@ -73,6 +74,12 @@ class VFS {
}
preferences.edit().putBoolean("vfs_enabled", true).apply()
if (corePreferences.makePublicMediaFilesDownloaded) {
Log.w("$TAG VFS is now enabled, disabling auto export of media files to native gallery")
corePreferences.makePublicMediaFilesDownloaded = false
}
return true
}

View file

@ -99,7 +99,8 @@ class MediaViewerActivity : GenericActivity() {
val timestamp = args.getLong("timestamp", -1)
val isEncrypted = args.getBoolean("isEncrypted", false)
val originalPath = args.getString("originalPath", "")
viewModel.initTempModel(path, timestamp, isEncrypted, originalPath)
val isFromEphemeralMessage = args.getBoolean("isFromEphemeralMessage", false)
viewModel.initTempModel(path, timestamp, isEncrypted, originalPath, isFromEphemeralMessage)
val conversationId = args.getString("conversationId").orEmpty()
Log.i(
@ -183,6 +184,12 @@ class MediaViewerActivity : GenericActivity() {
val currentItem = binding.mediaViewPager.currentItem
val model = if (currentItem >= 0 && currentItem < list.size) list[currentItem] else null
if (model != null) {
// Never do auto media export for ephemeral messages!
if (model.isFromEphemeralMessage) {
Log.e("$TAG Do not export media from ephemeral message!")
return
}
val filePath = model.path
lifecycleScope.launch {
withContext(Dispatchers.IO) {

View file

@ -57,9 +57,9 @@ class MediaListViewModel
}
@UiThread
fun initTempModel(path: String, timestamp: Long, isEncrypted: Boolean, originalPath: String) {
fun initTempModel(path: String, timestamp: Long, isEncrypted: Boolean, originalPath: String, isFromEphemeralMessage: Boolean) {
val name = FileUtils.getNameFromFilePath(path)
val model = FileModel(path, name, 0, timestamp, isEncrypted, originalPath)
val model = FileModel(path, name, 0, timestamp, isEncrypted, originalPath, isFromEphemeralMessage)
temporaryModel = model
Log.i("$TAG Temporary model for file [$name] created, use it while other media for conversation are being loaded")
mediaList.postValue(arrayListOf(model))
@ -98,7 +98,10 @@ class MediaListViewModel
val size = mediaContent.size.toLong()
val timestamp = mediaContent.creationTimestamp
if (path.isNotEmpty() && name.isNotEmpty()) {
val model = FileModel(path, name, size, timestamp, isEncrypted, originalPath)
// TODO FIXME: we don't have the ephemeral info at Content level, using the chatRoom info even if content ephemeral status may or may not be different...
val ephemeral = chatRoom.isEphemeralEnabled
val model = FileModel(path, name, size, timestamp, isEncrypted, originalPath, ephemeral)
list.add(model)
}

View file

@ -52,9 +52,11 @@ import androidx.navigation.NavOptions
import androidx.navigation.findNavController
import kotlin.math.max
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.LinphoneApplication.Companion.corePreferences
import org.linphone.R
@ -313,6 +315,19 @@ class MainActivity : GenericActivity() {
}
}
coreContext.filesToExportToNativeMediaGalleryEvent.observe(this) {
it.consume { files ->
Log.i("$TAG Found [${files.size}] files to export to native media gallery")
for (file in files) {
exportFileToNativeMediaGallery(file)
}
coreContext.postOnCoreThread {
coreContext.clearFilesToExportToNativeGallery()
}
}
}
CarConnection(this).type.observe(this) {
val asString = when (it) {
CarConnection.CONNECTION_TYPE_NOT_CONNECTED -> "NOT CONNECTED"
@ -777,4 +792,18 @@ class MainActivity : GenericActivity() {
dialog.show()
currentlyDisplayedAuthDialog = dialog
}
private fun exportFileToNativeMediaGallery(filePath: String) {
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")
} else {
Log.e("$TAG Failed to export file [$filePath] to MediaStore!")
}
}
}
}
}

View file

@ -145,6 +145,7 @@ class ConversationDocumentsListFragment : SlidingPaneChildFragment() {
putBoolean("isEncrypted", fileModel.isEncrypted)
putLong("timestamp", fileModel.fileCreationTimestamp)
putString("originalPath", fileModel.originalPath)
putBoolean("isFromEphemeralMessage", fileModel.isFromEphemeralMessage)
putBoolean("isMedia", false)
}
when (FileUtils.getMimeType(mime)) {

View file

@ -1114,6 +1114,7 @@ open class ConversationFragment : SlidingPaneChildFragment() {
putBoolean("isEncrypted", fileModel.isEncrypted)
putLong("timestamp", fileModel.fileCreationTimestamp)
putString("originalPath", fileModel.originalPath)
putBoolean("isFromEphemeralMessage", fileModel.isFromEphemeralMessage)
}
when (mimeType) {
FileUtils.MimeType.Image, FileUtils.MimeType.Video, FileUtils.MimeType.Audio -> {

View file

@ -174,6 +174,7 @@ class ConversationMediaListFragment : SlidingPaneChildFragment() {
putBoolean("isEncrypted", fileModel.isEncrypted)
putLong("timestamp", fileModel.fileCreationTimestamp)
putString("originalPath", fileModel.originalPath)
putBoolean("isFromEphemeralMessage", fileModel.isFromEphemeralMessage)
putBoolean("isMedia", true)
}
when (FileUtils.getMimeType(mime)) {

View file

@ -38,7 +38,8 @@ class EventLogModel
onWebUrlClicked: ((url: String) -> Unit)? = null,
onContactClicked: ((friendRefKey: String) -> Unit)? = null,
onRedToastToShow: ((pair: Pair<Int, Int>) -> Unit)? = null,
onVoiceRecordingPlaybackEnded: ((id: String) -> Unit)? = null
onVoiceRecordingPlaybackEnded: ((id: String) -> Unit)? = null,
onFileToExportToNativeGallery: ((path: String) -> Unit)? = null
) {
companion object {
private const val TAG = "[Event Log Model]"
@ -89,7 +90,8 @@ class EventLogModel
onWebUrlClicked,
onContactClicked,
onRedToastToShow,
onVoiceRecordingPlaybackEnded
onVoiceRecordingPlaybackEnded,
onFileToExportToNativeGallery
)
}

View file

@ -45,6 +45,7 @@ class FileModel
val fileCreationTimestamp: Long,
val isEncrypted: Boolean,
val originalPath: String,
val isFromEphemeralMessage: Boolean,
val isWaitingToBeDownloaded: Boolean = false,
val flexboxLayoutWrapBefore: Boolean = false,
private val onClicked: ((model: FileModel) -> Unit)? = null

View file

@ -42,6 +42,7 @@ import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.LinphoneApplication.Companion.corePreferences
import org.linphone.R
import org.linphone.core.Address
import org.linphone.core.ChatMessage
@ -82,7 +83,8 @@ class MessageModel
private val onWebUrlClicked: ((url: String) -> Unit)? = null,
private val onContactClicked: ((friendRefKey: String) -> Unit)? = null,
private val onRedToastToShow: ((pair: Pair<Int, Int>) -> Unit)? = null,
private val onVoiceRecordingPlaybackEnded: ((id: String) -> Unit)? = null
private val onVoiceRecordingPlaybackEnded: ((id: String) -> Unit)? = null,
private val onFileToExportToNativeGallery: ((path: String) -> Unit)? = null
) {
companion object {
private const val TAG = "[Message Model]"
@ -224,6 +226,27 @@ class MessageModel
}
}
@WorkerThread
override fun onFileTransferTerminated(message: ChatMessage, content: Content) {
Log.i("$TAG File [${content.name}] from message [${message.messageId}] transfer terminated")
// Never do auto media export for ephemeral messages!
if (corePreferences.makePublicMediaFilesDownloaded && !message.isEphemeral) {
val path = content.filePath
if (path.isNullOrEmpty()) return
val mime = "${content.type}/${content.subtype}"
val mimeType = FileUtils.getMimeType(mime)
when (mimeType) {
FileUtils.MimeType.Image, FileUtils.MimeType.Video, FileUtils.MimeType.Audio -> {
Log.i("$TAG Exporting file path [$path] to the native media gallery")
onFileToExportToNativeGallery?.invoke(path)
}
else -> {}
}
}
}
@WorkerThread
override fun onNewMessageReaction(message: ChatMessage, reaction: ChatMessageReaction) {
Log.i(
@ -443,6 +466,7 @@ class MessageModel
timestamp,
isFileEncrypted,
originalPath,
chatMessage.isEphemeral,
flexboxLayoutWrapBefore = wrapBefore
) { model ->
onContentClicked?.invoke(model)
@ -470,7 +494,8 @@ class MessageModel
content.fileSize.toLong(),
timestamp,
isFileEncrypted,
path
path,
chatMessage.isEphemeral
) { model ->
onContentClicked?.invoke(model)
}
@ -482,6 +507,7 @@ class MessageModel
timestamp,
isFileEncrypted,
name,
chatMessage.isEphemeral,
isWaitingToBeDownloaded = true
) { model ->
downloadContent(model, content)

View file

@ -79,7 +79,11 @@ class ConversationDocumentsListViewModel
val size = documentContent.size.toLong()
val timestamp = documentContent.creationTimestamp
if (path.isNotEmpty() && name.isNotEmpty()) {
val model = FileModel(path, name, size, timestamp, isEncrypted, originalPath) {
// TODO FIXME: we don't have the ephemeral info at Content level, using the chatRoom info even if content ephemeral status may or may not be different...
val ephemeral = chatRoom.isEphemeralEnabled
val model =
FileModel(path, name, size, timestamp, isEncrypted, originalPath, ephemeral) {
openDocumentEvent.postValue(Event(it))
}
list.add(model)

View file

@ -78,7 +78,8 @@ class ConversationMediaListViewModel
val size = mediaContent.size.toLong()
val timestamp = mediaContent.creationTimestamp
if (path.isNotEmpty() && name.isNotEmpty()) {
val model = FileModel(path, name, size, timestamp, isEncrypted, originalPath) {
val model =
FileModel(path, name, size, timestamp, isEncrypted, originalPath, chatRoom.isEphemeralEnabled) {
openMediaEvent.postValue(Event(it))
}
list.add(model)

View file

@ -761,6 +761,19 @@ class ConversationViewModel
},
{ id ->
voiceRecordPlaybackEndedEvent.postValue(Event(id))
},
{ filePath ->
viewModelScope.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")
} else {
Log.e("$TAG Failed to export file [$filePath] to MediaStore!")
}
}
}
}
)
eventsList.add(model)

View file

@ -397,7 +397,7 @@ class SendMessageInConversationViewModel
val fileName = FileUtils.getNameFromFilePath(file)
val timestamp = System.currentTimeMillis() / 1000
val model = FileModel(file, fileName, 0, timestamp, false, file) { model ->
val model = FileModel(file, fileName, 0, timestamp, false, file, chatRoom.isEphemeralEnabled) { model ->
removeAttachment(model.path)
}

View file

@ -90,6 +90,8 @@ class SettingsViewModel
val autoDownloadEnabled = MutableLiveData<Boolean>()
val autoExportMediaToNativeGallery = MutableLiveData<Boolean>()
val markAsReadWhenDismissingNotification = MutableLiveData<Boolean>()
// Contacts settings
@ -246,7 +248,8 @@ class SettingsViewModel
expandAudioCodecs.value = false
expandVideoCodecs.value = false
isVfsEnabled.value = VFS.isEnabled(coreContext.context)
val vfsEnabled = VFS.isEnabled(coreContext.context)
isVfsEnabled.value = vfsEnabled
val vibrator = coreContext.context.getSystemService(Vibrator::class.java)
isVibrationAvailable.value = vibrator.hasVibrator()
@ -285,6 +288,7 @@ class SettingsViewModel
allowIpv6.postValue(core.isIpv6Enabled)
autoDownloadEnabled.postValue(core.maxSizeForAutoDownloadIncomingFiles == 0)
autoExportMediaToNativeGallery.postValue(corePreferences.makePublicMediaFilesDownloaded && !vfsEnabled)
markAsReadWhenDismissingNotification.postValue(
corePreferences.markConversationAsReadWhenDismissingMessageNotification
)
@ -439,6 +443,15 @@ class SettingsViewModel
}
}
@UiThread
fun toggleAutoExportMediaFilesToNativeGallery() {
val newValue = autoExportMediaToNativeGallery.value == false
coreContext.postOnCoreThread { core ->
corePreferences.makePublicMediaFilesDownloaded = newValue
autoExportMediaToNativeGallery.postValue(newValue)
}
}
@UiThread
fun toggleMarkConversationAsReadWhenDismissingNotification() {
val newValue = markAsReadWhenDismissingNotification.value == false

View file

@ -40,10 +40,39 @@
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:layout_marginEnd="16dp"
android:enabled="@{!viewModel.isVfsEnabled}"
android:checked="@{viewModel.autoDownloadEnabled}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.appcompat.widget.AppCompatTextView
style="@style/settings_title_style"
android:onClick="@{() -> viewModel.toggleAutoExportMediaFilesToNativeGallery()}"
android:id="@+id/auto_export_media_to_native_gallery_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="10dp"
android:text="@string/settings_conversations_auto_export_media_to_native_gallery_title"
android:maxLines="2"
android:ellipsize="end"
app:layout_constraintTop_toTopOf="@id/auto_export_media_to_native_gallery_switch"
app:layout_constraintBottom_toBottomOf="@id/auto_export_media_to_native_gallery_switch"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@id/auto_export_media_to_native_gallery_switch"/>
<com.google.android.material.materialswitch.MaterialSwitch
style="@style/material_switch_style"
android:id="@+id/auto_export_media_to_native_gallery_switch"
android:onClick="@{() -> viewModel.toggleAutoExportMediaFilesToNativeGallery()}"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:layout_marginEnd="16dp"
android:checked="@{viewModel.autoExportMediaToNativeGallery}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/auto_download_switch" />
<androidx.appcompat.widget.AppCompatTextView
style="@style/settings_title_style"
android:onClick="@{() -> viewModel.toggleMarkConversationAsReadWhenDismissingNotification()}"
@ -70,7 +99,7 @@
android:layout_marginEnd="16dp"
android:checked="@{viewModel.markAsReadWhenDismissingNotification}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/auto_download_switch" />
app:layout_constraintTop_toBottomOf="@id/auto_export_media_to_native_gallery_switch" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -201,6 +201,7 @@
<string name="settings_calls_change_ringtone_title">Changer de sonnerie</string>
<string name="settings_conversations_title">Conversations</string>
<string name="settings_conversations_auto_download_title">Télécharger automatiquement les fichiers</string>
<string name="settings_conversations_auto_export_media_to_native_gallery_title">Rendre visible dans la galerie les médias téléchargés</string>
<string name="settings_conversations_mark_as_read_when_dismissing_notif_title">Marquer la conversation comme lue lorsqu\'une notification de message est supprimée</string>
<string name="settings_contacts_title">Contacts</string>
<string name="settings_contacts_add_ldap_server_title">Ajouter un serveur LDAP</string>

View file

@ -240,6 +240,7 @@
<string name="settings_calls_change_ringtone_title">Change ringtone</string>
<string name="settings_conversations_title">Conversations</string>
<string name="settings_conversations_auto_download_title">Auto-download files</string>
<string name="settings_conversations_auto_export_media_to_native_gallery_title">Make downloaded media public</string>
<string name="settings_conversations_mark_as_read_when_dismissing_notif_title">Mark conversation as read when dismissing message notification</string>
<string name="settings_contacts_title">Contacts</string>
<string name="settings_contacts_add_ldap_server_title">Add LDAP server</string>