Merge branch 'release/6.0'

This commit is contained in:
Sylvain Berfini 2025-03-11 16:01:38 +01:00
commit 8363d41441
219 changed files with 4923 additions and 2013 deletions

View file

@ -24,13 +24,12 @@ job-android:
artifacts:
paths:
- ./app/build/outputs/apk/debug/linphone-android-debug-*.apk
- ./app/build/outputs/apk/release/linphone-android-release-*.apk
when: always
expire_in: 1 week
expire_in: 1 day
.scheduled-job-android:
extends: job-android
only:
- schedules
- schedules

View file

@ -11,7 +11,7 @@ Group changes to describe their impact on the project, as follows:
Security to invite users to upgrade in case of vulnerabilities.
## [6.0.0] - 2024-??-??
## [6.0.0] - 2025-03-11
6.0.0 release is a complete rework of Linphone Android, with a fully redesigned UI, so it is impossible to list everything here.
@ -20,30 +20,38 @@ Group changes to describe their impact on the project, as follows:
- Asymmetrical video : you no longer need to send your own camera feed to receive the one from the remote end of the call, and vice versa.
- Improved multi account: you'll only see history, conversations, meetings etc... related to currently selected account, and you can switch the default account in two clicks.
- Call transfer: Blind & Attended call transfer have been merged into one: during a call, if you initiate a transfer action, either pick another call to do the attended transfer or select a contact from the list (you can input a SIP URI not already in the suggestions list) to start a blind transfer.
- User can only send up to 12 files in a single chat message.
- IMDNs are now only sent to the message sender, preventing huge traffic in large groups, and thus the delivery status icon for received messages is now hidden in groups (as it was in 1-1 conversations).
- Settings: a lot of them are gone, the one that are still there have been reworked to increase user friendliness.
- Default screen (between contacts, call history, conversations & meetings list) will change depending on where you were when the app was paused or killed, and you will return to that last visited screen on the next startup.
- Gradle files have been migrated from Groovy to Kotlin DSL, and dependencies are now in a separated file (libs.versions.toml).
- Account creation no longer allows you to use your phone number as username, but it is still required to provide it to receive activation code by SMS.
- Minimum supported Android OS version is now 9 (API level 28).
- Telecom Manager support is now based on androidx.core.core-telecom package.
- Some settings have changed name and/or section in linphonerc file.
### Added
- Contacts trust: contacts for which all devices have been validated through a ZRTP call with SAS exchange are now highlighted with a blue circle (and with a red one in case of mistrust). That trust is now handled at contact level (instead of conversation level in previous versions).
- Contacts trust: contacts for which all devices have been validated through a ZRTP call with SAS exchange are now highlighted with a blue circle (and with a red one in case of mistrust). That trust is now handled at contact level (instead of conversation level in previous versions).
- Media & documents exchanged in a conversation can be easily found through a dedicated screen.
- A brand new chat message search feature has been added to conversations.
- You can now react to a chat message using any emoji.
- 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).
- Save / export document & media from ephemeral messages will be disabled, and secure policy that prevents screenshots will be enforced in file viewer even if the setting is disabled.
- 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).
- Security focus: security & trust is more visible than ever, and unsecure conversations & calls are even more visible than before.
- CardDAV: you can configure as many CardDAV servers you want to synchronize you contacts in Linphone (in addition or in replacement of native addressbook import).
- OpenID: when used with a SSO compliant SIP server (such as Flexisip), we support single-sign-on login.
- MWI support: display and allow to call your voicemail when you have new messages (if supported by your VoIP provider and properly configured in your account params).
- CCMP support: if you configure a CCMP server URL in your accounts params, it will be used when scheduling meetings & to fetch list of meetings you've organized/been invited to.
- Devices list: check on which device your sip.linphone.org account is connected and the last connection date & time (like on subscribe.linphone.org).
- Protobuf dependency to allow logging native crashes stack traces at next app startup.
- Android 15 startup listener, allowing us to log type of start (cold, warm, etc...) and some other useful info.
- Dialer & in-call numpad show letters under the digit.
### Removed
- Dialer: the previous home screen (dialer) has been removed, you'll find it as an input option in the new start call screen.

View file

@ -134,6 +134,8 @@ adb logcat -d | ndk-stack -sym ./libs-debug/`adb shell getprop ro.product.cpu.ab
```
Warning: This command won't print anything until you reproduce the crash!
Starting [NDK r29](https://github.com/android/ndk/wiki/Changelog-r29) you will be able to directly use the ```libs-debug.zip``` file in ```ndk-stack -sym``` argument.
## Create an APK with a different package name
Simply edit the ```app/build.gradle.kts``` file and change the value of the ```packageName``` variable.

View file

@ -103,7 +103,7 @@ android {
versionCode = 600000 // 6.00.000
versionName = "6.0.0"
manifestPlaceholders["appAuthRedirectScheme"] = "org.linphone"
manifestPlaceholders["appAuthRedirectScheme"] = packageName
ndk {
//noinspection ChromeOsAbiSupport
@ -155,6 +155,7 @@ android {
}
resValue("string", "linphone_app_version", gitVersion.trim())
resValue("string", "linphone_app_branch", gitBranch.toString().trim())
resValue("string", "linphone_openid_callback_scheme", packageName)
if (crashlyticsAvailable) {
val path = File("$sdkPath/libs-debug/").toString()
@ -177,6 +178,7 @@ android {
resValue("string", "file_provider", "$packageName.fileprovider")
resValue("string", "linphone_app_version", gitVersion.trim())
resValue("string", "linphone_app_branch", gitBranch.toString().trim())
resValue("string", "linphone_openid_callback_scheme", packageName)
if (crashlyticsAvailable) {
val path = File("$sdkPath/libs-debug/").toString()

View file

@ -60,6 +60,7 @@
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc"/>
<!--<meta-data
android:name="androidx.car.app.minCarApiLevel"
android:value="1"/>-->
@ -122,18 +123,6 @@
</activity>
<activity
android:name=".ui.fileviewer.MediaViewerActivity"
android:windowSoftInputMode="adjustResize"
android:launchMode="singleTask"
android:resizeableActivity="true" />
<activity
android:name=".ui.fileviewer.FileViewerActivity"
android:windowSoftInputMode="adjustResize"
android:launchMode="singleTask"
android:resizeableActivity="true" />
<activity
android:name=".ui.welcome.WelcomeActivity"
android:windowSoftInputMode="adjustResize"
@ -146,6 +135,18 @@
android:launchMode="singleTask"
android:resizeableActivity="true" />
<activity
android:name=".ui.fileviewer.MediaViewerActivity"
android:windowSoftInputMode="adjustResize"
android:launchMode="singleTask"
android:resizeableActivity="true" />
<activity
android:name=".ui.fileviewer.FileViewerActivity"
android:windowSoftInputMode="adjustResize"
android:launchMode="singleTask"
android:resizeableActivity="true" />
<activity
android:name=".ui.call.CallActivity"
android:windowSoftInputMode="adjustResize"
@ -199,7 +200,7 @@
</service>
<!--<service
android:name=".telecom.auto.AAService"
android:name=".telecom.auto.AndroidAutoService"
android:exported="true">
<intent-filter>
<action android:name="androidx.car.app.CarAppService"/>

View file

@ -22,13 +22,14 @@
<entry name="rtp_bundle" overwrite="true">1</entry>
<entry name="lime_server_url" overwrite="true">https://lime.linphone.org/lime-server/lime-server.php</entry>
<entry name="lime_algo" overwrite="true">c25519</entry>
<entry name="supported" overwrite="true"></entry>
</section>
<section name="nat_policy_default_values">
<entry name="stun_server" overwrite="true">stun.linphone.org</entry>
<entry name="protocols" overwrite="true">stun,ice</entry>
</section>
<section name="sip">
<entry name="media_encryption" overwrite="true">zrtp</entry>
<entry name="media_encryption" overwrite="true">srtp</entry>
<entry name="media_encryption_mandatory">1</entry>
</section>
</config>

View file

@ -22,6 +22,7 @@
<entry name="rtp_bundle" overwrite="true">0</entry>
<entry name="lime_server_url" overwrite="true"></entry>
<entry name="lime_algo" overwrite="true"></entry>
<entry name="supported" overwrite="true">outbound</entry>
</section>
<section name="nat_policy_default_values">
<entry name="stun_server" overwrite="true">stun.linphone.org</entry>

View file

@ -13,6 +13,7 @@ media_encryption=none
update_presence_model_timestamp_before_publish_expires_refresh=1
use_rfc2833=1
use_info=1
rls_uri=sips:rls@sip.linphone.org
[net]
#Because dynamic bitrate adaption can increase bitrate, we must allow "no limit"

View file

@ -22,7 +22,6 @@ zrtp_key_agreements_suites=MS_ZRTP_KEY_AGREEMENT_K255_MLK512,MS_ZRTP_KEY_AGREEME
chat_messages_aggregation_delay=1000
chat_messages_aggregation=1
update_presence_model_timestamp_before_publish_expires_refresh=1
rls_uri=sips:rls@sip.linphone.org
[sound]
#remove this property for any application that is not Linphone public version itself

View file

@ -127,6 +127,7 @@ class LinphoneApplication : Application(), SingletonImageLoader.Factory {
.crossfade(false)
.components {
add(VideoFrameDecoder.Factory())
// add(GifDecoder.Factory) // Do not add it, GIFs are properly rendered without it and adding it breaks resizing...
add(SvgDecoder.Factory())
}
.memoryCache {

View file

@ -27,13 +27,10 @@ import android.view.View
import android.view.contentcapture.ContentCaptureContext
import android.view.contentcapture.ContentCaptureSession
import androidx.annotation.RequiresApi
import org.linphone.utils.LinphoneUtils
@RequiresApi(Build.VERSION_CODES.Q)
class Api29Compatibility {
companion object {
private const val TAG = "[API 29 Compatibility]"
fun getMediaCollectionUri(isImage: Boolean, isVideo: Boolean, isAudio: Boolean): Uri {
return when {
isImage -> {
@ -59,11 +56,10 @@ class Api29Compatibility {
return intent.getStringExtra(Intent.EXTRA_LOCUS_ID)
}
fun setLocusIdInContentCaptureSession(root: View, localSipUri: String, remoteSipUri: String) {
fun setLocusIdInContentCaptureSession(root: View, conversationId: String) {
val session: ContentCaptureSession? = root.contentCaptureSession
if (session != null) {
val id = LinphoneUtils.getChatRoomId(localSipUri, remoteSipUri)
session.contentCaptureContext = ContentCaptureContext.forLocusId(id)
session.contentCaptureContext = ContentCaptureContext.forLocusId(conversationId)
}
}
}

View file

@ -26,8 +26,6 @@ import androidx.annotation.RequiresApi
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
class Api33Compatibility {
companion object {
private const val TAG = "[API 33 Compatibility]"
fun getAllRequiredPermissionsArray(): Array<String> {
return arrayOf(
Manifest.permission.POST_NOTIFICATIONS,

View file

@ -24,12 +24,11 @@ import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.annotation.RequiresApi
import androidx.core.content.ContextCompat
import org.linphone.core.tools.Log
import androidx.core.net.toUri
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
class Api34Compatibility {
@ -69,10 +68,10 @@ class Api34Compatibility {
val intent = Intent()
// See https://developer.android.com/reference/android/provider/Settings#ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT
intent.action = Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT
intent.data = Uri.parse("package:${context.packageName}")
intent.data = "package:${context.packageName}".toUri()
intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY)
Log.i("$TAG Starting ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT")
ContextCompat.startActivity(context, intent, null)
context.startActivity(intent, null)
}
}
}

View file

@ -38,7 +38,6 @@ class Compatibility {
companion object {
private const val TAG = "[Compatibility]"
const val FOREGROUND_SERVICE_TYPE_DATA_SYNC = 1 // ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
const val FOREGROUND_SERVICE_TYPE_PHONE_CALL = 4 // ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL
const val FOREGROUND_SERVICE_TYPE_CAMERA = 64 // ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA
const val FOREGROUND_SERVICE_TYPE_MICROPHONE = 128 // ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
@ -159,12 +158,11 @@ class Compatibility {
return null
}
fun setLocusIdInContentCaptureSession(root: View, localSipUri: String, remoteSipUri: String) {
fun setLocusIdInContentCaptureSession(root: View, conversationId: String) {
if (Version.sdkAboveOrEqual(Version.API29_ANDROID_10)) {
return Api29Compatibility.setLocusIdInContentCaptureSession(
root,
localSipUri,
remoteSipUri
conversationId
)
}
}

View file

@ -33,6 +33,8 @@ import androidx.core.content.res.ResourcesCompat
import androidx.core.graphics.drawable.IconCompat
import org.linphone.R
import org.linphone.utils.AppUtils
import androidx.core.graphics.createBitmap
import androidx.core.graphics.drawable.toDrawable
class AvatarGenerator(private val context: Context) {
private var textSize: Float = AppUtils.getDimension(R.dimen.avatar_initials_text_size)
@ -74,7 +76,7 @@ class AvatarGenerator(private val context: Context) {
val textPainter = getTextPainter()
val painter = if (useTransparentBackground) getTransparentPainter() else getBackgroundPainter()
val bitmap = Bitmap.createBitmap(avatarSize, avatarSize, Bitmap.Config.ARGB_8888)
val bitmap = createBitmap(avatarSize, avatarSize)
val canvas = Canvas(bitmap)
val areaRect = Rect(0, 0, avatarSize, avatarSize)
val bounds = RectF(areaRect)
@ -91,7 +93,7 @@ class AvatarGenerator(private val context: Context) {
}
fun buildDrawable(): BitmapDrawable {
return BitmapDrawable(context.resources, buildBitmap(true))
return buildBitmap(true).toDrawable(context.resources)
}
fun buildIcon(): IconCompat {

View file

@ -143,6 +143,14 @@ class ContactsManager
}
private val friendListListener: FriendListListenerStub = object : FriendListListenerStub() {
@WorkerThread
override fun onPresenceReceived(friendList: FriendList, friends: Array<out Friend?>) {
if (friendList.isSubscriptionBodyless) {
Log.i("$TAG Bodyless friendlist [${friendList.displayName}] presence received")
notifyContactsListChanged()
}
}
@WorkerThread
override fun onNewSipAddressDiscovered(
friendList: FriendList,
@ -575,10 +583,12 @@ class ContactsManager
@WorkerThread
fun onCoreStarted(core: Core) {
Log.i("$TAG Core has been started")
loadContactsOnlyFromDefaultDirectory = corePreferences.fetchContactsFromDefaultDirectory
core.addListener(coreListener)
for (list in core.friendsLists) {
Log.i("$TAG Found existing friend list [${list.displayName}]")
list.addListener(friendListListener)
}
@ -604,6 +614,7 @@ class ContactsManager
@WorkerThread
fun onCoreStopped(core: Core) {
Log.w("$TAG Core has been stopped")
coroutineScope.cancel()
core.removeListener(coreListener)

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
@ -92,6 +93,10 @@ class CoreContext
MutableLiveData<Event<String>>()
}
val clearAuthenticationRequestDialogEvent: MutableLiveData<Event<Boolean>> by lazy {
MutableLiveData<Event<Boolean>>()
}
val refreshMicrophoneMuteStateEvent: MutableLiveData<Event<Boolean>> by lazy {
MutableLiveData<Event<Boolean>>()
}
@ -112,6 +117,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 +165,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]")
@ -163,6 +209,8 @@ class CoreContext
// Wait for GlobalState.ON as some settings modification won't be saved
// in RC file if Core isn't ON
onCoreStarted()
} else if (state == GlobalState.Shutdown) {
onCoreStopped()
}
}
@ -347,6 +395,7 @@ class CoreContext
}
}
@WorkerThread
override fun onAccountAdded(core: Core, account: Account) {
// Prevent this trigger when core is stopped/start in remote prov
if (core.globalState == GlobalState.Off) return
@ -368,6 +417,15 @@ class CoreContext
}
}
}
@WorkerThread
override fun onAccountRemoved(core: Core, account: Account) {
Log.i("$TAG Account [${account.params.identityAddress?.asStringUriOnly()}] removed, clearing auth request dialog if needed")
if (account.findAuthInfo() == digestAuthInfoPendingPasswordUpdate) {
Log.i("$TAG Removed account matches auth info pending password update, removing dialog")
clearAuthenticationRequestDialogEvent.postValue(Event(true))
}
}
}
private var logcatEnabled: Boolean = corePreferences.printLogsInLogcat
@ -415,7 +473,9 @@ class CoreContext
}
Log.i("=========================================")
Log.i("==== Linphone-android information dump ====")
Log.i("VERSION=${BuildConfig.VERSION_NAME} / ${BuildConfig.VERSION_CODE}")
val gitVersion = AppUtils.getString(org.linphone.R.string.linphone_app_version)
val gitBranch = AppUtils.getString(org.linphone.R.string.linphone_app_branch)
Log.i("VERSION=${BuildConfig.VERSION_NAME} / ${BuildConfig.VERSION_CODE} ($gitVersion from $gitBranch branch)")
Log.i("PACKAGE=${BuildConfig.APPLICATION_ID}")
Log.i("BUILD TYPE=${BuildConfig.BUILD_TYPE}")
Log.i("=========================================")
@ -498,6 +558,14 @@ class CoreContext
Log.i("$TAG Started contacts, telecom & notifications managers")
}
@WorkerThread
private fun onCoreStopped() {
Log.w("$TAG Core is being shut down, notifying managers so they can remove their listeners and do some cleanup if needed")
contactsManager.onCoreStopped(core)
telecomManager.onCoreStopped(core)
notificationsManager.onCoreStopped(core)
}
@WorkerThread
private fun destroyCore() {
if (!::core.isInitialized) {
@ -520,10 +588,6 @@ class CoreContext
core.stop()
contactsManager.onCoreStopped(core)
telecomManager.onCoreStopped(core)
notificationsManager.onCoreStopped(core)
// It's very unlikely the process will survive until the Core reaches GlobalStateOff sadly
Log.w("$TAG Core has been shut down")
exitProcess(0)
@ -620,6 +684,11 @@ class CoreContext
return found != null
}
@WorkerThread
fun clearFilesToExportToNativeGallery() {
filesToExportToNativeMediaGallery.clear()
}
@WorkerThread
fun startAudioCall(
address: Address,
@ -728,7 +797,9 @@ class CoreContext
@WorkerThread
fun answerCall(call: Call) {
Log.i("$TAG Answering call $call")
Log.i(
"$TAG Answering call with remote address [${call.remoteAddress.asStringUriOnly()}] and to address [${call.toAddress.asStringUriOnly()}]"
)
val params = core.createCallParams(call)
if (params == null) {
Log.w("$TAG Answering call without params!")

View file

@ -142,12 +142,29 @@ class CorePreferences
// Conversation related
@get:WorkerThread @set:WorkerThread
var markConversationAsReadWhenDismissingMessageNotification: Boolean
get() = config.getBool("app", "mark_as_read_notif_dismissal", false)
set(value) {
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
var createEndToEndEncryptedMeetingsAndGroupCalls: Boolean
get() = config.getBool("app", "create_e2e_encrypted_conferences", false)
set(value) {
config.setBool("app", "create_e2e_encrypted_conferences", value)
}
// Contacts related
@get:WorkerThread @set:WorkerThread
@ -266,6 +283,10 @@ class CorePreferences
val hideAssistantThirdPartySipAccount: Boolean
get() = config.getBool("ui", "assistant_hide_third_party_account", false)
@get:WorkerThread
val singleSignOnClientId: String
get() = config.getString("app", "oidc_client_id", "linphone")!!
@get:WorkerThread
val useUsernameAsSingleSignOnLoginHint: Boolean
get() = config.getBool("ui", "use_username_as_sso_login_hint", false)
@ -328,6 +349,10 @@ class CorePreferences
val ssoCacheFile: String
get() = context.filesDir.absolutePath + "/auth_state.json"
@get:AnyThread
val messageReceivedInVisibleConversationNotificationSound: String
get() = context.filesDir.absolutePath + "/share/sounds/linphone/incoming_chat.wav"
@UiThread
fun copyAssetsFromPackage() {
copy("linphonerc_default", configPath)

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
@ -38,6 +39,7 @@ import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
import org.linphone.core.tools.Log
import androidx.core.content.edit
class VFS {
companion object {
@ -72,7 +74,13 @@ class VFS {
return false
}
preferences.edit().putBoolean("vfs_enabled", true).apply()
preferences.edit { putBoolean("vfs_enabled", true) }
if (corePreferences.makePublicMediaFilesDownloaded) {
Log.w("$TAG VFS is now enabled, disabling auto export of media files to native gallery")
corePreferences.makePublicMediaFilesDownloaded = false
}
return true
}
@ -91,10 +99,10 @@ class VFS {
generateSecretKey()
encryptToken(generateToken()).let { data ->
preferences
.edit()
.putString(VFS_IV, data.first)
.putString(VFS_KEY, data.second)
.commit()
.edit(commit = true) {
putString(VFS_IV, data.first)
.putString(VFS_KEY, data.second)
}
}
}

View file

@ -31,6 +31,7 @@ import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.media.AudioAttributes
import android.media.AudioManager
import android.media.MediaPlayer
import android.media.RingtoneManager
import android.net.Uri
import android.os.Bundle
@ -71,6 +72,8 @@ import org.linphone.core.MediaDirection
import org.linphone.core.tools.Log
import org.linphone.ui.call.CallActivity
import org.linphone.ui.main.MainActivity
import org.linphone.ui.main.MainActivity.Companion.ARGUMENTS_CHAT
import org.linphone.ui.main.MainActivity.Companion.ARGUMENTS_CONVERSATION_ID
import org.linphone.utils.AppUtils
import org.linphone.utils.FileUtils
import org.linphone.utils.LinphoneUtils
@ -122,6 +125,8 @@ class NotificationsManager
private var currentlyDisplayedChatRoomId: String = ""
private var currentlyDisplayedIncomingCallFragment: Boolean = false
private lateinit var mediaPlayer: MediaPlayer
private val contactsListener = object : ContactsListener {
@WorkerThread
override fun onContactsLoaded() { }
@ -262,11 +267,22 @@ class NotificationsManager
Log.i("$TAG Received ${messages.size} aggregated messages")
if (corePreferences.disableChat) return
val id = LinphoneUtils.getChatRoomId(chatRoom)
val id = LinphoneUtils.getConversationId(chatRoom)
if (currentlyDisplayedChatRoomId.isNotEmpty() && id == currentlyDisplayedChatRoomId) {
Log.i(
"$TAG Do not notify received messages for currently displayed conversation [$id]"
"$TAG Do not notify received messages for currently displayed conversation [$id], but play sound if at least one message is incoming and not read"
)
var playSound = false
for (message in messages) {
if (!message.isOutgoing && !message.isRead) {
playSound = true
break
}
}
if (playSound) {
playMessageReceivedSound()
}
return
}
@ -301,7 +317,7 @@ class NotificationsManager
"$TAG Reaction received [${reaction.body}] from [${address.asStringUriOnly()}] for message [$message]"
)
val id = LinphoneUtils.getChatRoomId(chatRoom)
val id = LinphoneUtils.getConversationId(chatRoom)
/*if (id == currentlyDisplayedChatRoomId) {
Log.i(
"$TAG Do not notify received reaction for currently displayed conversation [$id]"
@ -340,7 +356,7 @@ class NotificationsManager
if (corePreferences.disableChat) return
if (chatRoom.muted) {
val id = LinphoneUtils.getChatRoomId(chatRoom)
val id = LinphoneUtils.getConversationId(chatRoom)
Log.i("$TAG Conversation $id has been muted")
return
}
@ -370,7 +386,7 @@ class NotificationsManager
val notification = createMessageNotification(
notifiable,
pendingIntent,
LinphoneUtils.getChatRoomId(chatRoom),
LinphoneUtils.getConversationId(chatRoom),
me
)
notify(notifiable.notificationId, notification, CHAT_TAG)
@ -389,7 +405,7 @@ class NotificationsManager
@WorkerThread
override fun onChatRoomRead(core: Core, chatRoom: ChatRoom) {
Log.i(
"$TAG Conversation [${LinphoneUtils.getChatRoomId(chatRoom)}] has been marked as read, removing notification if any"
"$TAG Conversation [${LinphoneUtils.getConversationId(chatRoom)}] has been marked as read, removing notification if any"
)
dismissChatNotification(chatRoom)
}
@ -533,6 +549,21 @@ class NotificationsManager
core.addListener(coreListener)
coreContext.contactsManager.addListener(contactsListener)
val soundPath = corePreferences.messageReceivedInVisibleConversationNotificationSound
mediaPlayer = MediaPlayer().apply {
try {
setAudioAttributes(
AudioAttributes.Builder().setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
.build()
)
setDataSource(soundPath)
prepare()
} catch (e: Exception) {
Log.e("$TAG Failed to prepare message received sound file [$soundPath]: $e")
}
}
}
@WorkerThread
@ -786,7 +817,7 @@ class NotificationsManager
val address = chatRoom.peerAddress.asStringUriOnly()
var notifiable: Notifiable? = chatNotificationsMap[address]
if (notifiable == null) {
notifiable = Notifiable(LinphoneUtils.getChatRoomId(chatRoom).hashCode())
notifiable = Notifiable(LinphoneUtils.getConversationId(chatRoom).hashCode())
notifiable.myself = LinphoneUtils.getDisplayName(chatRoom.localAddress)
notifiable.localIdentity = chatRoom.localAddress.asStringUriOnly()
notifiable.remoteAddress = chatRoom.peerAddress.asStringUriOnly()
@ -834,7 +865,7 @@ class NotificationsManager
val notification = createMessageNotification(
notifiable,
pendingIntent,
LinphoneUtils.getChatRoomId(chatRoom),
LinphoneUtils.getConversationId(chatRoom),
me
)
notify(notifiable.notificationId, notification, CHAT_TAG)
@ -899,7 +930,7 @@ class NotificationsManager
val notification = createMessageNotification(
notifiable,
pendingIntent,
LinphoneUtils.getChatRoomId(chatRoom),
LinphoneUtils.getConversationId(chatRoom),
me
)
notify(notifiable.notificationId, notification, CHAT_TAG)
@ -928,7 +959,7 @@ class NotificationsManager
val notification = createMessageNotification(
notifiable,
pendingIntent,
LinphoneUtils.getChatRoomId(chatRoom),
LinphoneUtils.getConversationId(chatRoom),
me
)
Log.i(
@ -1103,7 +1134,7 @@ class NotificationsManager
}
Log.i(
"Creating notification for [${if (isIncoming) "incoming" else "outgoing"}] [${if (isConference) "conference" else "call"}] with video [${if (isVideo) "enabled" else "disabled"}] on channel [$channel]"
"Creating notification for ${if (isIncoming) "[incoming] " else ""}[${if (isConference) "conference" else "call"}] with video [${if (isVideo) "enabled" else "disabled"}] on channel [$channel]"
)
val builder = NotificationCompat.Builder(
@ -1295,7 +1326,7 @@ class NotificationsManager
return true
} else {
val previousNotificationId = previousChatNotifications.find { id ->
id == LinphoneUtils.getChatRoomId(chatRoom).hashCode()
id == LinphoneUtils.getConversationId(chatRoom).hashCode()
}
if (previousNotificationId != null) {
Log.i(
@ -1362,7 +1393,7 @@ class NotificationsManager
val notification = createMessageNotification(
notifiable,
pendingIntent,
LinphoneUtils.getChatRoomId(chatRoom),
LinphoneUtils.getConversationId(chatRoom),
me
)
notify(notifiable.notificationId, notification, CHAT_TAG)
@ -1551,7 +1582,7 @@ class NotificationsManager
val id = context.getString(R.string.notification_channel_call_id)
val name = context.getString(R.string.notification_channel_call_name)
val channel = NotificationChannel(id, name, NotificationManager.IMPORTANCE_DEFAULT).apply {
val channel = NotificationChannel(id, name, NotificationManager.IMPORTANCE_LOW).apply {
description = name
lockscreenVisibility = Notification.VISIBILITY_PUBLIC
}
@ -1585,9 +1616,8 @@ class NotificationsManager
@WorkerThread
private fun getChatRoomPendingIntent(chatRoom: ChatRoom, notificationId: Int): PendingIntent {
val args = Bundle()
args.putBoolean("Chat", true)
args.putString("RemoteSipUri", chatRoom.peerAddress.asStringUriOnly())
args.putString("LocalSipUri", chatRoom.localAddress.asStringUriOnly())
args.putBoolean(ARGUMENTS_CHAT, true)
args.putString(ARGUMENTS_CONVERSATION_ID, LinphoneUtils.getConversationId(chatRoom))
// Not using NavDeepLinkBuilder to prevent stacking a ConversationsListFragment above another one
return TaskStackBuilder.create(context).run {
@ -1605,6 +1635,17 @@ class NotificationsManager
}
}
@WorkerThread
private fun playMessageReceivedSound() {
if (::mediaPlayer.isInitialized) {
try {
mediaPlayer.start()
} catch (e: Exception) {
Log.e("$TAG Failed to play message received sound file: $e")
}
}
}
class Notifiable(val notificationId: Int) {
var myself: String? = null

View file

@ -31,9 +31,11 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.LinphoneApplication.Companion.corePreferences
import org.linphone.core.AudioDevice
import org.linphone.core.Call
import org.linphone.core.CallListenerStub
import org.linphone.core.Reason
import org.linphone.core.tools.Log
import org.linphone.utils.AudioUtils
import org.linphone.utils.Event
@ -50,6 +52,7 @@ class TelecomCallControlCallback(
private var availableEndpoints: List<CallEndpointCompat> = arrayListOf()
private var currentEndpoint = CallEndpointCompat.TYPE_UNKNOWN
private var endpointUpdateRequestFromLinphone: Boolean = false
private val callListener = object : CallListenerStub() {
@WorkerThread
@ -64,9 +67,14 @@ class TelecomCallControlCallback(
CallAttributesCompat.Companion.CALL_TYPE_AUDIO_CALL
}
scope.launch {
Log.i("$TAG Answering ${if (isVideo) "video" else "audio"} call")
Log.i("$TAG Answering [${if (isVideo) "video" else "audio"}] call")
callControl.answer(type)
}
if (isVideo && corePreferences.routeAudioToSpeakerWhenVideoIsEnabled) {
Log.i("$TAG Answering video call, routing audio to speaker")
AudioUtils.routeAudioToSpeaker(call)
}
} else {
scope.launch {
Log.i("$TAG Setting call active")
@ -74,18 +82,39 @@ class TelecomCallControlCallback(
}
}
} else if (state == Call.State.End) {
val reason = call.reason
val direction = call.dir
scope.launch {
Log.i("$TAG Disconnecting call because it has ended")
callControl.disconnect(DisconnectCause(DisconnectCause.LOCAL))
val disconnectCause = when (reason) {
Reason.NotAnswered -> DisconnectCause.REMOTE
Reason.Declined -> DisconnectCause.REJECTED
Reason.Busy -> {
if (direction == Call.Dir.Incoming) {
DisconnectCause.MISSED
} else {
DisconnectCause.BUSY
}
}
else -> DisconnectCause.LOCAL
}
Log.i("$TAG Disconnecting [${if (direction == Call.Dir.Incoming)"incoming" else "outgoing"}] call with cause [${disconnectCauseToString(disconnectCause)}] because it has ended with reason [$reason]")
try {
callControl.disconnect(DisconnectCause(disconnectCause))
} catch (ise: IllegalArgumentException) {
Log.e("$TAG Couldn't disconnect call control with cause [${disconnectCauseToString(disconnectCause)}]: $ise")
}
}
} else if (state == Call.State.Error) {
val reason = call.reason
scope.launch {
Log.w("$TAG Disconnecting call due to error [$message]")
// For some reason DisconnectCause.ERROR or DisconnectCause.BUSY triggers an IllegalArgumentException with following message
// Valid DisconnectCause codes are limited to [DisconnectCause.LOCAL, DisconnectCause.REMOTE, DisconnectCause.MISSED, or DisconnectCause.REJECTED]
val disconnectCause = DisconnectCause.REJECTED
Log.w("$TAG Disconnecting call with cause [${disconnectCauseToString(disconnectCause)}] due to error [$message] and reason [$reason]")
try {
// For some reason DisconnectCause.ERROR triggers an IllegalArgumentException
callControl.disconnect(DisconnectCause(DisconnectCause.REJECTED))
callControl.disconnect(DisconnectCause(disconnectCause))
} catch (ise: IllegalArgumentException) {
Log.e("$TAG Couldn't terminate call control with REJECTED cause: $ise")
Log.e("$TAG Couldn't disconnect call control with cause [${disconnectCauseToString(disconnectCause)}]: $ise")
}
}
} else if (state == Call.State.Pausing) {
@ -134,11 +163,23 @@ class TelecomCallControlCallback(
}.launchIn(scope)
callControl.currentCallEndpoint.onEach { endpoint ->
Log.i("$TAG We're asked to use [${endpoint.name}] audio endpoint")
val type = endpoint.type
currentEndpoint = type
if (endpointUpdateRequestFromLinphone) {
Log.i("$TAG Linphone requests to use [${endpoint.name}] audio endpoint with type [$type]")
} else {
Log.i("$TAG Android requests us to use [${endpoint.name}] audio endpoint with type [$type]")
}
if (!endpointUpdateRequestFromLinphone && !coreContext.isConnectedToAndroidAuto && (type == CallEndpointCompat.Companion.TYPE_EARPIECE || type == CallEndpointCompat.Companion.TYPE_SPEAKER)) {
Log.w("$TAG Device isn't connected to Android Auto, do not follow system request to change audio endpoint to either earpiece or speaker")
return@onEach
}
// Change audio route in SDK, this way the usual listener will trigger
// and we'll be able to update the UI accordingly
val route = arrayListOf<AudioDevice.Type>()
when (endpoint.type) {
when (type) {
CallEndpointCompat.Companion.TYPE_EARPIECE -> {
route.add(AudioDevice.Type.Earpiece)
}
@ -154,7 +195,6 @@ class TelecomCallControlCallback(
}
}
if (route.isNotEmpty()) {
currentEndpoint = endpoint.type
coreContext.postOnCoreThread {
if (!AudioUtils.applyAudioRouteChangeInLinphone(call, route)) {
Log.w("$TAG Failed to apply audio route change, trying again in 200ms")
@ -164,6 +204,7 @@ class TelecomCallControlCallback(
}
}
}
endpointUpdateRequestFromLinphone = false
}.launchIn(scope)
callControl.isMuted.onEach { muted ->
@ -193,6 +234,7 @@ class TelecomCallControlCallback(
}
fun applyAudioRouteToCallWithId(routes: List<AudioDevice.Type>): Boolean {
endpointUpdateRequestFromLinphone = true
Log.i("$TAG Looking for audio endpoint with type [${routes.first()}]")
var wiredHeadsetFound = false
@ -260,4 +302,23 @@ class TelecomCallControlCallback(
}
return false
}
private fun disconnectCauseToString(cause: Int): String {
return when (cause) {
DisconnectCause.UNKNOWN -> "UNKNOWN"
DisconnectCause.ERROR -> "ERROR"
DisconnectCause.LOCAL -> "LOCAL"
DisconnectCause.REMOTE -> "REMOTE"
DisconnectCause.CANCELED -> "CANCELED"
DisconnectCause.MISSED -> "MISSED"
DisconnectCause.REJECTED -> "REJECTED"
DisconnectCause.BUSY -> "BUSY"
DisconnectCause.RESTRICTED -> "RESTRICTED"
DisconnectCause.OTHER -> "OTHER"
DisconnectCause.CONNECTION_MANAGER_NOT_SUPPORTED -> "CONNECTION_MANAGER_NOT_SUPPORTED"
DisconnectCause.ANSWERED_ELSEWHERE -> "ANSWERED_ELSEWHERE"
DisconnectCause.CALL_PULLED -> "CALL_PULLED"
else -> "UNEXPECTED: $cause"
}
}
}

View file

@ -20,9 +20,9 @@
package org.linphone.telecom
import android.content.Context
import android.net.Uri
import androidx.annotation.WorkerThread
import androidx.core.telecom.CallAttributesCompat
import androidx.core.telecom.CallException
import androidx.core.telecom.CallsManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -35,6 +35,7 @@ import org.linphone.core.Core
import org.linphone.core.CoreListenerStub
import org.linphone.core.tools.Log
import org.linphone.utils.LinphoneUtils
import androidx.core.net.toUri
class TelecomManager
@WorkerThread
@ -51,8 +52,15 @@ class TelecomManager
private val coreListener = object : CoreListenerStub() {
@WorkerThread
override fun onCallCreated(core: Core, call: Call) {
onCallCreated(call)
override fun onCallStateChanged(
core: Core,
call: Call,
state: Call.State?,
message: String
) {
if (state == Call.State.IncomingReceived || state == Call.State.OutgoingProgress) {
onCallCreated(call)
}
}
@WorkerThread
@ -88,10 +96,10 @@ class TelecomManager
@WorkerThread
fun onCallCreated(call: Call) {
Log.i("$TAG Call to [${call.remoteAddress.asStringUriOnly()}] created")
Log.i("$TAG Call to [${call.remoteAddress.asStringUriOnly()}] created in state [${call.state}]")
val address = call.callLog.remoteAddress
val uri = Uri.parse(address.asStringUriOnly())
val uri = address.asStringUriOnly().toUri()
val direction = if (call.dir == Call.Dir.Outgoing) {
CallAttributesCompat.DIRECTION_OUTGOING
@ -99,9 +107,9 @@ class TelecomManager
CallAttributesCompat.DIRECTION_INCOMING
}
val conferenceInfo = LinphoneUtils.getConferenceInfoIfAny(call)
val capabilities = CallAttributesCompat.SUPPORTS_SET_INACTIVE or CallAttributesCompat.SUPPORTS_TRANSFER
val conferenceInfo = LinphoneUtils.getConferenceInfoIfAny(call)
val displayName = if (call.conference != null || conferenceInfo != null) {
conferenceInfo?.subject ?: call.conference?.subject ?: LinphoneUtils.getDisplayName(address)
} else {
@ -109,20 +117,24 @@ class TelecomManager
friend?.name ?: LinphoneUtils.getDisplayName(address)
}
// When call is created, it is ringing (incoming or outgoing, do not set video)
val type = CallAttributesCompat.Companion.CALL_TYPE_AUDIO_CALL
val callAttributes = CallAttributesCompat(
displayName,
uri,
direction,
type,
capabilities
)
Log.i("$TAG Adding call to Telecom's CallsManager with attributes [$callAttributes]")
val isVideo = LinphoneUtils.isVideoEnabled(call)
val type = if (isVideo) {
CallAttributesCompat.Companion.CALL_TYPE_VIDEO_CALL
} else {
CallAttributesCompat.Companion.CALL_TYPE_AUDIO_CALL
}
scope.launch {
try {
val callAttributes = CallAttributesCompat(
displayName,
uri,
direction,
type,
capabilities
)
Log.i("$TAG Adding call to Telecom's CallsManager with attributes [$callAttributes]")
callsManager.addCall(
callAttributes,
{ callType -> // onAnswer
@ -160,6 +172,11 @@ class TelecomManager
) {
val callbacks = TelecomCallControlCallback(call, this, scope)
// We must first call setCallback on callControlScope before using it
callbacks.onCallControlCallbackSet()
currentlyFollowedCalls += 1
Log.i("$TAG Call added to Telecom's CallsManager")
coreContext.postOnCoreThread {
val callId = call.callLog.callId.orEmpty()
if (callId.isNotEmpty()) {
@ -167,13 +184,8 @@ class TelecomManager
map[callId] = callbacks
}
}
// We must first call setCallback on callControlScope before using it
callbacks.onCallControlCallbackSet()
currentlyFollowedCalls += 1
Log.i("$TAG Call added to Telecom's CallsManager")
}
} catch (e: Exception) {
} catch (e: CallException) {
Log.e("$TAG Failed to add call to Telecom's CallsManager: $e")
}
}

View file

@ -38,7 +38,7 @@ import org.linphone.core.tools.Log
import org.linphone.utils.AppUtils
import org.linphone.utils.LinphoneUtils
class AAScreen(context: CarContext) : Screen(context) {
class AndroidAutoScreen(context: CarContext) : Screen(context) {
companion object {
private const val TAG = "[Android Auto Screen]"
}

View file

@ -26,7 +26,7 @@ import androidx.car.app.validation.HostValidator
import org.linphone.R
import org.linphone.core.tools.Log
class AAService : CarAppService() {
class AndroidAutoService : CarAppService() {
companion object {
private const val TAG = "[Android Auto Service]"
}
@ -55,6 +55,6 @@ class AAService : CarAppService() {
override fun onCreateSession(): Session {
Log.i("$TAG Creating Session object")
return AASession()
return AndroidAutoSession()
}
}

View file

@ -24,13 +24,13 @@ import androidx.car.app.Screen
import androidx.car.app.Session
import org.linphone.core.tools.Log
class AASession : Session() {
class AndroidAutoSession : Session() {
companion object {
private const val TAG = "[Android Auto Session]"
}
override fun onCreateScreen(intent: Intent): Screen {
Log.i("$TAG Creating Screen object for host with API level [${carContext.carAppApiLevel}]")
return AAScreen(carContext)
return AndroidAutoScreen(carContext)
}
}

View file

@ -235,7 +235,7 @@ open class GenericActivity : AppCompatActivity() {
startActivity(intent)
}
private fun enableWindowSecureMode(enable: Boolean) {
protected fun enableWindowSecureMode(enable: Boolean) {
val flags: Int = window.attributes.flags
if ((enable && flags and WindowManager.LayoutParams.FLAG_SECURE != 0) ||
(!enable && flags and WindowManager.LayoutParams.FLAG_SECURE == 0)

View file

@ -24,10 +24,6 @@ import androidx.fragment.app.Fragment
@UiThread
abstract class GenericFragment : Fragment() {
companion object {
private const val TAG = "[Generic Fragment]"
}
protected fun observeToastEvents(viewModel: GenericViewModel) {
viewModel.showRedToastEvent.observe(viewLifecycleOwner) {
it.consume { pair ->

View file

@ -19,6 +19,8 @@
*/
package org.linphone.ui
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import org.linphone.utils.Event
@ -41,4 +43,20 @@ open class GenericViewModel : ViewModel() {
val showFormattedRedToastEvent: MutableLiveData<Event<Pair<String, Int>>> by lazy {
MutableLiveData<Event<Pair<String, Int>>>()
}
fun showGreenToast(@StringRes message: Int, @DrawableRes icon: Int) {
showGreenToastEvent.postValue(Event(Pair(message, icon)))
}
fun showFormattedGreenToast(message: String, @DrawableRes icon: Int) {
showFormattedGreenToastEvent.postValue(Event(Pair(message, icon)))
}
fun showRedToast(@StringRes message: Int, @DrawableRes icon: Int) {
showRedToastEvent.postValue(Event(Pair(message, icon)))
}
fun showFormattedRedToast(message: String, @DrawableRes icon: Int) {
showFormattedRedToastEvent.postValue(Event(Pair(message, icon)))
}
}

View file

@ -23,10 +23,10 @@ import androidx.annotation.FontRes
import org.linphone.R
enum class NotoSansFont(@FontRes val fontRes: Int) {
NotoSansLight(R.font.noto_sans_light), // 300
// NotoSansLight(R.font.noto_sans_light), // 300
NotoSansRegular(R.font.noto_sans_regular), // 400
NotoSansMedium(R.font.noto_sans_medium), // 500
NotoSansSemiBold(R.font.noto_sans_semi_bold), // 600
// NotoSansSemiBold(R.font.noto_sans_semi_bold), // 600
NotoSansBold(R.font.noto_sans_bold), // 700
NotoSansExtraBold(R.font.noto_sans_extra_bold) // 800
}

View file

@ -21,7 +21,6 @@ package org.linphone.ui.assistant.fragment
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.telephony.TelephonyManager
import android.view.LayoutInflater
@ -44,6 +43,7 @@ import org.linphone.ui.assistant.model.AcceptConditionsAndPolicyDialogModel
import org.linphone.ui.assistant.viewmodel.AccountLoginViewModel
import org.linphone.utils.DialogUtils
import org.linphone.utils.PhoneNumberUtils
import androidx.core.net.toUri
@UiThread
class LandingFragment : GenericFragment() {
@ -104,7 +104,7 @@ class LandingFragment : GenericFragment() {
binding.setForgottenPasswordClickListener {
val url = getString(R.string.web_platform_forgotten_password_url)
try {
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
val browserIntent = Intent(Intent.ACTION_VIEW, url.toUri())
startActivity(browserIntent)
} catch (ise: IllegalStateException) {
Log.e(
@ -207,7 +207,7 @@ class LandingFragment : GenericFragment() {
it.consume {
val url = getString(R.string.website_privacy_policy_url)
try {
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
val browserIntent = Intent(Intent.ACTION_VIEW, url.toUri())
startActivity(browserIntent)
} catch (ise: IllegalStateException) {
Log.e(
@ -221,7 +221,7 @@ class LandingFragment : GenericFragment() {
it.consume {
val url = getString(R.string.website_terms_and_conditions_url)
try {
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
val browserIntent = Intent(Intent.ACTION_VIEW, url.toUri())
startActivity(browserIntent)
} catch (ise: IllegalStateException) {
Log.e(

View file

@ -38,6 +38,7 @@ import org.linphone.databinding.AssistantQrCodeScannerFragmentBinding
import org.linphone.ui.GenericActivity
import org.linphone.ui.GenericFragment
import org.linphone.ui.assistant.viewmodel.QrCodeViewModel
import org.linphone.ui.main.sso.fragment.SingleSignOnFragmentDirections
@UiThread
class QrCodeScannerFragment : GenericFragment() {
@ -85,17 +86,37 @@ class QrCodeScannerFragment : GenericFragment() {
viewModel.qrCodeFoundEvent.observe(viewLifecycleOwner) {
it.consume { isValid ->
if (!isValid) {
(requireActivity() as GenericActivity).showRedToast(
getString(R.string.assistant_qr_code_invalid_toast),
R.drawable.warning_circle
)
} else {
if (isValid) {
requireActivity().finish()
}
}
}
viewModel.onErrorEvent.observe(viewLifecycleOwner) {
it.consume {
// Core has restarted but something went wrong, restart video capture
enableQrCodeVideoScanner()
}
}
coreContext.bearerAuthenticationRequestedEvent.observe(viewLifecycleOwner) {
it.consume { pair ->
val serverUrl = pair.first
val username = pair.second
Log.i(
"$TAG Navigating to Single Sign On Fragment with server URL [$serverUrl] and username [$username]"
)
if (findNavController().currentDestination?.id == R.id.qrCodeScannerFragment) {
val action = SingleSignOnFragmentDirections.actionGlobalSingleSignOnFragment(
serverUrl,
username
)
findNavController().navigate(action)
}
}
}
if (!isCameraPermissionGranted()) {
if (ActivityCompat.shouldShowRequestPermissionRationale(requireActivity(), Manifest.permission.CAMERA)) {
Log.w("$TAG CAMERA permission wasn't granted yet, asking for it now")

View file

@ -21,7 +21,6 @@ package org.linphone.ui.assistant.fragment
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.telephony.TelephonyManager
import android.text.Editable
@ -49,6 +48,7 @@ import org.linphone.utils.ConfirmationDialogModel
import org.linphone.utils.AppUtils
import org.linphone.utils.DialogUtils
import org.linphone.utils.PhoneNumberUtils
import androidx.core.net.toUri
@UiThread
class RegisterFragment : GenericFragment() {
@ -102,7 +102,7 @@ class RegisterFragment : GenericFragment() {
binding.setOpenSubscribeWebPageClickListener {
val url = getString(R.string.web_platform_register_email_url)
try {
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
val browserIntent = Intent(Intent.ACTION_VIEW, url.toUri())
startActivity(browserIntent)
} catch (ise: IllegalStateException) {
Log.e(

View file

@ -20,7 +20,6 @@
package org.linphone.ui.assistant.fragment
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
@ -31,6 +30,7 @@ import org.linphone.R
import org.linphone.core.tools.Log
import org.linphone.databinding.AssistantThirdPartySipAccountWarningFragmentBinding
import org.linphone.ui.GenericFragment
import androidx.core.net.toUri
@UiThread
class ThirdPartySipAccountWarningFragment : GenericFragment() {
@ -61,7 +61,7 @@ class ThirdPartySipAccountWarningFragment : GenericFragment() {
binding.setContactClickListener {
val url = getString(R.string.website_contact_url)
try {
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
val browserIntent = Intent(Intent.ACTION_VIEW, url.toUri())
startActivity(browserIntent)
} catch (ise: IllegalStateException) {
Log.e(

View file

@ -33,6 +33,7 @@ import kotlinx.coroutines.withContext
import org.json.JSONException
import org.json.JSONObject
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.LinphoneApplication.Companion.corePreferences
import org.linphone.R
import org.linphone.core.Account
import org.linphone.core.AccountManagerServices
@ -82,6 +83,8 @@ class AccountCreationViewModel
val showPassword = MutableLiveData<Boolean>()
val lockUsernameAndPassword = MutableLiveData<Boolean>()
val createEnabled = MediatorLiveData<Boolean>()
val pushNotificationsAvailable = MutableLiveData<Boolean>()
@ -165,14 +168,7 @@ class AccountCreationViewModel
operationInProgress.postValue(false)
if (!errorMessage.isNullOrEmpty()) {
showFormattedRedToastEvent.postValue(
Event(
Pair(
errorMessage,
R.drawable.warning_circle
)
)
)
showFormattedRedToast(errorMessage, R.drawable.warning_circle)
}
for (parameter in parameterErrors?.keys.orEmpty()) {
@ -199,12 +195,11 @@ class AccountCreationViewModel
if (account != null) {
coreContext.core.removeAccount(account)
}
createEnabled.postValue(true)
}
else -> {
createEnabled.postValue(true)
}
}
createEnabled.postValue(true)
}
}
@ -258,6 +253,7 @@ class AccountCreationViewModel
init {
operationInProgress.value = false
lockUsernameAndPassword.value = false
coreContext.postOnCoreThread { core ->
pushNotificationsAvailable.postValue(LinphoneUtils.arePushNotificationsAvailable(core))
@ -446,9 +442,9 @@ class AccountCreationViewModel
operationInProgress.postValue(true)
createEnabled.postValue(false)
val usernameValue = username.value
val passwordValue = password.value
if (usernameValue.isNullOrEmpty() || passwordValue.isNullOrEmpty()) {
val usernameValue = username.value.orEmpty().trim()
val passwordValue = password.value.orEmpty().trim()
if (usernameValue.isEmpty() || passwordValue.isEmpty()) {
Log.e("$TAG Either username [$usernameValue] or password is null or empty!")
return
}
@ -469,15 +465,16 @@ class AccountCreationViewModel
@WorkerThread
private fun storeAccountInCore(identity: String) {
val passwordValue = password.value
val core = coreContext.core
core.loadConfigFromXml(corePreferences.linphoneDefaultValuesPath)
val sipIdentity = Factory.instance().createAddress(identity)
if (sipIdentity == null) {
Log.e("$TAG Failed to create address from SIP Identity [$identity]!")
return
}
val passwordValue = password.value
// We need to have an AuthInfo for newly created account to authorize phone number linking request
val authInfo = Factory.instance().createAuthInfo(
sipIdentity.username.orEmpty(),
@ -506,6 +503,8 @@ class AccountCreationViewModel
accountCreatedAuthInfo = authInfo
accountCreated = account
lockUsernameAndPassword.postValue(true)
}
@WorkerThread

View file

@ -172,17 +172,12 @@ open class AccountLoginViewModel
"sip:$userInput@$defaultDomain"
}
}
Log.i("$TAG Computed identity is [$identity] from user input [$userInput]")
val identityAddress = Factory.instance().createAddress(identity)
if (identityAddress == null) {
Log.e("$TAG Can't parse [$identity] as Address!")
showRedToastEvent.postValue(
Event(
Pair(
R.string.assistant_login_cant_parse_address_toast,
R.drawable.warning_circle
)
)
)
showRedToast(R.string.assistant_login_cant_parse_address_toast, R.drawable.warning_circle)
return@postOnCoreThread
}
@ -191,14 +186,7 @@ open class AccountLoginViewModel
Log.e(
"$TAG Address [${identityAddress.asStringUriOnly()}] doesn't contains an username!"
)
showRedToastEvent.postValue(
Event(
Pair(
R.string.assistant_login_address_without_username_toast,
R.drawable.warning_circle
)
)
)
showRedToast(R.string.assistant_login_address_without_username_toast, R.drawable.warning_circle)
return@postOnCoreThread
}

View file

@ -30,6 +30,7 @@ import org.linphone.core.CoreListenerStub
import org.linphone.core.tools.Log
import org.linphone.ui.GenericViewModel
import org.linphone.utils.Event
import org.linphone.R
class QrCodeViewModel
@UiThread
@ -40,12 +41,18 @@ class QrCodeViewModel
val qrCodeFoundEvent = MutableLiveData<Event<Boolean>>()
val onErrorEvent = MutableLiveData<Event<Boolean>>()
private val coreListener = object : CoreListenerStub() {
@WorkerThread
override fun onConfiguringStatus(core: Core, status: ConfiguringState, message: String?) {
Log.i("$TAG Configuring state is [$status]")
if (status == ConfiguringState.Successful) {
qrCodeFoundEvent.postValue(Event(true))
} else if (status == ConfiguringState.Failed) {
Log.e("$TAG Failure applying remote provisioning: $message")
showRedToast(R.string.remote_provisioning_config_failed_toast, R.drawable.warning_circle)
onErrorEvent.postValue(Event(true))
}
}
@ -53,16 +60,20 @@ class QrCodeViewModel
override fun onQrcodeFound(core: Core, result: String?) {
Log.i("$TAG QR Code found: [$result]")
if (result == null) {
qrCodeFoundEvent.postValue(Event(false))
showRedToast(R.string.assistant_qr_code_invalid_toast, R.drawable.warning_circle)
} else {
val isValidUrl = Patterns.WEB_URL.matcher(result).matches()
if (!isValidUrl) {
Log.e("$TAG The content of the QR Code doesn't seem to be a valid web URL")
qrCodeFoundEvent.postValue(Event(false))
showRedToast(R.string.assistant_qr_code_invalid_toast, R.drawable.warning_circle)
} else {
Log.i(
"$TAG QR code URL set, restarting the Core to apply configuration changes"
)
core.nativePreviewWindowId = null
core.isVideoPreviewEnabled = false
core.isQrcodeVideoPreviewEnabled = false
core.provisioningUri = result
coreContext.core.stop()
Log.i("$TAG Core has been stopped, restarting it")

View file

@ -181,32 +181,25 @@ class ThirdPartySipAccountLoginViewModel
// Allow to enter SIP identity instead of simply username
// in case identity domain doesn't match proxy domain
val user = username.value.orEmpty().trim()
val userId = authId.value.orEmpty().trim()
val identity = if (user.startsWith("sip:")) {
if (user.contains("@")) {
user
} else {
"$user@$domain"
}
} else {
if (user.contains("@")) {
"sip:$user"
} else {
"sip:$user@$domain"
}
var user = username.value.orEmpty().trim()
if (user.startsWith("sip:")) {
user = user.substring("sip:".length)
} else if (user.startsWith("sips:")) {
user = user.substring("sips:".length)
}
if (user.contains("@")) {
user = user.split("@")[0]
}
val userId = authId.value.orEmpty().trim()
Log.i("$TAG Parsed username is [$user], user ID [$userId] and domain [$domain]")
val identity = "sip:$user@$domain"
val identityAddress = Factory.instance().createAddress(identity)
if (identityAddress == null) {
Log.e("$TAG Can't parse [$identity] as Address!")
showRedToastEvent.postValue(
Event(
Pair(
R.string.assistant_login_cant_parse_address_toast,
R.drawable.warning_circle
)
)
)
showRedToast(R.string.assistant_login_cant_parse_address_toast, R.drawable.warning_circle)
return@postOnCoreThread
}
@ -216,7 +209,7 @@ class ThirdPartySipAccountLoginViewModel
password.value.orEmpty().trim(),
null,
null,
null
domainValue
)
core.addAuthInfo(newlyCreatedAuthInfo)

View file

@ -125,10 +125,13 @@ class ActiveConferenceCallFragment : GenericCallFragment() {
callViewModel = requireActivity().run {
ViewModelProvider(this)[CurrentCallViewModel::class.java]
}
observeToastEvents(callViewModel)
observeToastEvents(callViewModel.conferenceModel)
callsViewModel = requireActivity().run {
ViewModelProvider(this)[CallsViewModel::class.java]
}
observeToastEvents(callsViewModel)
binding.lifecycleOwner = viewLifecycleOwner
binding.viewModel = callViewModel
@ -224,17 +227,12 @@ class ActiveConferenceCallFragment : GenericCallFragment() {
}
callViewModel.conferenceModel.goToConversationEvent.observe(viewLifecycleOwner) {
it.consume { pair ->
it.consume { conversationId ->
if (findNavController().currentDestination?.id == R.id.activeConferenceCallFragment) {
val localSipUri = pair.first
val remoteSipUri = pair.second
Log.i(
"$TAG Display conversation with local SIP URI [$localSipUri] and remote SIP URI [$remoteSipUri]"
)
Log.i("$TAG Display conversation with conversation ID [$conversationId]")
val action =
ActiveConferenceCallFragmentDirections.actionActiveConferenceCallFragmentToInCallConversationFragment(
localSipUri,
remoteSipUri
conversationId
)
findNavController().navigate(action)
}

View file

@ -30,10 +30,12 @@ import android.view.ViewGroup
import android.view.animation.Animation
import android.view.animation.AnimationUtils
import android.widget.PopupWindow
import androidx.core.view.doOnLayout
import androidx.databinding.DataBindingUtil
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.LinearLayoutManager
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.R
import org.linphone.core.Participant
import org.linphone.core.tools.Log
@ -129,6 +131,32 @@ class ConferenceParticipantsListFragment : GenericCallFragment() {
showKickParticipantDialog(displayName, participant)
}
}
viewModel.isSendingVideo.observe(viewLifecycleOwner) { sending ->
coreContext.postOnCoreThread { core ->
core.nativePreviewWindowId = if (sending) {
Log.i("$TAG We are sending video, setting capture preview surface")
binding.localPreviewVideoSurface
} else {
Log.i("$TAG We are not sending video, clearing capture preview surface")
null
}
}
}
}
override fun onResume() {
super.onResume()
(binding.root as? ViewGroup)?.doOnLayout {
setupVideoPreview(binding.localPreviewVideoSurface)
}
}
override fun onPause() {
super.onPause()
cleanVideoPreview(binding.localPreviewVideoSurface)
}
private fun showKickParticipantDialog(displayName: String, participant: Participant) {

View file

@ -26,6 +26,7 @@ import android.widget.GridLayout
import androidx.annotation.UiThread
import androidx.core.view.children
import org.linphone.core.tools.Log
import androidx.core.view.isEmpty
@UiThread
class GridBoxLayout : GridLayout {
@ -58,7 +59,7 @@ class GridBoxLayout : GridLayout {
@SuppressLint("DrawAllocation")
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
if (childCount == 0 || (!changed && previousChildCount == childCount)) {
if (isEmpty() || (!changed && previousChildCount == childCount)) {
super.onLayout(changed, left, top, right, bottom)
// To prevent display issue the first time conference is locally paused
children.forEach { child ->

View file

@ -39,6 +39,7 @@ import org.linphone.ui.call.conference.model.ConferenceParticipantModel
import org.linphone.ui.call.conference.view.GridBoxLayout
import org.linphone.utils.AppUtils
import org.linphone.utils.Event
import org.linphone.utils.LinphoneUtils
class ConferenceViewModel
@UiThread
@ -91,8 +92,8 @@ class ConferenceViewModel
MutableLiveData<Event<Pair<String, Participant>>>()
}
val goToConversationEvent: MutableLiveData<Event<Pair<String, String>>> by lazy {
MutableLiveData<Event<Pair<String, String>>>()
val goToConversationEvent: MutableLiveData<Event<String>> by lazy {
MutableLiveData<Event<String>>()
}
private lateinit var conference: Conference
@ -134,22 +135,36 @@ class ConferenceViewModel
@WorkerThread
override fun onActiveSpeakerParticipantDevice(
conference: Conference,
participantDevice: ParticipantDevice
participantDevice: ParticipantDevice?
) {
activeSpeaker.value?.isActiveSpeaker?.postValue(false)
val found = participantDevices.value.orEmpty().find {
it.device.address.equal(participantDevice.address)
}
if (found != null) {
Log.i("$TAG Newly active speaker participant is [${found.name}]")
found.isActiveSpeaker.postValue(true)
activeSpeaker.postValue(found!!)
if (participantDevice != null) {
val found = participantDevices.value.orEmpty().find {
it.device.address.equal(participantDevice.address)
}
if (found != null) {
Log.i("$TAG Newly active speaker participant is [${found.name}]")
found.isActiveSpeaker.postValue(true)
activeSpeaker.postValue(found!!)
} else {
Log.i("$TAG Failed to find actively speaking participant...")
val model = ConferenceParticipantDeviceModel(participantDevice)
model.isActiveSpeaker.postValue(true)
activeSpeaker.postValue(model)
}
} else {
Log.i("$TAG Failed to find actively speaking participant...")
val model = ConferenceParticipantDeviceModel(participantDevice)
model.isActiveSpeaker.postValue(true)
activeSpeaker.postValue(model)
Log.w("$TAG Notified active speaker participant device is null, using first one that's not us")
val firstNotUs = participantDevices.value.orEmpty().find {
it.isMe == false
}
if (firstNotUs != null) {
Log.i("$TAG Newly active speaker participant is [${firstNotUs.name}]")
firstNotUs.isActiveSpeaker.postValue(true)
activeSpeaker.postValue(firstNotUs!!)
} else {
Log.i("$TAG No participant device that's not us found, expected if we're alone")
}
}
}
@ -355,14 +370,7 @@ class ConferenceViewModel
Log.i("$TAG Navigating to conference's conversation")
val chatRoom = conference.chatRoom
if (chatRoom != null) {
goToConversationEvent.postValue(
Event(
Pair(
chatRoom.localAddress.asStringUriOnly(),
chatRoom.peerAddress.asStringUriOnly()
)
)
)
goToConversationEvent.postValue(Event(LinphoneUtils.getConversationId(chatRoom)))
} else {
Log.e(
"$TAG No chat room available for current conference [${conference.conferenceAddress?.asStringUriOnly()}]"
@ -396,14 +404,7 @@ class ConferenceViewModel
Log.e(
"$TAG Failed to parse SIP URI [$uri] into address, can't add it to the conference!"
)
showRedToastEvent.postValue(
Event(
Pair(
R.string.conference_failed_to_add_participant_invalid_address_toast,
R.drawable.warning_circle
)
)
)
showRedToast(R.string.conference_failed_to_add_participant_invalid_address_toast, R.drawable.warning_circle)
}
}
val addressesArray = arrayOfNulls<Address>(addresses.size)
@ -796,14 +797,7 @@ class ConferenceViewModel
"$TAG Too many participant devices for grid layout, switching to active speaker layout"
)
setNewLayout(ACTIVE_SPEAKER_LAYOUT)
showRedToastEvent.postValue(
Event(
Pair(
R.string.conference_too_many_participants_for_mosaic_layout_toast,
R.drawable.warning_circle
)
)
)
showRedToast(R.string.conference_too_many_participants_for_mosaic_layout_toast, R.drawable.warning_circle)
}
}
}

View file

@ -154,10 +154,12 @@ class ActiveCallFragment : GenericCallFragment() {
callViewModel = requireActivity().run {
ViewModelProvider(this)[CurrentCallViewModel::class.java]
}
observeToastEvents(callViewModel)
callsViewModel = requireActivity().run {
ViewModelProvider(this)[CallsViewModel::class.java]
}
observeToastEvents(callsViewModel)
binding.lifecycleOwner = viewLifecycleOwner
binding.viewModel = callViewModel
@ -369,17 +371,14 @@ class ActiveCallFragment : GenericCallFragment() {
}
callViewModel.goToConversationEvent.observe(viewLifecycleOwner) {
it.consume { pair ->
it.consume { conversationId ->
if (findNavController().currentDestination?.id == R.id.activeCallFragment) {
val localSipUri = pair.first
val remoteSipUri = pair.second
Log.i(
"$TAG Display conversation with local SIP URI [$localSipUri] and remote SIP URI [$remoteSipUri]"
"$TAG Display conversation with conversation ID [$conversationId]"
)
val action =
ActiveCallFragmentDirections.actionActiveCallFragmentToInCallConversationFragment(
localSipUri,
remoteSipUri
conversationId
)
findNavController().navigate(action)
}

View file

@ -23,14 +23,17 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.doOnLayout
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.core.tools.Log
import org.linphone.databinding.CallsListFragmentBinding
import org.linphone.ui.call.adapter.CallsListAdapter
import org.linphone.ui.call.viewmodel.CallsViewModel
import org.linphone.ui.call.viewmodel.CurrentCallViewModel
import org.linphone.utils.ConfirmationDialogModel
import org.linphone.utils.DialogUtils
@ -43,6 +46,8 @@ class CallsListFragment : GenericCallFragment() {
private lateinit var viewModel: CallsViewModel
private lateinit var callViewModel: CurrentCallViewModel
private lateinit var adapter: CallsListAdapter
private var bottomSheetDialog: BottomSheetDialogFragment? = null
@ -73,6 +78,11 @@ class CallsListFragment : GenericCallFragment() {
binding.viewModel = viewModel
observeToastEvents(viewModel)
callViewModel = requireActivity().run {
ViewModelProvider(this)[CurrentCallViewModel::class.java]
}
observeToastEvents(callViewModel)
binding.callsList.setHasFixedSize(true)
binding.callsList.layoutManager = LinearLayoutManager(requireContext())
@ -101,6 +111,18 @@ class CallsListFragment : GenericCallFragment() {
showMergeCallsIntoConferenceConfirmationDialog()
}
callViewModel.isSendingVideo.observe(viewLifecycleOwner) { sending ->
coreContext.postOnCoreThread { core ->
core.nativePreviewWindowId = if (sending) {
Log.i("$TAG We are sending video, setting capture preview surface")
binding.localPreviewVideoSurface
} else {
Log.i("$TAG We are not sending video, clearing capture preview surface")
null
}
}
}
viewModel.calls.observe(viewLifecycleOwner) {
Log.i("$TAG Calls list updated with [${it.size}] items")
adapter.submitList(it)
@ -113,11 +135,21 @@ class CallsListFragment : GenericCallFragment() {
}
}
override fun onResume() {
super.onResume()
(binding.root as? ViewGroup)?.doOnLayout {
setupVideoPreview(binding.localPreviewVideoSurface)
}
}
override fun onPause() {
super.onPause()
bottomSheetDialog?.dismiss()
bottomSheetDialog = null
cleanVideoPreview(binding.localPreviewVideoSurface)
}
private fun showMergeCallsIntoConferenceConfirmationDialog() {

View file

@ -20,7 +20,6 @@
package org.linphone.ui.call.fragment
import android.os.Bundle
import android.os.SystemClock
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@ -67,16 +66,12 @@ class EndedCallFragment : GenericCallFragment() {
callViewModel = requireActivity().run {
ViewModelProvider(this)[CurrentCallViewModel::class.java]
}
observeToastEvents(callViewModel)
binding.lifecycleOwner = viewLifecycleOwner
binding.viewModel = callViewModel
Log.i("$TAG Showing ended call fragment")
callViewModel.callDuration.observe(viewLifecycleOwner) { duration ->
binding.chronometer.base = SystemClock.elapsedRealtime() - (1000 * duration)
binding.chronometer.stop() // Do not start it and make sure it is stopped
}
}
override fun onResume() {
@ -84,7 +79,7 @@ class EndedCallFragment : GenericCallFragment() {
lifecycleScope.launch {
withContext(Dispatchers.IO) {
if (callViewModel.terminatedByUsed) {
if (callViewModel.terminatedByUser) {
Log.i(
"$TAG Call terminated by user, waiting 1 second before finishing activity"
)

View file

@ -55,6 +55,7 @@ class IncomingCallFragment : GenericCallFragment() {
callViewModel = requireActivity().run {
ViewModelProvider(this)[CurrentCallViewModel::class.java]
}
observeToastEvents(callViewModel)
binding.lifecycleOwner = viewLifecycleOwner
binding.viewModel = callViewModel

View file

@ -56,6 +56,7 @@ class OutgoingCallFragment : GenericCallFragment() {
callViewModel = requireActivity().run {
ViewModelProvider(this)[CurrentCallViewModel::class.java]
}
observeToastEvents(callViewModel)
binding.lifecycleOwner = viewLifecycleOwner
binding.viewModel = callViewModel

View file

@ -41,8 +41,6 @@ import org.linphone.ui.call.model.CallModel
import org.linphone.ui.call.viewmodel.CallsViewModel
import org.linphone.ui.call.viewmodel.CurrentCallViewModel
import org.linphone.ui.main.adapter.ConversationsContactsAndSuggestionsListAdapter
import org.linphone.ui.main.contacts.model.ContactNumberOrAddressClickListener
import org.linphone.ui.main.contacts.model.ContactNumberOrAddressModel
import org.linphone.ui.main.history.viewmodel.StartCallViewModel
import org.linphone.utils.ConfirmationDialogModel
import org.linphone.ui.main.model.ConversationContactOrSuggestionModel
@ -75,22 +73,6 @@ class TransferCallFragment : GenericCallFragment() {
private var numberOrAddressPickerDialog: Dialog? = null
private val listener = object : ContactNumberOrAddressClickListener {
@UiThread
override fun onClicked(model: ContactNumberOrAddressModel) {
val address = model.address
if (address != null) {
coreContext.postOnCoreThread {
// TODO FIXME: transfer call (blind)
}
}
}
@UiThread
override fun onLongPress(model: ContactNumberOrAddressModel) {
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -115,10 +97,12 @@ class TransferCallFragment : GenericCallFragment() {
callViewModel = requireActivity().run {
ViewModelProvider(this)[CurrentCallViewModel::class.java]
}
observeToastEvents(callViewModel)
callsViewModel = requireActivity().run {
ViewModelProvider(this)[CallsViewModel::class.java]
}
observeToastEvents(callsViewModel)
binding.viewModel = viewModel
binding.callsViewModel = callsViewModel
@ -258,7 +242,7 @@ class TransferCallFragment : GenericCallFragment() {
private fun showConfirmAttendedTransferDialog(callModel: CallModel) {
val label = AppUtils.getFormattedString(
org.linphone.R.string.call_transfer_confirm_dialog_message,
R.string.call_transfer_confirm_dialog_message,
callViewModel.displayedName.value.orEmpty(),
callModel.displayName.value.orEmpty()
)
@ -294,7 +278,7 @@ class TransferCallFragment : GenericCallFragment() {
private fun showConfirmBlindTransferDialog(contactModel: ConversationContactOrSuggestionModel) {
val label = AppUtils.getFormattedString(
org.linphone.R.string.call_transfer_confirm_dialog_message,
R.string.call_transfer_confirm_dialog_message,
callViewModel.displayedName.value.orEmpty(),
contactModel.name
)

View file

@ -27,10 +27,6 @@ import org.linphone.utils.Event
class ZrtpAlertDialogModel
@UiThread
constructor(val allowTryAgain: Boolean) : GenericViewModel() {
companion object {
private const val TAG = "[ZRTP Alert Dialog]"
}
val tryAgainEvent = MutableLiveData<Event<Boolean>>()
val hangUpEvent = MutableLiveData<Event<Boolean>>()

View file

@ -231,14 +231,7 @@ class CallsViewModel
val conference = LinphoneUtils.createGroupCall(defaultAccount, subject)
if (conference == null) {
Log.e("$TAG Failed to create conference!")
showRedToastEvent.postValue(
Event(
Pair(
R.string.conference_failed_to_merge_calls_into_conference_toast,
R.drawable.warning_circle
)
)
)
showRedToast(R.string.conference_failed_to_merge_calls_into_conference_toast, R.drawable.warning_circle)
} else {
conference.addParticipants(core.calls)
}

View file

@ -143,7 +143,7 @@ class CurrentCallViewModel
val qualityIcon = MutableLiveData<Int>()
var terminatedByUsed = false
var terminatedByUser = false
val isRemoteRecordingEvent: MutableLiveData<Event<Pair<Boolean, String>>> by lazy {
MutableLiveData<Event<Pair<Boolean, String>>>()
@ -199,8 +199,8 @@ class CurrentCallViewModel
val operationInProgress = MutableLiveData<Boolean>()
val goToConversationEvent: MutableLiveData<Event<Pair<String, String>>> by lazy {
MutableLiveData<Event<Pair<String, String>>>()
val goToConversationEvent: MutableLiveData<Event<String>> by lazy {
MutableLiveData<Event<String>>()
}
val chatRoomCreationErrorEvent: MutableLiveData<Event<Int>> by lazy {
@ -358,7 +358,11 @@ class CurrentCallViewModel
videoUpdateInProgress.postValue(false)
updateCallDuration()
if (corePreferences.automaticallyStartCallRecording) {
isRecording.postValue(call.params.isRecording)
val recording = call.params.isRecording
isRecording.postValue(recording)
if (recording) {
showRecordingToast()
}
}
// MediaEncryption None & SRTP won't be notified through onEncryptionChanged callback,
@ -390,21 +394,14 @@ class CurrentCallViewModel
val state = chatRoom.state
if (state == ChatRoom.State.Instantiated) return
val id = LinphoneUtils.getChatRoomId(chatRoom)
val id = LinphoneUtils.getConversationId(chatRoom)
Log.i("$TAG Conversation [$id] (${chatRoom.subject}) state changed: [$state]")
if (state == ChatRoom.State.Created) {
Log.i("$TAG Conversation [$id] successfully created")
chatRoom.removeListener(this)
operationInProgress.postValue(false)
goToConversationEvent.postValue(
Event(
Pair(
chatRoom.localAddress.asStringUriOnly(),
chatRoom.peerAddress.asStringUriOnly()
)
)
)
goToConversationEvent.postValue(Event(LinphoneUtils.getConversationId(chatRoom)))
} else if (state == ChatRoom.State.CreationFailed) {
Log.e("$TAG Conversation [$id] creation has failed!")
chatRoom.removeListener(this)
@ -612,7 +609,7 @@ class CurrentCallViewModel
coreContext.postOnCoreThread {
if (::currentCall.isInitialized) {
Log.i("$TAG Terminating call [${currentCall.remoteAddress.asStringUriOnly()}]")
terminatedByUsed = true
terminatedByUser = true
coreContext.terminateCall(currentCall)
}
}
@ -706,7 +703,12 @@ class CurrentCallViewModel
val routeAudioToSpeaker = isSpeakerEnabled.value != true
coreContext.postOnCoreThread { core ->
var earpieceFound = false
var speakerFound = false
val audioDevices = core.audioDevices
val currentDevice = currentCall.outputAudioDevice
Log.i("$TAG Currently used output audio device is [${currentDevice?.deviceName} (${currentDevice?.type}])")
val list = arrayListOf<AudioDeviceModel>()
for (device in audioDevices) {
// Only list output audio devices
@ -714,9 +716,11 @@ class CurrentCallViewModel
val name = when (device.type) {
AudioDevice.Type.Earpiece -> {
earpieceFound = true
AppUtils.getString(R.string.call_audio_device_type_earpiece)
}
AudioDevice.Type.Speaker -> {
speakerFound = true
AppUtils.getString(R.string.call_audio_device_type_speaker)
}
AudioDevice.Type.Headset -> {
@ -739,7 +743,6 @@ class CurrentCallViewModel
}
else -> device.deviceName
}
val currentDevice = currentCall.outputAudioDevice
val isCurrentlyInUse = device.type == currentDevice?.type && device.deviceName == currentDevice.deviceName
val model = AudioDeviceModel(device, name, device.type, isCurrentlyInUse, true) {
// onSelected
@ -765,8 +768,8 @@ class CurrentCallViewModel
Log.i("$TAG Found audio device [${device.id}]")
}
if (list.size > 2) {
Log.i("$TAG Found more than two devices, showing list to let user choose")
if (list.size > 2 || (list.size > 1 && (!earpieceFound || !speakerFound))) {
Log.i("$TAG Found more than two devices (or more than 1 but no earpiece or speaker), showing list to let user choose")
showAudioDevicesListEvent.postValue(Event(list))
} else {
Log.i(
@ -855,8 +858,12 @@ class CurrentCallViewModel
Log.i("$TAG Starting call recording")
currentCall.startRecording()
}
val recording = currentCall.params.isRecording
isRecording.postValue(recording)
if (recording) {
showRecordingToast()
}
}
}
}
@ -921,17 +928,10 @@ class CurrentCallViewModel
if (existingConversation != null) {
Log.i(
"$TAG Found existing conversation [${
LinphoneUtils.getChatRoomId(existingConversation)
LinphoneUtils.getConversationId(existingConversation)
}], going to it"
)
goToConversationEvent.postValue(
Event(
Pair(
existingConversation.localAddress.asStringUriOnly(),
existingConversation.peerAddress.asStringUriOnly()
)
)
)
goToConversationEvent.postValue(Event(LinphoneUtils.getConversationId(existingConversation)))
} else {
Log.i("$TAG No existing conversation was found, let's create it")
createCurrentCallConversation(currentCall)
@ -1052,7 +1052,7 @@ class CurrentCallViewModel
)
contact.value?.destroy()
terminatedByUsed = false
terminatedByUser = false
currentCall = call
callStatsModel.update(call, call.audioStats)
callMediaEncryptionModel.update(call)
@ -1172,7 +1172,11 @@ class CurrentCallViewModel
contact.postValue(model)
displayedName.postValue(model.friend.name)
isRecording.postValue(call.params.isRecording)
val recording = call.params.isRecording
isRecording.postValue(recording)
if (recording) {
showRecordingToast()
}
val isRemoteRecording = call.remoteParams?.isRecording == true
if (isRemoteRecording) {
@ -1204,6 +1208,7 @@ class CurrentCallViewModel
@WorkerThread
private fun updateOutputAudioDevice(audioDevice: AudioDevice?) {
Log.i("$TAG Output audio device updated to [${audioDevice?.deviceName} (${audioDevice?.type})]")
isSpeakerEnabled.postValue(audioDevice?.type == AudioDevice.Type.Speaker)
isHeadsetEnabled.postValue(
audioDevice?.type == AudioDevice.Type.Headphones || audioDevice?.type == AudioDevice.Type.Headset
@ -1346,44 +1351,29 @@ class CurrentCallViewModel
@WorkerThread
private fun createCurrentCallConversation(call: Call) {
val localAddress = call.callLog.localAddress
val remoteAddress = call.remoteAddress
val participants = arrayOf(remoteAddress)
val core = call.core
operationInProgress.postValue(true)
val params = getChatRoomParams(call) ?: return // TODO: show error to user
val conversation = core.createChatRoom(params, localAddress, participants)
if (conversation != null) {
val chatRoom = core.createChatRoom(params, participants)
if (chatRoom != null) {
if (params.chatParams?.backend == ChatRoom.Backend.FlexisipChat) {
if (conversation.state == ChatRoom.State.Created) {
val id = LinphoneUtils.getChatRoomId(conversation)
if (chatRoom.state == ChatRoom.State.Created) {
val id = LinphoneUtils.getConversationId(chatRoom)
Log.i("$TAG 1-1 conversation [$id] has been created")
operationInProgress.postValue(false)
goToConversationEvent.postValue(
Event(
Pair(
conversation.localAddress.asStringUriOnly(),
conversation.peerAddress.asStringUriOnly()
)
)
)
goToConversationEvent.postValue(Event(LinphoneUtils.getConversationId(chatRoom)))
} else {
Log.i("$TAG Conversation isn't in Created state yet, wait for it")
conversation.addListener(chatRoomListener)
chatRoom.addListener(chatRoomListener)
}
} else {
val id = LinphoneUtils.getChatRoomId(conversation)
val id = LinphoneUtils.getConversationId(chatRoom)
Log.i("$TAG Conversation successfully created [$id]")
operationInProgress.postValue(false)
goToConversationEvent.postValue(
Event(
Pair(
conversation.localAddress.asStringUriOnly(),
conversation.peerAddress.asStringUriOnly()
)
)
)
goToConversationEvent.postValue(Event(LinphoneUtils.getConversationId(chatRoom)))
}
} else {
Log.e(
@ -1407,6 +1397,8 @@ class CurrentCallViewModel
params.isChatEnabled = true
params.isGroupEnabled = false
params.subject = AppUtils.getString(R.string.conversation_one_to_one_hidden_subject)
params.account = account
val chatParams = params.chatParams ?: return null
chatParams.ephemeralLifetime = 0 // Make sure ephemeral is disabled by default
@ -1471,4 +1463,9 @@ class CurrentCallViewModel
}
}
}
@AnyThread
private fun showRecordingToast() {
showGreenToast(R.string.call_is_being_recorded, R.drawable.record_fill)
}
}

View file

@ -1,7 +1,6 @@
package org.linphone.ui.fileviewer
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.util.DisplayMetrics
import androidx.activity.enableEdgeToEdge
@ -23,6 +22,7 @@ import org.linphone.ui.GenericActivity
import org.linphone.ui.fileviewer.adapter.PdfPagesListAdapter
import org.linphone.ui.fileviewer.viewmodel.FileViewModel
import org.linphone.utils.FileUtils
import androidx.core.net.toUri
@UiThread
class FileViewerActivity : GenericActivity() {
@ -73,11 +73,19 @@ class FileViewerActivity : GenericActivity() {
return
}
val isFromEphemeralMessage = args.getBoolean("isFromEphemeralMessage", false)
if (isFromEphemeralMessage) {
Log.i("$TAG Displayed content is from an ephemeral chat message, force secure mode to prevent screenshots")
// Force preventing screenshots for ephemeral messages contents
enableWindowSecureMode(true)
}
val timestamp = args.getLong("timestamp", -1)
val preLoadedContent = args.getString("content")
Log.i(
"$TAG Path argument is [$path], pre loaded text content is ${if (preLoadedContent.isNullOrEmpty()) "not available" else "available, using it"}"
)
viewModel.isFromEphemeralMessage.value = isFromEphemeralMessage
viewModel.loadFile(path, timestamp, preLoadedContent)
binding.setBackClickListener {
@ -178,7 +186,7 @@ class FileViewerActivity : GenericActivity() {
val filePath = FileUtils.getProperFilePath(viewModel.getFilePath())
val copy = FileUtils.getFilePath(
baseContext,
Uri.parse(filePath),
filePath.toUri(),
overrideExisting = false,
copyToCache = true
)

View file

@ -1,7 +1,6 @@
package org.linphone.ui.fileviewer
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.activity.enableEdgeToEdge
import androidx.annotation.UiThread
@ -17,6 +16,7 @@ import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.linphone.LinphoneApplication.Companion.corePreferences
import org.linphone.R
import org.linphone.core.tools.Log
import org.linphone.databinding.FileMediaViewerActivityBinding
@ -27,6 +27,7 @@ import org.linphone.ui.main.chat.model.FileModel
import org.linphone.ui.main.viewmodel.SharedMainViewModel
import org.linphone.utils.AppUtils
import org.linphone.utils.FileUtils
import androidx.core.net.toUri
@UiThread
class MediaViewerActivity : GenericActivity() {
@ -51,6 +52,13 @@ class MediaViewerActivity : GenericActivity() {
val model = list[position]
viewModel.currentlyDisplayedFileName.value = model.fileName
viewModel.currentlyDisplayedFileDateTime.value = model.dateTime
val isFromEphemeral = model.isFromEphemeralMessage
viewModel.isCurrentlyDisplayedFileFromEphemeralMessage.value = isFromEphemeral
if (!corePreferences.enableSecureMode) {
// Force preventing screenshots for ephemeral messages contents, but allow it for others
enableWindowSecureMode(isFromEphemeral)
}
}
}
}
@ -96,17 +104,24 @@ class MediaViewerActivity : GenericActivity() {
return
}
val isFromEphemeralMessage = args.getBoolean("isFromEphemeralMessage", false)
if (isFromEphemeralMessage) {
Log.i("$TAG Displayed content is from an ephemeral chat message, force secure mode to prevent screenshots")
// Force preventing screenshots for ephemeral messages contents
enableWindowSecureMode(true)
}
val timestamp = args.getLong("timestamp", -1)
val isEncrypted = args.getBoolean("isEncrypted", false)
val originalPath = args.getString("originalPath", "")
viewModel.initTempModel(path, timestamp, isEncrypted, originalPath)
Log.i("$TAG Path argument is [$path], timestamp [$timestamp], encrypted [$isEncrypted] and original path [$originalPath]")
viewModel.initTempModel(path, timestamp, isEncrypted, originalPath, isFromEphemeralMessage)
val localSipUri = args.getString("localSipUri").orEmpty()
val remoteSipUri = args.getString("remoteSipUri").orEmpty()
val conversationId = args.getString("conversationId").orEmpty()
Log.i(
"$TAG Looking up for conversation with local SIP URI [$localSipUri] and remote SIP URI [$remoteSipUri] trying to display file [$path]"
"$TAG Looking up for conversation with conversation ID [$conversationId] trying to display file [$path]"
)
viewModel.findChatRoom(null, localSipUri, remoteSipUri)
viewModel.findChatRoom(null, conversationId)
viewModel.mediaList.observe(this) {
updateMediaList(path, it)
@ -232,7 +247,7 @@ class MediaViewerActivity : GenericActivity() {
val filePath = FileUtils.getProperFilePath(model.path)
val copy = FileUtils.getFilePath(
baseContext,
Uri.parse(filePath),
filePath.toUri(),
overrideExisting = true,
copyToCache = true
)

View file

@ -24,10 +24,6 @@ import android.util.AttributeSet
import android.view.TextureView
class RatioTextureView : TextureView {
companion object {
private const val TAG = "[Ratio TextureView]"
}
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
@ -38,13 +34,6 @@ class RatioTextureView : TextureView {
defStyleAttr
)
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int) : super(
context,
attrs,
defStyleAttr,
defStyleRes
)
private var ratioWidth = 0
private var rationHeight = 0

View file

@ -19,7 +19,6 @@
*/
package org.linphone.ui.fileviewer.viewmodel
import android.graphics.Bitmap
import android.graphics.pdf.PdfRenderer
import android.net.Uri
import android.os.ParcelFileDescriptor
@ -31,7 +30,6 @@ import java.io.BufferedReader
import java.io.File
import java.io.FileReader
import java.lang.IllegalStateException
import java.lang.StringBuilder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ -41,6 +39,8 @@ import org.linphone.ui.GenericViewModel
import org.linphone.utils.Event
import org.linphone.utils.FileUtils
import org.linphone.utils.TimestampUtils
import androidx.core.net.toUri
import androidx.core.graphics.createBitmap
class FileViewModel
@UiThread
@ -69,6 +69,8 @@ class FileViewModel
val dateTime = MutableLiveData<String>()
val isFromEphemeralMessage = MutableLiveData<Boolean>()
val exportPlainTextFileEvent: MutableLiveData<Event<String>> by lazy {
MutableLiveData<Event<String>>()
}
@ -178,11 +180,7 @@ class FileViewModel
Log.d(
"$TAG Page size is ${page.width}/${page.height}, screen size is $screenWidth/$screenHeight"
)
val bm = Bitmap.createBitmap(
page.width,
page.height,
Bitmap.Config.ARGB_8888
)
val bm = createBitmap(page.width, page.height)
page.render(bm, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
page.close()
@ -230,7 +228,7 @@ class FileViewModel
@UiThread
fun copyFileToUri(dest: Uri) {
val source = Uri.parse(FileUtils.getProperFilePath(getFilePath()))
val source = FileUtils.getProperFilePath(getFilePath()).toUri()
Log.i("$TAG Copying file URI [$source] to [$dest]")
viewModelScope.launch {
withContext(Dispatchers.IO) {
@ -239,24 +237,10 @@ class FileViewModel
Log.i(
"$TAG File [$filePath] has been successfully exported to documents"
)
showGreenToastEvent.postValue(
Event(
Pair(
R.string.file_successfully_exported_to_documents_toast,
R.drawable.check
)
)
)
showGreenToast(R.string.file_successfully_exported_to_documents_toast, R.drawable.check)
} else {
Log.e("$TAG Failed to export file [$filePath] to documents!")
showRedToastEvent.postValue(
Event(
Pair(
R.string.export_file_to_documents_error_toast,
R.drawable.warning_circle
)
)
)
showRedToast(R.string.export_file_to_documents_error_toast, R.drawable.warning_circle)
}
}
}
@ -272,24 +256,13 @@ class FileViewModel
Log.i(
"$TAG Text has been successfully exported to documents"
)
showGreenToastEvent.postValue(
Event(
Pair(
showGreenToast(
R.string.file_successfully_exported_to_documents_toast,
R.drawable.check
)
)
)
} else {
Log.e("$TAG Failed to save text to documents!")
showRedToastEvent.postValue(
Event(
Pair(
R.string.export_file_to_documents_error_toast,
R.drawable.warning_circle
)
)
)
showRedToast(R.string.export_file_to_documents_error_toast, R.drawable.warning_circle)
}
}
}
@ -337,14 +310,7 @@ 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
)
)
)
showRedToast(R.string.conversation_file_cant_be_opened_error_toast, R.drawable.warning_circle)
}
}
}

View file

@ -41,6 +41,8 @@ class MediaListViewModel
val currentlyDisplayedFileDateTime = MutableLiveData<String>()
val isCurrentlyDisplayedFileFromEphemeralMessage = MutableLiveData<Boolean>()
private lateinit var temporaryModel: FileModel
override fun beforeNotifyingChatRoomFound(sameOne: Boolean) {
@ -57,9 +59,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))
@ -68,7 +70,7 @@ class MediaListViewModel
@WorkerThread
private fun loadMediaList() {
val list = arrayListOf<FileModel>()
val chatRoomId = LinphoneUtils.getChatRoomId(chatRoom)
val chatRoomId = LinphoneUtils.getConversationId(chatRoom)
Log.i("$TAG Loading media contents for conversation [$chatRoomId]")
val media = chatRoom.mediaContents
@ -90,20 +92,37 @@ class MediaListViewModel
Log.d(
"$TAG [VFS] Content is encrypted, requesting plain file path for file [${mediaContent.filePath}]"
)
mediaContent.exportPlainFile()
val exportedPath = mediaContent.exportPlainFile()
Log.i("$TAG Media original path is [$originalPath], newly exported plain file path is [$exportedPath]")
exportedPath
} else {
originalPath
}
val name = mediaContent.name.orEmpty()
val size = mediaContent.size.toLong()
val timestamp = mediaContent.creationTimestamp
if (path.isNotEmpty() && name.isNotEmpty()) {
val model = FileModel(path, name, size, timestamp, isEncrypted, originalPath)
val messageId = mediaContent.relatedChatMessageId
val ephemeral = if (messageId != null) {
val chatMessage = chatRoom.findMessage(messageId)
if (chatMessage == null) {
Log.w("$TAG Failed to find message using ID [$messageId] related to this content, can't get real info about being related to ephemeral message")
}
chatMessage?.isEphemeral ?: chatRoom.isEphemeralEnabled
} else {
Log.e("$TAG No chat message ID related to this content, can't get real info about being related to ephemeral message")
chatRoom.isEphemeralEnabled
}
val model = FileModel(path, name, size, timestamp, isEncrypted, originalPath, ephemeral)
list.add(model)
} else {
Log.w("$TAG Skipping content because either name [$name] or path [$path] is empty")
}
if (tempFilePath.isNotEmpty() && !tempFileModelFound) {
if (path == tempFilePath) {
if (path == tempFilePath || (isEncrypted && originalPath == temporaryModel.originalPath)) {
tempFileModelFound = true
}
}

View file

@ -34,6 +34,7 @@ import org.linphone.ui.GenericViewModel
import org.linphone.utils.Event
import org.linphone.utils.FileUtils
import org.linphone.utils.TimestampUtils
import org.linphone.R
class MediaViewModel
@UiThread
@ -163,33 +164,40 @@ class MediaViewModel
private fun initMediaPlayer() {
isMediaPlaying.value = false
mediaPlayer = MediaPlayer().apply {
setAudioAttributes(
AudioAttributes.Builder().setContentType(AudioAttributes.CONTENT_TYPE_MUSIC).setUsage(
AudioAttributes.USAGE_MEDIA
).build()
)
setDataSource(filePath)
setOnCompletionListener {
Log.i("$TAG Media player reached the end of file")
isMediaPlaying.postValue(false)
position.postValue(0)
stopUpdatePlaybackPosition()
try {
mediaPlayer = MediaPlayer().apply {
setAudioAttributes(
AudioAttributes.Builder().setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.setUsage(
AudioAttributes.USAGE_MEDIA
).build()
)
setDataSource(filePath)
setOnCompletionListener {
Log.i("$TAG Media player reached the end of file")
isMediaPlaying.postValue(false)
position.postValue(0)
stopUpdatePlaybackPosition()
// Leave full screen when playback is done
fullScreenMode.postValue(false)
changeFullScreenModeEvent.postValue(Event(false))
}
setOnVideoSizeChangedListener { mediaPlayer, width, height ->
videoSizeChangedEvent.postValue(Event(Pair(width, height)))
}
try {
prepare()
} catch (e: Exception) {
fullScreenMode.postValue(false)
changeFullScreenModeEvent.postValue(Event(false))
Log.e("$TAG Failed to prepare video file: $e")
// Leave full screen when playback is done
fullScreenMode.postValue(false)
changeFullScreenModeEvent.postValue(Event(false))
}
setOnVideoSizeChangedListener { mediaPlayer, width, height ->
videoSizeChangedEvent.postValue(Event(Pair(width, height)))
}
try {
prepare()
} catch (e: Exception) {
fullScreenMode.postValue(false)
changeFullScreenModeEvent.postValue(Event(false))
Log.e("$TAG Failed to prepare video file: $e")
}
}
} catch (e: Exception) {
Log.e("$TAG Failed to initialize media player for file [$filePath]: $e")
showRedToast(R.string.media_player_generic_error_toast, R.drawable.warning_circle)
return
}
val durationInMillis = mediaPlayer.duration

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
@ -86,6 +88,9 @@ class MainActivity : GenericActivity() {
private const val HISTORY_FRAGMENT_ID = 2
private const val CHAT_FRAGMENT_ID = 3
private const val MEETINGS_FRAGMENT_ID = 4
const val ARGUMENTS_CHAT = "Chat"
const val ARGUMENTS_CONVERSATION_ID = "ConversationId"
}
private lateinit var binding: MainActivityBinding
@ -279,6 +284,12 @@ class MainActivity : GenericActivity() {
}
}
coreContext.clearAuthenticationRequestDialogEvent.observe(this) {
it.consume {
currentlyDisplayedAuthDialog?.dismiss()
}
}
coreContext.showGreenToastEvent.observe(this) {
it.consume { pair ->
val message = getString(pair.first)
@ -310,6 +321,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"
@ -514,14 +538,9 @@ class MainActivity : GenericActivity() {
private fun handleLocusOrShortcut(id: String) {
Log.i("$TAG Found locus ID [$id]")
val pair = LinphoneUtils.getLocalAndPeerSipUrisFromChatRoomId(id)
if (pair != null) {
val localSipUri = pair.first
val remoteSipUri = pair.second
Log.i(
"$TAG Navigating to conversation with local [$localSipUri] and peer [$remoteSipUri] addresses, computed from shortcut ID"
)
sharedViewModel.showConversationEvent.value = Event(pair)
if (id.isNotEmpty()) {
Log.i("$TAG Navigating to conversation with ID [$id], computed from shortcut ID")
sharedViewModel.showConversationEvent.value = Event(id)
}
}
@ -547,22 +566,18 @@ class MainActivity : GenericActivity() {
}
}
} else {
if (intent.hasExtra("Chat")) {
if (intent.hasExtra(ARGUMENTS_CHAT)) {
Log.i("$TAG Intent has [Chat] extra")
coreContext.postOnMainThread {
try {
Log.i("$TAG Trying to go to Conversations fragment")
val args = intent.extras
val localSipUri = args?.getString("LocalSipUri", "")
val remoteSipUri = args?.getString("RemoteSipUri", "")
if (remoteSipUri.isNullOrEmpty() || localSipUri.isNullOrEmpty()) {
Log.w("$TAG Found [Chat] extra but no local and/or remote SIP URI!")
val conversationId = args?.getString(ARGUMENTS_CONVERSATION_ID, "")
if (conversationId.isNullOrEmpty()) {
Log.w("$TAG Found [Chat] extra but no conversation ID!")
} else {
Log.i(
"$TAG Found [Chat] extra with local [$localSipUri] and peer [$remoteSipUri] addresses"
)
val pair = Pair(localSipUri, remoteSipUri)
sharedViewModel.showConversationEvent.value = Event(pair)
Log.i("$TAG Found [Chat] extra with conversation ID [$conversationId]")
sharedViewModel.showConversationEvent.value = Event(conversationId)
}
args?.clear()
@ -665,25 +680,23 @@ class MainActivity : GenericActivity() {
Log.i(
"$TAG App is already started and in debug fragment, navigating to conversations list"
)
val pair = parseShortcutIfAny(intent)
if (pair != null) {
val conversationId = parseShortcutIfAny(intent)
if (conversationId != null) {
Log.i(
"$TAG Navigating from debug to conversation with local [${pair.first}] and peer [${pair.second}] addresses, computed from shortcut ID"
"$TAG Navigating from debug to conversation with ID [$conversationId], computed from shortcut ID"
)
sharedViewModel.showConversationEvent.value = Event(pair)
sharedViewModel.showConversationEvent.value = Event(conversationId)
}
val action = DebugFragmentDirections.actionDebugFragmentToConversationsListFragment()
findNavController().navigate(action)
} else {
val pair = parseShortcutIfAny(intent)
if (pair != null) {
val localSipUri = pair.first
val remoteSipUri = pair.second
val conversationId = parseShortcutIfAny(intent)
if (conversationId != null) {
Log.i(
"$TAG Navigating to conversation with local [$localSipUri] and peer [$remoteSipUri] addresses, computed from shortcut ID"
"$TAG Navigating to conversation with conversation ID [$conversationId] addresses, computed from shortcut ID"
)
sharedViewModel.showConversationEvent.value = Event(pair)
sharedViewModel.showConversationEvent.value = Event(conversationId)
}
if (findNavController().currentDestination?.id == R.id.conversationsListFragment) {
@ -698,11 +711,11 @@ class MainActivity : GenericActivity() {
}
}
private fun parseShortcutIfAny(intent: Intent): Pair<String, String>? {
private fun parseShortcutIfAny(intent: Intent): String? {
val shortcutId = intent.getStringExtra("android.intent.extra.shortcut.ID") // Intent.EXTRA_SHORTCUT_ID
if (shortcutId != null) {
Log.i("$TAG Found shortcut ID [$shortcutId]")
return LinphoneUtils.getLocalAndPeerSipUrisFromChatRoomId(shortcutId)
return shortcutId
} else {
Log.i("$TAG No shortcut ID was found")
}
@ -785,4 +798,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

@ -87,7 +87,7 @@ class ConversationsContactsAndSuggestionsListAdapter :
override fun getItemViewType(position: Int): Int {
val model = getItem(position)
return if (model.localAddress != null) {
return if (model.conversationId.isNotEmpty()) {
CONVERSATION_TYPE
} else if (model.friend != null) {
if (model.starred) {

View file

@ -35,7 +35,7 @@ import org.linphone.core.tools.Log
import org.linphone.databinding.ChatBubbleIncomingBinding
import org.linphone.databinding.ChatBubbleOutgoingBinding
import org.linphone.databinding.ChatConversationEventBinding
import org.linphone.databinding.ChatConversationSecuredFirstEventBinding
import org.linphone.databinding.ChatConversationE2eEncryptedFirstEventBinding
import org.linphone.ui.main.chat.model.EventLogModel
import org.linphone.ui.main.chat.model.EventModel
import org.linphone.ui.main.chat.model.MessageModel
@ -70,13 +70,19 @@ class ConversationEventAdapter :
MutableLiveData<Event<MessageModel>>()
}
private var isConversationSecured: Boolean = false
fun setIsConversationSecured(secured: Boolean) {
isConversationSecured = secured
}
override fun displayHeaderForPosition(position: Int): Boolean {
// We only want to display it at top
return position == 0
}
override fun getHeaderViewForPosition(context: Context, position: Int): View {
val binding = ChatConversationSecuredFirstEventBinding.inflate(LayoutInflater.from(context))
val binding = ChatConversationE2eEncryptedFirstEventBinding.inflate(LayoutInflater.from(context))
return binding.root
}

View file

@ -29,7 +29,7 @@ import androidx.annotation.UiThread
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import org.linphone.databinding.ChatLongPressMenuBinding
import org.linphone.databinding.ChatConversationLongPressMenuBinding
@UiThread
class ConversationDialogFragment(
@ -71,7 +71,7 @@ class ConversationDialogFragment(
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val view = ChatLongPressMenuBinding.inflate(layoutInflater)
val view = ChatConversationLongPressMenuBinding.inflate(layoutInflater)
view.isMuted = isMuted
view.isGroup = isGroup
view.isReadOnly = isReadOnly

View file

@ -91,13 +91,10 @@ class ConversationDocumentsListFragment : SlidingPaneChildFragment() {
binding.viewModel = viewModel
observeToastEvents(viewModel)
val localSipUri = args.localSipUri
val remoteSipUri = args.remoteSipUri
Log.i(
"$TAG Looking up for conversation with local SIP URI [$localSipUri] and remote SIP URI [$remoteSipUri]"
)
val conversationId = args.conversationId
Log.i("$TAG Looking up for conversation with conversation ID [$conversationId]")
val chatRoom = sharedViewModel.displayedChatRoom
viewModel.findChatRoom(chatRoom, localSipUri, remoteSipUri)
viewModel.findChatRoom(chatRoom, conversationId)
val headerItemDecoration = RecyclerViewHeaderDecoration(requireContext(), adapter)
binding.documentsList.addItemDecoration(headerItemDecoration)
@ -143,12 +140,12 @@ class ConversationDocumentsListFragment : SlidingPaneChildFragment() {
val bundle = Bundle()
bundle.apply {
putString("localSipUri", viewModel.localSipUri)
putString("remoteSipUri", viewModel.remoteSipUri)
putString("conversationId", viewModel.conversationId)
putString("path", path)
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

@ -38,7 +38,6 @@ import org.linphone.ui.main.contacts.model.ContactNumberOrAddressModel
import org.linphone.ui.main.contacts.model.NumberOrAddressPickerDialogModel
import org.linphone.ui.main.fragment.SlidingPaneChildFragment
import org.linphone.utils.DialogUtils
import org.linphone.utils.Event
import org.linphone.utils.RecyclerViewHeaderDecoration
@UiThread
@ -66,11 +65,7 @@ class ConversationForwardMessageFragment : SlidingPaneChildFragment() {
override fun goBack(): Boolean {
sharedViewModel.messageToForwardEvent.value?.consume {
Log.w("$TAG Cancelling message forward")
viewModel.showRedToastEvent.postValue(
Event(
Pair(R.string.conversation_message_forward_cancelled_toast, R.drawable.forward)
)
)
viewModel.showRedToast(R.string.conversation_message_forward_cancelled_toast, R.drawable.forward)
}
return findNavController().popBackStack()
@ -125,17 +120,11 @@ class ConversationForwardMessageFragment : SlidingPaneChildFragment() {
}
viewModel.chatRoomCreatedEvent.observe(viewLifecycleOwner) {
it.consume { pair ->
Log.i(
"$TAG Navigating to conversation [${pair.second}] with local address [${pair.first}]"
)
it.consume { conversationId ->
Log.i("$TAG Navigating to conversation [$conversationId]")
if (findNavController().currentDestination?.id == R.id.conversationForwardMessageFragment) {
val localSipUri = pair.first
val remoteSipUri = pair.second
val action = ConversationForwardMessageFragmentDirections.actionConversationForwardMessageFragmentToConversationFragment(
localSipUri,
remoteSipUri
conversationId
)
disableConsumingEventOnPause = true
findNavController().navigate(action)

View file

@ -93,6 +93,7 @@ import org.linphone.utils.addCharacterAtPosition
import org.linphone.utils.hideKeyboard
import org.linphone.utils.setKeyboardInsetListener
import org.linphone.utils.showKeyboard
import androidx.core.net.toUri
@UiThread
open class ConversationFragment : SlidingPaneChildFragment() {
@ -212,8 +213,11 @@ open class ConversationFragment : SlidingPaneChildFragment() {
.viewTreeObserver
.removeOnGlobalLayoutListener(this)
if (::scrollListener.isInitialized) {
binding.eventsList.addOnScrollListener(scrollListener)
binding.root.setKeyboardInsetListener { keyboardVisible ->
sendMessageViewModel.isKeyboardOpen.value = keyboardVisible
if (keyboardVisible) {
sendMessageViewModel.isEmojiPickerOpen.value = false
}
}
val unreadCount = viewModel.unreadMessagesCount.value ?: 0
@ -275,15 +279,21 @@ open class ConversationFragment : SlidingPaneChildFragment() {
}
override fun afterTextChanged(p0: Editable?) {
sendMessageViewModel.closeParticipantsList()
if (viewModel.isGroup.value == true) {
sendMessageViewModel.closeParticipantsList()
val split = p0.toString().split(" ")
for (part in split) {
if (part == "@") {
Log.i("$TAG '@' found, opening participants list")
sendMessageViewModel.openParticipantsList()
val split = p0.toString().split(" ")
for (part in split) {
if (part == "@") {
Log.i("$TAG '@' found, opening participants list")
sendMessageViewModel.openParticipantsList()
}
}
}
if (p0.toString().isNotEmpty()) {
sendMessageViewModel.notifyChatMessageIsBeingComposed()
}
}
}
@ -301,7 +311,9 @@ open class ConversationFragment : SlidingPaneChildFragment() {
if (e.action == MotionEvent.ACTION_UP) {
if ((rv.layoutManager as LinearLayoutManager).findFirstCompletelyVisibleItemPosition() == 0) {
if (e.y >= 0 && e.y <= headerItemDecoration.getDecorationHeight(0)) {
showEndToEndEncryptionDetailsBottomSheet()
if (viewModel.isEndToEndEncrypted.value == true) {
showEndToEndEncryptionDetailsBottomSheet()
}
return true
}
}
@ -471,14 +483,11 @@ open class ConversationFragment : SlidingPaneChildFragment() {
}
RecyclerViewSwipeUtils(callbacks).attachToRecyclerView(binding.eventsList)
val localSipUri = args.localSipUri
val remoteSipUri = args.remoteSipUri
Log.i(
"$TAG Looking up for conversation with local SIP URI [$localSipUri] and remote SIP URI [$remoteSipUri]"
)
val conversationId = args.conversationId
Log.i("$TAG Looking up for conversation with conversation ID [$conversationId]")
val chatRoom = sharedViewModel.displayedChatRoom
viewModel.findChatRoom(chatRoom, localSipUri, remoteSipUri)
Compatibility.setLocusIdInContentCaptureSession(binding.root, localSipUri, remoteSipUri)
viewModel.findChatRoom(chatRoom, conversationId)
Compatibility.setLocusIdInContentCaptureSession(binding.root, conversationId)
viewModel.chatRoomFoundEvent.observe(viewLifecycleOwner) {
it.consume { found ->
@ -494,6 +503,7 @@ open class ConversationFragment : SlidingPaneChildFragment() {
}
} else {
sendMessageViewModel.configureChatRoom(viewModel.chatRoom)
adapter.setIsConversationSecured(viewModel.isEndToEndEncrypted.value == true)
// Wait for chat room to be ready before trying to forward a message in it
sharedViewModel.messageToForwardEvent.observe(viewLifecycleOwner) { event ->
@ -575,6 +585,8 @@ open class ConversationFragment : SlidingPaneChildFragment() {
}
viewModel.isEndToEndEncrypted.observe(viewLifecycleOwner) { encrypted ->
adapter.setIsConversationSecured(encrypted)
if (encrypted) {
binding.eventsList.addItemDecoration(headerItemDecoration)
binding.eventsList.addOnItemTouchListener(listItemTouchListener)
@ -690,7 +702,7 @@ open class ConversationFragment : SlidingPaneChildFragment() {
}
binding.setWarningConversationDisabledClickListener {
showUnsafeConversationDetailsBottomSheet()
showUnsafeConversationDisabledDetailsBottomSheet()
}
binding.searchField.setOnEditorActionListener { view, actionId, _ ->
@ -767,7 +779,7 @@ open class ConversationFragment : SlidingPaneChildFragment() {
if (messageLongPressViewModel.visible.value == true) return@consume
Log.i("$TAG Requesting to open web browser on page [$url]")
try {
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
val browserIntent = Intent(Intent.ACTION_VIEW, url.toUri())
startActivity(browserIntent)
} catch (e: Exception) {
Log.e(
@ -786,13 +798,6 @@ open class ConversationFragment : SlidingPaneChildFragment() {
}
}
viewModel.isGroup.observe(viewLifecycleOwner) { group ->
if (group) {
Log.i("$TAG Adding text observer to message sending area")
binding.sendArea.messageToSend.addTextChangedListener(textObserver)
}
}
viewModel.messageDeletedEvent.observe(viewLifecycleOwner) {
it.consume {
val message = getString(R.string.conversation_message_deleted_toast)
@ -939,12 +944,7 @@ open class ConversationFragment : SlidingPaneChildFragment() {
}
})
binding.root.setKeyboardInsetListener { keyboardVisible ->
sendMessageViewModel.isKeyboardOpen.value = keyboardVisible
if (keyboardVisible) {
sendMessageViewModel.isEmojiPickerOpen.value = false
}
}
binding.sendArea.messageToSend.addTextChangedListener(textObserver)
scrollListener = object : ConversationScrollListener(layoutManager) {
@UiThread
@ -985,6 +985,10 @@ open class ConversationFragment : SlidingPaneChildFragment() {
.viewTreeObserver
.addOnGlobalLayoutListener(globalLayoutObserver)
if (::scrollListener.isInitialized) {
binding.eventsList.addOnScrollListener(scrollListener)
}
try {
adapter.registerAdapterDataObserver(dataObserver)
} catch (e: IllegalStateException) {
@ -1091,8 +1095,7 @@ open class ConversationFragment : SlidingPaneChildFragment() {
if (findNavController().currentDestination?.id == R.id.conversationFragment) {
val action =
ConversationFragmentDirections.actionConversationFragmentToConversationInfoFragment(
viewModel.localSipUri,
viewModel.remoteSipUri
viewModel.conversationId,
)
findNavController().navigate(action)
}
@ -1112,12 +1115,12 @@ open class ConversationFragment : SlidingPaneChildFragment() {
val bundle = Bundle()
bundle.apply {
putString("localSipUri", viewModel.localSipUri)
putString("remoteSipUri", viewModel.remoteSipUri)
putString("conversationId", viewModel.conversationId)
putString("path", path)
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 -> {
@ -1198,8 +1201,7 @@ open class ConversationFragment : SlidingPaneChildFragment() {
if (findNavController().currentDestination?.id == R.id.conversationFragment) {
val action =
ConversationFragmentDirections.actionConversationFragmentToConversationMediaListFragment(
localSipUri = viewModel.localSipUri,
remoteSipUri = viewModel.remoteSipUri
viewModel.conversationId
)
findNavController().navigate(action)
}
@ -1210,8 +1212,7 @@ open class ConversationFragment : SlidingPaneChildFragment() {
if (findNavController().currentDestination?.id == R.id.conversationFragment) {
val action =
ConversationFragmentDirections.actionConversationFragmentToConversationDocumentsListFragment(
localSipUri = viewModel.localSipUri,
remoteSipUri = viewModel.remoteSipUri
viewModel.conversationId
)
findNavController().navigate(action)
}
@ -1420,13 +1421,13 @@ open class ConversationFragment : SlidingPaneChildFragment() {
bottomSheetDialog = e2eEncryptionDetailsBottomSheet
}
private fun showUnsafeConversationDetailsBottomSheet() {
val unsafeConversationDetailsBottomSheet = UnsafeConversationDetailsDialogFragment()
unsafeConversationDetailsBottomSheet.show(
private fun showUnsafeConversationDisabledDetailsBottomSheet() {
val unsafeConversationDisabledDetailsBottomSheet = UnsafeConversationDisabledDetailsDialogFragment()
unsafeConversationDisabledDetailsBottomSheet.show(
requireActivity().supportFragmentManager,
UnsafeConversationDetailsDialogFragment.TAG
UnsafeConversationDisabledDetailsDialogFragment.TAG
)
bottomSheetDialog = unsafeConversationDetailsBottomSheet
bottomSheetDialog = unsafeConversationDisabledDetailsBottomSheet
}
private fun showOpenOrExportFileDialog(path: String, mime: String, bundle: Bundle) {

View file

@ -96,13 +96,10 @@ class ConversationInfoFragment : SlidingPaneChildFragment() {
binding.viewModel = viewModel
observeToastEvents(viewModel)
val localSipUri = args.localSipUri
val remoteSipUri = args.remoteSipUri
Log.i(
"$TAG Looking up for conversation with local SIP URI [$localSipUri] and remote SIP URI [$remoteSipUri]"
)
val conversationId = args.conversationId
Log.i("$TAG Looking up for conversation with conversation ID [$conversationId]")
val chatRoom = sharedViewModel.displayedChatRoom
viewModel.findChatRoom(chatRoom, localSipUri, remoteSipUri)
viewModel.findChatRoom(chatRoom, conversationId)
binding.participants.isNestedScrollingEnabled = false
binding.participants.setHasFixedSize(false)
@ -116,7 +113,7 @@ class ConversationInfoFragment : SlidingPaneChildFragment() {
it.consume { found ->
if (found) {
Log.i(
"$TAG Found matching conversation for local SIP URI [$localSipUri] and remote SIP URI [$remoteSipUri]"
"$TAG Found matching conversation with conversation ID [$conversationId]"
)
startPostponedEnterTransition()
} else {
@ -333,7 +330,7 @@ class ConversationInfoFragment : SlidingPaneChildFragment() {
if (findNavController().currentDestination?.id == R.id.conversationInfoFragment) {
Log.i("$TAG Going to shared media fragment")
val action =
ConversationInfoFragmentDirections.actionConversationInfoFragmentToConversationMediaListFragment(localSipUri, remoteSipUri)
ConversationInfoFragmentDirections.actionConversationInfoFragmentToConversationMediaListFragment(conversationId)
findNavController().navigate(action)
}
}
@ -342,7 +339,7 @@ class ConversationInfoFragment : SlidingPaneChildFragment() {
if (findNavController().currentDestination?.id == R.id.conversationInfoFragment) {
Log.i("$TAG Going to shared documents fragment")
val action =
ConversationInfoFragmentDirections.actionConversationInfoFragmentToConversationDocumentsListFragment(localSipUri, remoteSipUri)
ConversationInfoFragmentDirections.actionConversationInfoFragmentToConversationDocumentsListFragment(conversationId)
findNavController().navigate(action)
}
}

View file

@ -94,13 +94,10 @@ class ConversationMediaListFragment : SlidingPaneChildFragment() {
binding.viewModel = viewModel
observeToastEvents(viewModel)
val localSipUri = args.localSipUri
val remoteSipUri = args.remoteSipUri
Log.i(
"$TAG Looking up for conversation with local SIP URI [$localSipUri] and remote SIP URI [$remoteSipUri]"
)
val conversationId = args.conversationId
Log.i("$TAG Looking up for conversation with conversation ID [$conversationId]")
val chatRoom = sharedViewModel.displayedChatRoom
viewModel.findChatRoom(chatRoom, localSipUri, remoteSipUri)
viewModel.findChatRoom(chatRoom, conversationId)
val headerItemDecoration = RecyclerViewHeaderDecoration(requireContext(), adapter)
binding.mediaList.addItemDecoration(headerItemDecoration)
@ -172,12 +169,12 @@ class ConversationMediaListFragment : SlidingPaneChildFragment() {
val bundle = Bundle()
bundle.apply {
putString("localSipUri", viewModel.localSipUri)
putString("remoteSipUri", viewModel.remoteSipUri)
putString("conversationId", viewModel.conversationId)
putString("path", path)
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

@ -39,6 +39,7 @@ import org.linphone.databinding.ChatListFragmentBinding
import org.linphone.ui.GenericActivity
import org.linphone.ui.fileviewer.FileViewerActivity
import org.linphone.ui.fileviewer.MediaViewerActivity
import org.linphone.ui.main.MainActivity.Companion.ARGUMENTS_CONVERSATION_ID
import org.linphone.ui.main.chat.adapter.ConversationsListAdapter
import org.linphone.ui.main.chat.viewmodel.ConversationsListViewModel
import org.linphone.ui.main.fragment.AbstractMainFragment
@ -162,9 +163,7 @@ class ConversationsListFragment : AbstractMainFragment() {
it.consume { model ->
Log.i("$TAG Show conversation with ID [${model.id}]")
sharedViewModel.displayedChatRoom = model.chatRoom
sharedViewModel.showConversationEvent.value = Event(
Pair(model.localSipUri, model.remoteSipUri)
)
sharedViewModel.showConversationEvent.value = Event(model.id)
}
}
@ -191,16 +190,9 @@ class ConversationsListFragment : AbstractMainFragment() {
}
sharedViewModel.showConversationEvent.observe(viewLifecycleOwner) {
it.consume { pair ->
val localSipUri = pair.first
val remoteSipUri = pair.second
Log.i(
"$TAG Navigating to conversation fragment with local SIP URI [$localSipUri] and remote SIP URI [$remoteSipUri]"
)
val action = ConversationFragmentDirections.actionGlobalConversationFragment(
localSipUri,
remoteSipUri
)
it.consume { conversationId ->
Log.i("$TAG Navigating to conversation fragment with ID [$conversationId]")
val action = ConversationFragmentDirections.actionGlobalConversationFragment(conversationId)
binding.chatNavContainer.findNavController().navigate(action)
}
}
@ -214,6 +206,8 @@ class ConversationsListFragment : AbstractMainFragment() {
uri
)
findNavController().navigate(action)
} else {
Log.e("$TAG Failed to navigate to meeting waiting room, wrong current destination (expected conversationsListFragment but was something else)")
}
}
}
@ -328,12 +322,10 @@ class ConversationsListFragment : AbstractMainFragment() {
val args = arguments
if (args != null) {
val localSipUri = args.getString("LocalSipUri")
val remoteSipUri = args.getString("RemoteSipUri")
if (localSipUri != null && remoteSipUri != null) {
Log.i("$TAG Found local [$localSipUri] & remote [$remoteSipUri] URIs in arguments")
val pair = Pair(localSipUri, remoteSipUri)
sharedViewModel.showConversationEvent.value = Event(pair)
val conversationId = args.getString(ARGUMENTS_CONVERSATION_ID)
if (!conversationId.isNullOrEmpty()) {
Log.i("$TAG Found conversation ID [$conversationId] in arguments")
sharedViewModel.showConversationEvent.value = Event(conversationId)
args.clear()
}
}

View file

@ -29,7 +29,7 @@ import androidx.annotation.UiThread
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import org.linphone.databinding.ChatConversationE2eDetailsBottomSheetBinding
import org.linphone.databinding.ChatConversationE2eEncryptedDetailsBottomSheetBinding
@UiThread
class EndToEndEncryptionDetailsDialogFragment(
@ -62,7 +62,7 @@ class EndToEndEncryptionDetailsDialogFragment(
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val view = ChatConversationE2eDetailsBottomSheetBinding.inflate(layoutInflater)
val view = ChatConversationE2eEncryptedDetailsBottomSheetBinding.inflate(layoutInflater)
return view.root
}
}

View file

@ -93,11 +93,11 @@ class StartConversationFragment : GenericAddressPickerFragment() {
}
viewModel.chatRoomCreatedEvent.observe(viewLifecycleOwner) {
it.consume { pair ->
it.consume { conversationId ->
Log.i(
"$TAG Conversation [${pair.second}] for local address [${pair.first}] has been created, navigating to it"
"$TAG Conversation [$conversationId] has been created, navigating to it"
)
sharedViewModel.showConversationEvent.value = Event(pair)
sharedViewModel.showConversationEvent.value = Event(conversationId)
goBack()
}
}

View file

@ -29,14 +29,14 @@ import androidx.annotation.UiThread
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import org.linphone.databinding.ChatConversationUnsafeDetailsBottomSheetBinding
import org.linphone.databinding.ChatConversationUnsafeDisabledDetailsBottomSheetBinding
@UiThread
class UnsafeConversationDetailsDialogFragment(
class UnsafeConversationDisabledDetailsDialogFragment(
private val onDismiss: (() -> Unit)? = null
) : BottomSheetDialogFragment() {
companion object {
const val TAG = "UnsafeConversationDetailsDialogFragment"
const val TAG = "UnsafeConversationDisabledDetailsDialogFragment"
}
override fun onCancel(dialog: DialogInterface) {
@ -62,7 +62,7 @@ class UnsafeConversationDetailsDialogFragment(
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val view = ChatConversationUnsafeDetailsBottomSheetBinding.inflate(layoutInflater)
val view = ChatConversationUnsafeDisabledDetailsBottomSheetBinding.inflate(layoutInflater)
return view.root
}
}

View file

@ -48,11 +48,7 @@ class ConversationModel
private const val TAG = "[Conversation Model]"
}
val id = LinphoneUtils.getChatRoomId(chatRoom)
val localSipUri = chatRoom.localAddress.asStringUriOnly()
val remoteSipUri = chatRoom.peerAddress.asStringUriOnly()
val id = LinphoneUtils.getConversationId(chatRoom)
val isGroup = !chatRoom.hasCapability(Capabilities.OneToOne.toInt()) && chatRoom.hasCapability(
Capabilities.Conference.toInt()
@ -60,6 +56,8 @@ class ConversationModel
val isEncrypted = chatRoom.hasCapability(Capabilities.Encrypted.toInt())
val isEncryptionAvailable = LinphoneUtils.isEndToEndEncryptedChatAvailable(chatRoom.core)
val isReadOnly = MutableLiveData<Boolean>()
val subject = MutableLiveData<String>()

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

@ -22,7 +22,6 @@ package org.linphone.ui.main.chat.model
import android.media.MediaMetadataRetriever
import android.media.MediaMetadataRetriever.METADATA_KEY_DURATION
import android.media.ThumbnailUtils
import android.net.Uri
import android.provider.MediaStore
import androidx.annotation.AnyThread
import androidx.annotation.UiThread
@ -35,6 +34,7 @@ import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.core.tools.Log
import org.linphone.utils.FileUtils
import org.linphone.utils.TimestampUtils
import androidx.core.net.toUri
class FileModel
@AnyThread
@ -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
@ -185,7 +186,7 @@ class FileModel
private fun getDuration() {
try {
val retriever = MediaMetadataRetriever()
retriever.setDataSource(coreContext.context, Uri.parse(path))
retriever.setDataSource(coreContext.context, path.toUri())
val durationInMs = retriever.extractMetadata(METADATA_KEY_DURATION)?.toInt() ?: 0
val seconds = durationInMs / 1000
val duration = TimestampUtils.durationToString(seconds)

View file

@ -21,7 +21,9 @@ package org.linphone.ui.main.chat.model
import androidx.annotation.UiThread
import androidx.annotation.WorkerThread
import androidx.lifecycle.MutableLiveData
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.LinphoneApplication.Companion.corePreferences
import org.linphone.core.Address
class MessageBottomSheetParticipantModel
@ -35,8 +37,23 @@ class MessageBottomSheetParticipantModel
) {
val sipUri = address.asStringUriOnly()
val showSipUri = MutableLiveData<Boolean>()
val avatarModel = coreContext.contactsManager.getContactAvatarModelForAddress(address)
init {
showSipUri.postValue(false)
}
@UiThread
fun toggleShowSipUri() {
if (!isOurOwnReaction && !corePreferences.onlyDisplaySipUriUsername) {
showSipUri.postValue(showSipUri.value == false)
} else {
clicked()
}
}
@UiThread
fun clicked() {
onClick?.invoke()

View file

@ -63,6 +63,7 @@ class MessageDeliveryModel
message: ChatMessage,
state: ParticipantImdnState
) {
Log.i("$TAG Participant IMDN state changed [${state.state}], updating delivery status")
computeDeliveryStatus()
}
}

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]"
@ -98,12 +100,14 @@ class MessageModel
val isOutgoing = chatMessage.isOutgoing
val isInError = chatMessage.state == ChatMessage.State.NotDelivered
val isInError = MutableLiveData<Boolean>()
val timestamp = chatMessage.time
val time = TimestampUtils.toString(timestamp)
val hideDeliveryStatus = !isOutgoing && coreContext.core.imdnToEverybodyThreshold == 1
val chatRoomIsReadOnly = chatMessage.chatRoom.isReadOnly ||
(
!chatMessage.chatRoom.hasCapability(ChatRoom.Capabilities.Encrypted.toInt()) && LinphoneUtils.getAccountForAddress(
@ -222,6 +226,28 @@ class MessageModel
}
}
}
isInError.postValue(messageState == ChatMessage.State.NotDelivered)
}
@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
@ -274,6 +300,8 @@ class MessageModel
init {
updateAvatarModel()
isInError.postValue(chatMessage.state == ChatMessage.State.NotDelivered)
groupedWithNextMessage.postValue(isGroupedWithNextOne)
groupedWithPreviousMessage.postValue(isGroupedWithPreviousOne)
isPlayingVoiceRecord.postValue(false)
@ -443,6 +471,7 @@ class MessageModel
timestamp,
isFileEncrypted,
originalPath,
chatMessage.isEphemeral,
flexboxLayoutWrapBefore = wrapBefore
) { model ->
onContentClicked?.invoke(model)
@ -470,7 +499,8 @@ class MessageModel
content.fileSize.toLong(),
timestamp,
isFileEncrypted,
path
path,
chatMessage.isEphemeral
) { model ->
onContentClicked?.invoke(model)
}
@ -482,6 +512,7 @@ class MessageModel
timestamp,
isFileEncrypted,
name,
chatMessage.isEphemeral,
isWaitingToBeDownloaded = true
) { model ->
downloadContent(model, content)

View file

@ -22,7 +22,9 @@ package org.linphone.ui.main.chat.model
import android.view.View
import androidx.annotation.UiThread
import androidx.annotation.WorkerThread
import androidx.lifecycle.MutableLiveData
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.LinphoneApplication.Companion.corePreferences
import org.linphone.core.Address
import org.linphone.ui.main.contacts.model.ContactAvatarModel
@ -39,6 +41,8 @@ class ParticipantModel
) {
val sipUri = address.asStringUriOnly()
val showSipUri = MutableLiveData<Boolean>()
val avatarModel: ContactAvatarModel = coreContext.contactsManager.getContactAvatarModelForAddress(
address
)
@ -49,6 +53,19 @@ class ParticipantModel
avatarModel.friend
)
init {
showSipUri.postValue(false)
}
@UiThread
fun toggleShowSipUri() {
if (!corePreferences.onlyDisplaySipUriUsername) {
showSipUri.postValue(showSipUri.value == false)
} else {
onClicked()
}
}
@UiThread
fun onClicked() {
onClicked?.invoke(this)

View file

@ -28,8 +28,6 @@ import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.R
import org.linphone.core.Address
import org.linphone.core.ChatRoom
import org.linphone.core.ConferenceParams
import org.linphone.core.Factory
import org.linphone.core.MediaDirection
import org.linphone.core.tools.Log
import org.linphone.ui.GenericViewModel
@ -51,9 +49,7 @@ abstract class AbstractConversationViewModel : GenericViewModel() {
lateinit var chatRoom: ChatRoom
lateinit var localSipUri: String
lateinit var remoteSipUri: String
lateinit var conversationId: String
fun isChatRoomInitialized(): Boolean {
return ::chatRoom.isInitialized
@ -68,17 +64,13 @@ abstract class AbstractConversationViewModel : GenericViewModel() {
}
@UiThread
fun findChatRoom(room: ChatRoom?, localSipUri: String, remoteSipUri: String) {
this.localSipUri = localSipUri
this.remoteSipUri = remoteSipUri
fun findChatRoom(room: ChatRoom?, conversationId: String) {
coreContext.postOnCoreThread { core ->
Log.i(
"$TAG Looking for conversation with local SIP URI [$localSipUri] and remote SIP URI [$remoteSipUri]"
)
Log.i("$TAG Looking for conversation with conversation ID [$conversationId]")
if (room != null && ::chatRoom.isInitialized && chatRoom == room) {
Log.i("$TAG Conversation object already in memory, skipping")
this@AbstractConversationViewModel.conversationId = conversationId
beforeNotifyingChatRoomFound(sameOne = true)
chatRoomFoundEvent.postValue(Event(true))
afterNotifyingChatRoomFound(sameOne = true)
@ -86,17 +78,12 @@ abstract class AbstractConversationViewModel : GenericViewModel() {
return@postOnCoreThread
}
val localAddress = Factory.instance().createAddress(localSipUri)
val remoteAddress = Factory.instance().createAddress(remoteSipUri)
if (room != null && (!::chatRoom.isInitialized || chatRoom != room)) {
if (localAddress?.weakEqual(room.localAddress) == true && remoteAddress?.weakEqual(
room.peerAddress
) == true
) {
if (conversationId == LinphoneUtils.getConversationId(room)) {
Log.i("$TAG Conversation object available in sharedViewModel, using it")
chatRoom = room
this@AbstractConversationViewModel.conversationId = conversationId
beforeNotifyingChatRoomFound(sameOne = false)
chatRoomFoundEvent.postValue(Event(true))
afterNotifyingChatRoomFound(sameOne = false)
@ -105,18 +92,11 @@ abstract class AbstractConversationViewModel : GenericViewModel() {
}
}
if (localAddress != null && remoteAddress != null) {
if (conversationId.isNotEmpty()) {
Log.i("$TAG Searching for conversation in Core using local & peer SIP addresses")
val params: ConferenceParams? = null
val found = core.searchChatRoom(
params,
localAddress,
remoteAddress,
arrayOfNulls<Address>(
0
)
)
val found = core.searchChatRoomByIdentifier(conversationId)
if (found != null) {
this@AbstractConversationViewModel.conversationId = conversationId
if (::chatRoom.isInitialized && chatRoom == found) {
Log.i("$TAG Conversation object already in memory, keeping it")
beforeNotifyingChatRoomFound(sameOne = true)
@ -173,14 +153,7 @@ abstract class AbstractConversationViewModel : GenericViewModel() {
val conference = LinphoneUtils.createGroupCall(account, chatRoom.subject.orEmpty())
if (conference == null) {
Log.e("$TAG Failed to create group call!")
showRedToastEvent.postValue(
Event(
Pair(
R.string.conference_failed_to_create_group_call_toast,
R.drawable.warning_circle
)
)
)
showRedToast(R.string.conference_failed_to_create_group_call_toast, R.drawable.warning_circle)
return@postOnCoreThread
}
@ -199,14 +172,7 @@ abstract class AbstractConversationViewModel : GenericViewModel() {
)
if (conference.inviteParticipants(participants, callParams) != 0) {
Log.e("$TAG Failed to invite participants into group call!")
showRedToastEvent.postValue(
Event(
Pair(
R.string.conference_failed_to_create_group_call_toast,
R.drawable.warning_circle
)
)
)
showRedToast(R.string.conference_failed_to_create_group_call_toast, R.drawable.warning_circle)
}
}
}

View file

@ -72,7 +72,6 @@ class ChatMessageLongPressViewModel : GenericViewModel() {
val onDismissedEvent = MutableLiveData<Event<Boolean>>()
private lateinit var emojiBottomSheet: ChatBubbleEmojiPickerBottomSheetBinding
private lateinit var emojiBottomSheetBehavior: BottomSheetBehavior<View>
init {
@ -91,7 +90,7 @@ class ChatMessageLongPressViewModel : GenericViewModel() {
hideCopyTextToClipboard.value = model.text.value.isNullOrEmpty()
isChatRoomReadOnly.value = model.chatRoomIsReadOnly
isMessageOutgoing.value = model.isOutgoing
isMessageInError.value = model.isInError
isMessageInError.value = model.isInError.value == true
horizontalBias.value = if (model.isOutgoing) 1f else 0f
messageModel.value = model

View file

@ -42,7 +42,8 @@ class ConversationDocumentsListViewModel
MutableLiveData<Event<FileModel>>()
}
override fun beforeNotifyingChatRoomFound(sameOne: Boolean) {
@WorkerThread
override fun afterNotifyingChatRoomFound(sameOne: Boolean) {
loadDocumentsList()
}
@ -58,7 +59,7 @@ class ConversationDocumentsListViewModel
val list = arrayListOf<FileModel>()
Log.i(
"$TAG Loading document contents for conversation [${LinphoneUtils.getChatRoomId(
"$TAG Loading document contents for conversation [${LinphoneUtils.getConversationId(
chatRoom
)}]"
)
@ -79,12 +80,26 @@ 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) {
val messageId = documentContent.relatedChatMessageId
val ephemeral = if (messageId != null) {
val chatMessage = chatRoom.findMessage(messageId)
if (chatMessage == null) {
Log.w("$TAG Failed to find message using ID [$messageId] related to this content, can't get real info about being related to ephemeral message")
}
chatMessage?.isEphemeral ?: chatRoom.isEphemeralEnabled
} else {
Log.e("$TAG No chat message ID related to this content, can't get real info about being related to ephemeral message")
chatRoom.isEphemeralEnabled
}
val model =
FileModel(path, name, size, timestamp, isEncrypted, originalPath, ephemeral) {
openDocumentEvent.postValue(Event(it))
}
list.add(model)
}
}
Log.i("$TAG [${documents.size}] documents have been processed")
documentsList.postValue(list)
operationInProgress.postValue(false)

View file

@ -48,8 +48,8 @@ class ConversationForwardMessageViewModel
val operationInProgress = MutableLiveData<Boolean>()
val chatRoomCreatedEvent: MutableLiveData<Event<Pair<String, String>>> by lazy {
MutableLiveData<Event<Pair<String, String>>>()
val chatRoomCreatedEvent: MutableLiveData<Event<String>> by lazy {
MutableLiveData<Event<String>>()
}
val showNumberOrAddressPickerDialogEvent: MutableLiveData<Event<ArrayList<ContactNumberOrAddressModel>>> by lazy {
@ -83,33 +83,19 @@ class ConversationForwardMessageViewModel
val state = chatRoom.state
if (state == ChatRoom.State.Instantiated) return
val id = LinphoneUtils.getChatRoomId(chatRoom)
val id = LinphoneUtils.getConversationId(chatRoom)
Log.i("$TAG Conversation [$id] (${chatRoom.subject}) state changed: [$state]")
if (state == ChatRoom.State.Created) {
Log.i("$TAG Conversation [$id] successfully created")
chatRoom.removeListener(this)
operationInProgress.postValue(false)
chatRoomCreatedEvent.postValue(
Event(
Pair(
chatRoom.localAddress.asStringUriOnly(),
chatRoom.peerAddress.asStringUriOnly()
)
)
)
chatRoomCreatedEvent.postValue(Event(LinphoneUtils.getConversationId(chatRoom)))
} else if (state == ChatRoom.State.CreationFailed) {
Log.e("$TAG Conversation [$id] creation has failed!")
chatRoom.removeListener(this)
operationInProgress.postValue(false)
showRedToastEvent.postValue(
Event(
Pair(
R.string.conversation_failed_to_create_toast,
R.drawable.warning_circle
)
)
)
showRedToast(R.string.conversation_failed_to_create_toast, R.drawable.warning_circle)
}
}
}
@ -135,16 +121,9 @@ class ConversationForwardMessageViewModel
@UiThread
fun handleClickOnModel(model: ConversationContactOrSuggestionModel) {
coreContext.postOnCoreThread { core ->
if (model.localAddress != null) {
if (model.conversationId.isNotEmpty()) {
Log.i("$TAG User clicked on an existing conversation")
chatRoomCreatedEvent.postValue(
Event(
Pair(
model.localAddress.asStringUriOnly(),
model.address.asStringUriOnly()
)
)
)
chatRoomCreatedEvent.postValue(Event(model.conversationId))
if (searchFilter.value.orEmpty().isNotEmpty()) {
// Clear filter after it was used
coreContext.postOnMainThread {
@ -156,7 +135,7 @@ class ConversationForwardMessageViewModel
val friend = model.friend
if (friend == null) {
Log.i("$TAG Friend is null, using address [${model.address}]")
Log.i("$TAG Friend is null, using address [${model.address.asStringUriOnly()}]")
onAddressSelected(model.address)
return@postOnCoreThread
}
@ -195,18 +174,20 @@ class ConversationForwardMessageViewModel
params.isChatEnabled = true
params.isGroupEnabled = false
params.subject = AppUtils.getString(R.string.conversation_one_to_one_hidden_subject)
params.account = account
val chatParams = params.chatParams ?: return
chatParams.ephemeralLifetime = 0 // Make sure ephemeral is disabled by default
val sameDomain = remote.domain == corePreferences.defaultDomain && remote.domain == account.params.domain
if (account.params.instantMessagingEncryptionMandatory && sameDomain) {
Log.i("$TAG Account is in secure mode & domain matches, creating a E2E conversation")
Log.i("$TAG Account is in secure mode & domain matches, creating an E2E encrypted conversation")
chatParams.backend = ChatRoom.Backend.FlexisipChat
params.securityLevel = Conference.SecurityLevel.EndToEnd
} else if (!account.params.instantMessagingEncryptionMandatory) {
if (LinphoneUtils.isEndToEndEncryptedChatAvailable(core)) {
Log.i(
"$TAG Account is in interop mode but LIME is available, creating a E2E conversation"
"$TAG Account is in interop mode but LIME is available, creating an E2E encrypted conversation"
)
chatParams.backend = ChatRoom.Backend.FlexisipChat
params.securityLevel = Conference.SecurityLevel.EndToEnd
@ -222,14 +203,7 @@ class ConversationForwardMessageViewModel
"$TAG Account is in secure mode, can't chat with SIP address of different domain [${remote.asStringUriOnly()}]"
)
operationInProgress.postValue(false)
showRedToastEvent.postValue(
Event(
Pair(
R.string.conversation_invalid_participant_due_to_security_mode_toast,
R.drawable.warning_circle
)
)
)
showRedToast(R.string.conversation_invalid_participant_due_to_security_mode_toast, R.drawable.warning_circle)
return
}
@ -240,63 +214,35 @@ class ConversationForwardMessageViewModel
Log.i(
"$TAG No existing 1-1 conversation between local account [${localAddress?.asStringUriOnly()}] and remote [${remote.asStringUriOnly()}] was found for given parameters, let's create it"
)
val chatRoom = core.createChatRoom(params, localAddress, participants)
val chatRoom = core.createChatRoom(params, participants)
if (chatRoom != null) {
if (chatParams.backend == ChatRoom.Backend.FlexisipChat) {
if (chatRoom.state == ChatRoom.State.Created) {
val id = LinphoneUtils.getChatRoomId(chatRoom)
val id = LinphoneUtils.getConversationId(chatRoom)
Log.i("$TAG 1-1 conversation [$id] has been created")
operationInProgress.postValue(false)
chatRoomCreatedEvent.postValue(
Event(
Pair(
chatRoom.localAddress.asStringUriOnly(),
chatRoom.peerAddress.asStringUriOnly()
)
)
)
chatRoomCreatedEvent.postValue(Event(LinphoneUtils.getConversationId(chatRoom)))
} else {
Log.i("$TAG Conversation isn't in Created state yet, wait for it")
chatRoom.addListener(chatRoomListener)
}
} else {
val id = LinphoneUtils.getChatRoomId(chatRoom)
val id = LinphoneUtils.getConversationId(chatRoom)
Log.i("$TAG Conversation successfully created [$id]")
operationInProgress.postValue(false)
chatRoomCreatedEvent.postValue(
Event(
Pair(
chatRoom.localAddress.asStringUriOnly(),
chatRoom.peerAddress.asStringUriOnly()
)
)
)
chatRoomCreatedEvent.postValue(Event(LinphoneUtils.getConversationId(chatRoom)))
}
} else {
Log.e("$TAG Failed to create 1-1 conversation with [${remote.asStringUriOnly()}]!")
operationInProgress.postValue(false)
showRedToastEvent.postValue(
Event(
Pair(
R.string.conversation_failed_to_create_toast,
R.drawable.warning_circle
)
)
)
showRedToast(R.string.conversation_failed_to_create_toast, R.drawable.warning_circle)
}
} else {
Log.w(
"$TAG A 1-1 conversation between local account [${localAddress?.asStringUriOnly()}] and remote [${remote.asStringUriOnly()}] for given parameters already exists!"
)
operationInProgress.postValue(false)
chatRoomCreatedEvent.postValue(
Event(
Pair(
existingChatRoom.localAddress.asStringUriOnly(),
existingChatRoom.peerAddress.asStringUriOnly()
)
)
)
chatRoomCreatedEvent.postValue(Event(LinphoneUtils.getConversationId(existingChatRoom)))
}
}
}

View file

@ -108,7 +108,7 @@ class ConversationInfoViewModel
R.string.conversation_info_participant_added_to_conversation_toast,
getParticipant(eventLog)
)
showFormattedGreenToastEvent.postValue(Event(Pair(message, R.drawable.user_circle)))
showFormattedGreenToast(message, R.drawable.user_circle)
computeParticipantsList()
infoChangedEvent.postValue(Event(true))
@ -121,7 +121,7 @@ class ConversationInfoViewModel
R.string.conversation_info_participant_removed_from_conversation_toast,
getParticipant(eventLog)
)
showFormattedGreenToastEvent.postValue(Event(Pair(message, R.drawable.user_circle)))
showFormattedGreenToast(message, R.drawable.user_circle)
computeParticipantsList()
infoChangedEvent.postValue(Event(true))
@ -143,7 +143,7 @@ class ConversationInfoViewModel
getParticipant(eventLog)
)
}
showFormattedGreenToastEvent.postValue(Event(Pair(message, R.drawable.user_circle)))
showFormattedGreenToast(message, R.drawable.user_circle)
computeParticipantsList()
}
@ -151,11 +151,9 @@ class ConversationInfoViewModel
@WorkerThread
override fun onSubjectChanged(chatRoom: ChatRoom, eventLog: EventLog) {
Log.i(
"$TAG Conversation [${LinphoneUtils.getChatRoomId(chatRoom)}] has a new subject [${chatRoom.subject}]"
)
showGreenToastEvent.postValue(
Event(Pair(R.string.conversation_subject_changed_toast, R.drawable.check))
"$TAG Conversation [${LinphoneUtils.getConversationId(chatRoom)}] has a new subject [${chatRoom.subject}]"
)
showGreenToast(R.string.conversation_subject_changed_toast, R.drawable.check)
subject.postValue(chatRoom.subject)
infoChangedEvent.postValue(Event(true))
@ -166,34 +164,13 @@ class ConversationInfoViewModel
Log.i("$TAG Ephemeral event [${eventLog.type}]")
when (eventLog.type) {
EventLog.Type.ConferenceEphemeralMessageEnabled -> {
showGreenToastEvent.postValue(
Event(
Pair(
R.string.conversation_ephemeral_messages_enabled_toast,
R.drawable.clock_countdown
)
)
)
showGreenToast(R.string.conversation_ephemeral_messages_enabled_toast, R.drawable.clock_countdown)
}
EventLog.Type.ConferenceEphemeralMessageDisabled -> {
showGreenToastEvent.postValue(
Event(
Pair(
R.string.conversation_ephemeral_messages_disabled_toast,
R.drawable.clock_countdown
)
)
)
showGreenToast(R.string.conversation_ephemeral_messages_disabled_toast, R.drawable.clock_countdown)
}
else -> {
showGreenToastEvent.postValue(
Event(
Pair(
R.string.conversation_ephemeral_messages_lifetime_changed_toast,
R.drawable.clock_countdown
)
)
)
showGreenToast(R.string.conversation_ephemeral_messages_lifetime_changed_toast, R.drawable.clock_countdown)
}
}
}
@ -241,7 +218,7 @@ class ConversationInfoViewModel
fun leaveGroup() {
coreContext.postOnCoreThread {
if (isChatRoomInitialized()) {
Log.i("$TAG Leaving conversation [${LinphoneUtils.getChatRoomId(chatRoom)}]")
Log.i("$TAG Leaving conversation [${LinphoneUtils.getConversationId(chatRoom)}]")
chatRoom.leave()
}
groupLeftEvent.postValue(Event(true))
@ -253,7 +230,7 @@ class ConversationInfoViewModel
coreContext.postOnCoreThread {
if (isChatRoomInitialized()) {
Log.i(
"$TAG Cleaning conversation [${LinphoneUtils.getChatRoomId(chatRoom)}] history"
"$TAG Cleaning conversation [${LinphoneUtils.getConversationId(chatRoom)}] history"
)
chatRoom.deleteHistory()
}
@ -303,7 +280,7 @@ class ConversationInfoViewModel
coreContext.postOnCoreThread {
val address = participantModel.address
Log.i(
"$TAG Removing participant [$address] from the conversation [${LinphoneUtils.getChatRoomId(
"$TAG Removing participant [$address] from the conversation [${LinphoneUtils.getConversationId(
chatRoom
)}]"
)
@ -324,7 +301,7 @@ class ConversationInfoViewModel
coreContext.postOnCoreThread {
val address = participantModel.address
Log.i(
"$TAG Granting admin rights to participant [$address] from the conversation [${LinphoneUtils.getChatRoomId(
"$TAG Granting admin rights to participant [$address] from the conversation [${LinphoneUtils.getConversationId(
chatRoom
)}]"
)
@ -345,7 +322,7 @@ class ConversationInfoViewModel
coreContext.postOnCoreThread {
val address = participantModel.address
Log.i(
"$TAG Removing admin rights from participant [$address] from the conversation [${LinphoneUtils.getChatRoomId(
"$TAG Removing admin rights from participant [$address] from the conversation [${LinphoneUtils.getConversationId(
chatRoom
)}]"
)
@ -431,14 +408,7 @@ class ConversationInfoViewModel
val ok = chatRoom.addParticipants(toAddList.toTypedArray())
if (!ok) {
Log.w("$TAG Failed to add some/all participants to the group!")
showRedToastEvent.postValue(
Event(
Pair(
R.string.conversation_failed_to_add_participant_to_group_conversation_toast,
R.drawable.warning_circle
)
)
)
showRedToast(R.string.conversation_failed_to_add_participant_to_group_conversation_toast, R.drawable.warning_circle)
}
}
}

View file

@ -36,11 +36,14 @@ class ConversationMediaListViewModel
val mediaList = MutableLiveData<List<FileModel>>()
val operationInProgress = MutableLiveData<Boolean>()
val openMediaEvent: MutableLiveData<Event<FileModel>> by lazy {
MutableLiveData<Event<FileModel>>()
}
override fun beforeNotifyingChatRoomFound(sameOne: Boolean) {
@WorkerThread
override fun afterNotifyingChatRoomFound(sameOne: Boolean) {
loadMediaList()
}
@ -52,9 +55,11 @@ class ConversationMediaListViewModel
@WorkerThread
private fun loadMediaList() {
operationInProgress.postValue(true)
val list = arrayListOf<FileModel>()
Log.i(
"$TAG Loading media contents for conversation [${LinphoneUtils.getChatRoomId(
"$TAG Loading media contents for conversation [${LinphoneUtils.getConversationId(
chatRoom
)}]"
)
@ -78,13 +83,16 @@ 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)
}
}
Log.i("$TAG [${media.size}] media have been processed")
mediaList.postValue(list)
operationInProgress.postValue(false)
}
}

View file

@ -48,6 +48,7 @@ import org.linphone.utils.AppUtils
import org.linphone.utils.Event
import org.linphone.utils.FileUtils
import org.linphone.utils.LinphoneUtils
import androidx.core.net.toUri
class ConversationViewModel
@UiThread
@ -72,6 +73,8 @@ class ConversationViewModel
val isEndToEndEncrypted = MutableLiveData<Boolean>()
val isEndToEndEncryptionAvailable = MutableLiveData<Boolean>()
val isGroup = MutableLiveData<Boolean>()
val subject = MutableLiveData<String>()
@ -288,7 +291,7 @@ class ConversationViewModel
list.remove(found)
eventsList = list
updateEvents.postValue(Event(true))
isEmpty.postValue(eventsList.isEmpty)
isEmpty.postValue(eventsList.isEmpty())
} else {
Log.e("$TAG Failed to find matching message in conversation events list")
}
@ -313,7 +316,8 @@ class ConversationViewModel
}
init {
coreContext.postOnCoreThread {
coreContext.postOnCoreThread { core ->
isEndToEndEncryptionAvailable.postValue(LinphoneUtils.isEndToEndEncryptedChatAvailable(core))
coreContext.contactsManager.addListener(contactsListener)
}
@ -428,7 +432,7 @@ class ConversationViewModel
list.remove(found)
eventsList = list
updateEvents.postValue(Event(true))
isEmpty.postValue(eventsList.isEmpty)
isEmpty.postValue(eventsList.isEmpty())
} else {
Log.e(
"$TAG Failed to find chat message id [${chatMessageModel.id}] in events list!"
@ -470,7 +474,7 @@ class ConversationViewModel
fun updateCurrentlyDisplayedConversation() {
coreContext.postOnCoreThread {
if (isChatRoomInitialized()) {
val id = LinphoneUtils.getChatRoomId(chatRoom)
val id = LinphoneUtils.getConversationId(chatRoom)
Log.i(
"$TAG Asking notifications manager not to notify messages for conversation [$id]"
)
@ -524,7 +528,7 @@ class ConversationViewModel
list.addAll(eventsList)
eventsList = list
updateEvents.postValue(Event(true))
isEmpty.postValue(eventsList.isEmpty)
isEmpty.postValue(eventsList.isEmpty())
}
}
}
@ -647,7 +651,7 @@ class ConversationViewModel
Log.i("$TAG Extracted [${list.size}] events from conversation history in database")
eventsList = list
updateEvents.postValue(Event(true))
isEmpty.postValue(eventsList.isEmpty)
isEmpty.postValue(eventsList.isEmpty())
}
@WorkerThread
@ -694,7 +698,7 @@ class ConversationViewModel
list.addAll(newList)
eventsList = list
updateEvents.postValue(Event(true))
isEmpty.postValue(eventsList.isEmpty)
isEmpty.postValue(eventsList.isEmpty())
}
@WorkerThread
@ -726,7 +730,7 @@ class ConversationViewModel
list.addAll(eventsList)
eventsList = list
updateEvents.postValue(Event(true))
isEmpty.postValue(eventsList.isEmpty)
isEmpty.postValue(eventsList.isEmpty())
}
@WorkerThread
@ -761,6 +765,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)
@ -784,7 +801,7 @@ class ConversationViewModel
eventsList.addAll(processGroupedEvents(arrayListOf(event)))
} else {
for (event in history) {
if (groupedEventLogs.isEmpty) {
if (groupedEventLogs.isEmpty()) {
groupedEventLogs.add(event)
continue
}
@ -925,11 +942,7 @@ class ConversationViewModel
} else {
R.string.conversation_search_no_more_match
}
showRedToastEvent.postValue(
Event(
Pair(message, R.drawable.magnifying_glass)
)
)
showRedToast(message, R.drawable.magnifying_glass)
} else {
Log.i(
"$TAG Found result [${match.chatMessage?.messageId}] while looking up for message with text [$textToSearch] in direction [$direction] starting from message [${latestMatch?.chatMessage?.messageId}]"
@ -962,7 +975,7 @@ class ConversationViewModel
@UiThread
fun copyFileToUri(filePath: String, dest: Uri) {
val source = Uri.parse(FileUtils.getProperFilePath(filePath))
val source = FileUtils.getProperFilePath(filePath).toUri()
Log.i("$TAG Copying file URI [$source] to [$dest]")
viewModelScope.launch {
withContext(Dispatchers.IO) {
@ -971,24 +984,10 @@ class ConversationViewModel
Log.i(
"$TAG File [$filePath] has been successfully exported to documents"
)
showGreenToastEvent.postValue(
Event(
Pair(
R.string.file_successfully_exported_to_documents_toast,
R.drawable.check
)
)
)
showGreenToast(R.string.file_successfully_exported_to_documents_toast, R.drawable.check)
} else {
Log.e("$TAG Failed to export file [$filePath] to documents!")
showRedToastEvent.postValue(
Event(
Pair(
R.string.export_file_to_documents_error_toast,
R.drawable.warning_circle
)
)
)
showRedToast(R.string.export_file_to_documents_error_toast, R.drawable.warning_circle)
}
}
}

View file

@ -33,7 +33,6 @@ import org.linphone.core.Friend
import org.linphone.core.tools.Log
import org.linphone.ui.main.chat.model.ConversationModel
import org.linphone.ui.main.viewmodel.AbstractMainViewModel
import org.linphone.utils.Event
import org.linphone.utils.LinphoneUtils
class ConversationsListViewModel
@ -55,7 +54,7 @@ class ConversationsListViewModel
state: ChatRoom.State?
) {
Log.i(
"$TAG Conversation [${LinphoneUtils.getChatRoomId(chatRoom)}] state changed [$state]"
"$TAG Conversation [${LinphoneUtils.getConversationId(chatRoom)}] state changed [$state]"
)
when (state) {
@ -67,7 +66,17 @@ class ConversationsListViewModel
@WorkerThread
override fun onMessageSent(core: Core, chatRoom: ChatRoom, message: ChatMessage) {
reorderChatRooms()
val id = LinphoneUtils.getConversationId(chatRoom)
val found = conversations.value.orEmpty().find {
it.id == id
}
if (found == null) {
Log.i("$TAG Message sent for a conversation not yet in the list (probably was empty), adding it")
addChatRoom(chatRoom)
} else {
Log.i("$TAG Message sent for an existing conversation, re-order them")
reorderChatRooms()
}
}
@WorkerThread
@ -76,7 +85,17 @@ class ConversationsListViewModel
chatRoom: ChatRoom,
messages: Array<out ChatMessage>
) {
reorderChatRooms()
val id = LinphoneUtils.getConversationId(chatRoom)
val found = conversations.value.orEmpty().find {
it.id == id
}
if (found == null) {
Log.i("$TAG Message(s) received for a conversation not yet in the list (probably was empty), adding it")
addChatRoom(chatRoom)
} else {
Log.i("$TAG Message(s) received for an existing conversation, re-order them")
reorderChatRooms()
}
}
}
@ -170,7 +189,8 @@ class ConversationsListViewModel
}
val hideEmptyChatRooms = coreContext.core.config.getBool("misc", "hide_empty_chat_rooms", true)
if (hideEmptyChatRooms && chatRoom.lastMessageInHistory == null) {
// Hide empty chat rooms only applies to 1-1 conversations
if (hideEmptyChatRooms && !LinphoneUtils.isChatRoomAGroup(chatRoom) && chatRoom.lastMessageInHistory == null) {
Log.w("$TAG Chat room with local address [${localAddress.asStringUriOnly()}] and peer address [${peerAddress.asStringUriOnly()}] is empty, not adding it to match Core setting")
return
}
@ -220,11 +240,7 @@ class ConversationsListViewModel
)
}
showGreenToastEvent.postValue(
Event(
Pair(R.string.conversation_deleted_toast, R.drawable.chat_teardrop_text)
)
)
showGreenToast(R.string.conversation_deleted_toast, R.drawable.chat_teardrop_text)
}
@WorkerThread

View file

@ -154,8 +154,10 @@ class SendMessageInConversationViewModel
isFileTransferServerAvailable.postValue(!core.fileTransferServer.isNullOrEmpty())
}
isKeyboardOpen.value = false
isEmojiPickerOpen.value = false
areFilePickersOpen.value = false
isVoiceRecording.value = false
isPlayingVoiceRecord.value = false
isCallConversation.value = false
maxNumberOfAttachmentsReached.value = false
@ -191,6 +193,7 @@ class SendMessageInConversationViewModel
@UiThread
fun configureChatRoom(room: ChatRoom) {
Log.i("$TAG Chat room configured")
chatRoom = room
coreContext.postOnCoreThread {
chatRoom.addListener(chatRoomListener)
@ -247,6 +250,8 @@ class SendMessageInConversationViewModel
@UiThread
fun sendMessage() {
coreContext.postOnCoreThread {
val isBasicChatRoom: Boolean = chatRoom.hasCapability(ChatRoom.Capabilities.Basic.toInt())
val messageToReplyTo = chatMessageToReplyTo
val message = if (messageToReplyTo != null) {
Log.i("$TAG Sending message as reply to [${messageToReplyTo.messageId}]")
@ -254,10 +259,12 @@ class SendMessageInConversationViewModel
} else {
chatRoom.createEmptyMessage()
}
var contentAdded = false
val toSend = textToSend.value.orEmpty().trim()
if (toSend.isNotEmpty()) {
message.addUtf8TextContent(toSend)
contentAdded = true
}
if (isVoiceRecording.value == true && voiceMessageRecorder.file != null) {
@ -267,7 +274,14 @@ class SendMessageInConversationViewModel
Log.i(
"$TAG Voice recording content created, file name is ${content.name} and duration is ${content.fileDuration}"
)
message.addContent(content)
if (isBasicChatRoom && contentAdded) {
val voiceMessage = chatRoom.createEmptyMessage()
voiceMessage.addContent(content)
voiceMessage.send()
} else {
message.addContent(content)
contentAdded = true
}
} else {
Log.e("$TAG Voice recording content couldn't be created!")
}
@ -292,7 +306,14 @@ class SendMessageInConversationViewModel
// Let the file body handler take care of the upload
content.filePath = attachment.path
message.addFileContent(content)
if (isBasicChatRoom && contentAdded) {
val fileMessage = chatRoom.createEmptyMessage()
fileMessage.addFileContent(content)
fileMessage.send()
} else {
message.addFileContent(content)
contentAdded = true
}
}
}
@ -318,6 +339,16 @@ class SendMessageInConversationViewModel
attachments.postValue(attachmentsList)
chatMessageToReplyTo = null
maxNumberOfAttachmentsReached.postValue(false)
}
}
@UiThread
fun notifyChatMessageIsBeingComposed() {
coreContext.postOnCoreThread {
if (::chatRoom.isInitialized) {
chatRoom.compose()
}
}
}
@ -353,14 +384,7 @@ class SendMessageInConversationViewModel
Log.w(
"$TAG Max number of attachments [$MAX_FILES_TO_ATTACH] reached, file [$file] won't be attached"
)
showRedToastEvent.postValue(
Event(
Pair(
R.string.conversation_maximum_number_of_attachments_reached,
R.drawable.warning_circle
)
)
)
showRedToast(R.string.conversation_maximum_number_of_attachments_reached, R.drawable.warning_circle)
viewModelScope.launch {
Log.i("$TAG Deleting temporary file [$file]")
FileUtils.deleteFile(file)
@ -373,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, false) { model ->
removeAttachment(model.path)
}
@ -409,7 +433,7 @@ class SendMessageInConversationViewModel
attachments.value = list
maxNumberOfAttachmentsReached.value = list.size >= MAX_FILES_TO_ATTACH
if (list.isEmpty) {
if (list.isEmpty()) {
isFileAttachmentsListOpen.value = false
}
}
@ -423,9 +447,7 @@ class SendMessageInConversationViewModel
Log.i("$TAG Sending forwarded message")
forwardedMessage.send()
showGreenToastEvent.postValue(
Event(Pair(R.string.conversation_message_forwarded_toast, R.drawable.forward))
)
showGreenToast(R.string.conversation_message_forwarded_toast, R.drawable.forward)
}
}
}
@ -580,14 +602,7 @@ class SendMessageInConversationViewModel
"$TAG Max duration for voice recording exceeded (${maxVoiceRecordDuration}ms), stopping."
)
stopVoiceRecorder()
showRedToastEvent.postValue(
Event(
Pair(
R.string.conversation_voice_recording_max_duration_reached_toast,
R.drawable.warning_circle
)
)
)
showRedToast(R.string.conversation_voice_recording_max_duration_reached_toast, R.drawable.warning_circle)
}
}
}.launchIn(viewModelScope)
@ -652,11 +667,7 @@ class SendMessageInConversationViewModel
val lowMediaVolume = AudioUtils.isMediaVolumeLow(context)
if (lowMediaVolume) {
Log.w("$TAG Media volume is low, notifying user as they may not hear voice message")
showRedToastEvent.postValue(
Event(
Pair(R.string.media_playback_low_volume_warning_toast, R.drawable.speaker_slash)
)
)
showRedToast(R.string.media_playback_low_volume_warning_toast, R.drawable.speaker_slash)
}
if (voiceRecordAudioFocusRequest == null) {

View file

@ -55,8 +55,8 @@ class StartConversationViewModel
MutableLiveData<Event<Int>>()
}
val chatRoomCreatedEvent: MutableLiveData<Event<Pair<String, String>>> by lazy {
MutableLiveData<Event<Pair<String, String>>>()
val chatRoomCreatedEvent: MutableLiveData<Event<String>> by lazy {
MutableLiveData<Event<String>>()
}
private val chatRoomListener = object : ChatRoomListenerStub() {
@ -65,21 +65,14 @@ class StartConversationViewModel
val state = chatRoom.state
if (state == ChatRoom.State.Instantiated) return
val id = LinphoneUtils.getChatRoomId(chatRoom)
val id = LinphoneUtils.getConversationId(chatRoom)
Log.i("$TAG Conversation [$id] (${chatRoom.subject}) state changed: [$state]")
if (state == ChatRoom.State.Created) {
Log.i("$TAG Conversation [$id] successfully created")
chatRoom.removeListener(this)
operationInProgress.postValue(false)
chatRoomCreatedEvent.postValue(
Event(
Pair(
chatRoom.localAddress.asStringUriOnly(),
chatRoom.peerAddress.asStringUriOnly()
)
)
)
chatRoomCreatedEvent.postValue(Event(LinphoneUtils.getConversationId(chatRoom)))
} else if (state == ChatRoom.State.CreationFailed) {
Log.e("$TAG Conversation [$id] creation has failed!")
chatRoom.removeListener(this)
@ -119,6 +112,8 @@ class StartConversationViewModel
params.isGroupEnabled = true
params.subject = groupChatRoomSubject
params.securityLevel = Conference.SecurityLevel.EndToEnd
params.account = account
val chatParams = params.chatParams ?: return@postOnCoreThread
chatParams.ephemeralLifetime = 0 // Make sure ephemeral is disabled by default
chatParams.backend = ChatRoom.Backend.FlexisipChat
@ -127,30 +122,18 @@ class StartConversationViewModel
for (participant in selection.value.orEmpty()) {
participants.add(participant.address)
}
val localAddress = account.params.identityAddress
val participantsArray = arrayOf<Address>()
val chatRoom = core.createChatRoom(
params,
localAddress,
participants.toArray(participantsArray)
)
val chatRoom = core.createChatRoom(params, participants.toArray(participantsArray))
if (chatRoom != null) {
if (chatParams.backend == ChatRoom.Backend.FlexisipChat) {
if (chatRoom.state == ChatRoom.State.Created) {
val id = LinphoneUtils.getChatRoomId(chatRoom)
val id = LinphoneUtils.getConversationId(chatRoom)
Log.i(
"$TAG Group conversation [$id] ($groupChatRoomSubject) has been created"
)
operationInProgress.postValue(false)
chatRoomCreatedEvent.postValue(
Event(
Pair(
chatRoom.localAddress.asStringUriOnly(),
chatRoom.peerAddress.asStringUriOnly()
)
)
)
chatRoomCreatedEvent.postValue(Event(LinphoneUtils.getConversationId(chatRoom)))
} else {
Log.i(
"$TAG Conversation [$groupChatRoomSubject] isn't in Created state yet, wait for it"
@ -158,17 +141,10 @@ class StartConversationViewModel
chatRoom.addListener(chatRoomListener)
}
} else {
val id = LinphoneUtils.getChatRoomId(chatRoom)
val id = LinphoneUtils.getConversationId(chatRoom)
Log.i("$TAG Conversation successfully created [$id] ($groupChatRoomSubject)")
operationInProgress.postValue(false)
chatRoomCreatedEvent.postValue(
Event(
Pair(
chatRoom.localAddress.asStringUriOnly(),
chatRoom.peerAddress.asStringUriOnly()
)
)
)
chatRoomCreatedEvent.postValue(Event(LinphoneUtils.getConversationId(chatRoom)))
}
} else {
Log.e("$TAG Failed to create group conversation [$groupChatRoomSubject]!")
@ -197,18 +173,20 @@ class StartConversationViewModel
params.isChatEnabled = true
params.isGroupEnabled = false
params.subject = AppUtils.getString(R.string.conversation_one_to_one_hidden_subject)
params.account = account
val chatParams = params.chatParams ?: return
chatParams.ephemeralLifetime = 0 // Make sure ephemeral is disabled by default
val sameDomain = remote.domain == corePreferences.defaultDomain && remote.domain == account.params.domain
if (account.params.instantMessagingEncryptionMandatory && sameDomain) {
Log.i("$TAG Account is in secure mode & domain matches, creating a E2E conversation")
Log.i("$TAG Account is in secure mode & domain matches, creating an E2E encrypted conversation")
chatParams.backend = ChatRoom.Backend.FlexisipChat
params.securityLevel = Conference.SecurityLevel.EndToEnd
} else if (!account.params.instantMessagingEncryptionMandatory) {
if (LinphoneUtils.isEndToEndEncryptedChatAvailable(core)) {
Log.i(
"$TAG Account is in interop mode but LIME is available, creating a E2E conversation"
"$TAG Account is in interop mode but LIME is available, creating an E2E encrypted conversation"
)
chatParams.backend = ChatRoom.Backend.FlexisipChat
params.securityLevel = Conference.SecurityLevel.EndToEnd
@ -237,37 +215,24 @@ class StartConversationViewModel
Log.i(
"$TAG No existing 1-1 conversation between local account [${localAddress?.asStringUriOnly()}] and remote [${remote.asStringUriOnly()}] was found for given parameters, let's create it"
)
val chatRoom = core.createChatRoom(params, localAddress, participants)
val chatRoom = core.createChatRoom(params, participants)
if (chatRoom != null) {
if (chatParams.backend == ChatRoom.Backend.FlexisipChat) {
if (chatRoom.state == ChatRoom.State.Created) {
val id = LinphoneUtils.getChatRoomId(chatRoom)
val state = chatRoom.state
if (state == ChatRoom.State.Created) {
val id = LinphoneUtils.getConversationId(chatRoom)
Log.i("$TAG 1-1 conversation [$id] has been created")
operationInProgress.postValue(false)
chatRoomCreatedEvent.postValue(
Event(
Pair(
chatRoom.localAddress.asStringUriOnly(),
chatRoom.peerAddress.asStringUriOnly()
)
)
)
chatRoomCreatedEvent.postValue(Event(LinphoneUtils.getConversationId(chatRoom)))
} else {
Log.i("$TAG Conversation isn't in Created state yet, wait for it")
Log.i("$TAG Conversation isn't in Created state yet (state is [$state]), wait for it")
chatRoom.addListener(chatRoomListener)
}
} else {
val id = LinphoneUtils.getChatRoomId(chatRoom)
val id = LinphoneUtils.getConversationId(chatRoom)
Log.i("$TAG Conversation successfully created [$id]")
operationInProgress.postValue(false)
chatRoomCreatedEvent.postValue(
Event(
Pair(
chatRoom.localAddress.asStringUriOnly(),
chatRoom.peerAddress.asStringUriOnly()
)
)
)
chatRoomCreatedEvent.postValue(Event(LinphoneUtils.getConversationId(chatRoom)))
}
} else {
Log.e("$TAG Failed to create 1-1 conversation with [${remote.asStringUriOnly()}]!")
@ -281,14 +246,7 @@ class StartConversationViewModel
"$TAG A 1-1 conversation between local account [${localAddress?.asStringUriOnly()}] and remote [${remote.asStringUriOnly()}] for given parameters already exists!"
)
operationInProgress.postValue(false)
chatRoomCreatedEvent.postValue(
Event(
Pair(
existingChatRoom.localAddress.asStringUriOnly(),
existingChatRoom.peerAddress.asStringUriOnly()
)
)
)
chatRoomCreatedEvent.postValue(Event(LinphoneUtils.getConversationId(existingChatRoom)))
}
}

View file

@ -24,7 +24,6 @@ import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.provider.ContactsContract
import android.view.LayoutInflater
@ -52,6 +51,7 @@ import org.linphone.utils.AppUtils
import org.linphone.utils.ConfirmationDialogModel
import org.linphone.utils.DialogUtils
import org.linphone.utils.Event
import androidx.core.net.toUri
@UiThread
class ContactFragment : SlidingPaneChildFragment() {
@ -171,7 +171,7 @@ class ContactFragment : SlidingPaneChildFragment() {
viewModel.openNativeContactEditor.observe(viewLifecycleOwner) {
it.consume { uri ->
val editIntent = Intent(Intent.ACTION_EDIT).apply {
setDataAndType(Uri.parse(uri), ContactsContract.Contacts.CONTENT_ITEM_TYPE)
setDataAndType(uri.toUri(), ContactsContract.Contacts.CONTENT_ITEM_TYPE)
putExtra("finishActivityOnSaveCompleted", true)
}
startActivity(editIntent)
@ -188,9 +188,9 @@ class ContactFragment : SlidingPaneChildFragment() {
}
viewModel.goToConversationEvent.observe(viewLifecycleOwner) {
it.consume { pair ->
Log.i("$TAG Going to conversation [${pair.first}][${pair.second}]")
sharedViewModel.showConversationEvent.value = Event(pair)
it.consume { conversationId ->
Log.i("$TAG Going to conversation [$conversationId]")
sharedViewModel.showConversationEvent.value = Event(conversationId)
sharedViewModel.navigateToConversationsEvent.value = Event(true)
}
}
@ -287,7 +287,7 @@ class ContactFragment : SlidingPaneChildFragment() {
)
val smsIntent: Intent = Intent().apply {
action = Intent.ACTION_SENDTO
data = Uri.parse("smsto:$number")
data = "smsto:$number".toUri()
putExtra("address", number)
putExtra("sms_body", smsBody)
}
@ -319,7 +319,7 @@ class ContactFragment : SlidingPaneChildFragment() {
private fun showConfirmTrustCallDialog(contactName: String, deviceSipUri: String) {
val label = AppUtils.getFormattedString(
org.linphone.R.string.contact_dialog_increase_trust_level_message,
R.string.contact_dialog_increase_trust_level_message,
contactName,
deviceSipUri
)

View file

@ -35,6 +35,7 @@ import org.linphone.core.SecurityLevel
import org.linphone.core.tools.Log
import org.linphone.utils.AppUtils
import org.linphone.utils.TimestampUtils
import androidx.core.net.toUri
class ContactAvatarModel
@WorkerThread
@ -151,7 +152,7 @@ class ContactAvatarModel
private fun getAvatarUri(friend: Friend): Uri? {
val picturePath = friend.photo
if (!picturePath.isNullOrEmpty()) {
return Uri.parse(picturePath)
return picturePath.toUri()
}
val refKey = friend.refKey

View file

@ -39,15 +39,24 @@ class ContactNumberOrAddressModel
) {
val selected = MutableLiveData<Boolean>()
private var actionDoneCallback: (() -> Unit)? = null
@UiThread
fun setActionDoneCallback(lambda: () -> Unit) {
actionDoneCallback = lambda
}
@UiThread
fun onClicked() {
listener.onClicked(this)
actionDoneCallback?.invoke()
}
@UiThread
fun onLongPress(): Boolean {
selected.value = true
listener.onLongPress(this)
actionDoneCallback?.invoke()
return true
}
}

View file

@ -33,6 +33,11 @@ class NumberOrAddressPickerDialogModel
val dismissEvent = MutableLiveData<Event<Boolean>>()
init {
for (model in list) {
model.setActionDoneCallback {
dismiss()
}
}
sipAddressesAndPhoneNumbers.value = list
}

View file

@ -19,7 +19,6 @@
*/
package org.linphone.ui.main.contacts.viewmodel
import android.net.Uri
import androidx.annotation.AnyThread
import androidx.annotation.UiThread
import androidx.annotation.WorkerThread
@ -42,6 +41,7 @@ import org.linphone.ui.GenericViewModel
import org.linphone.ui.main.contacts.model.NewOrEditNumberOrAddressModel
import org.linphone.utils.Event
import org.linphone.utils.FileUtils
import androidx.core.net.toUri
class ContactNewOrEditViewModel
@UiThread
@ -145,14 +145,7 @@ class ContactNewOrEditViewModel
val organization = company.value.orEmpty().trim()
if (fn.isEmpty() && ln.isEmpty() && organization.isEmpty()) {
Log.e("$TAG At least a mandatory field wasn't filled, aborting save")
showRedToastEvent.postValue(
Event(
Pair(
R.string.contact_editor_mandatory_field_not_filled_toast,
R.drawable.warning_circle
)
)
)
showRedToast(R.string.contact_editor_mandatory_field_not_filled_toast, R.drawable.warning_circle)
return
}
@ -190,7 +183,7 @@ class ContactNewOrEditViewModel
isImage = true,
overrideExisting = true
)
val oldFile = Uri.parse(FileUtils.getProperFilePath(picture))
val oldFile = FileUtils.getProperFilePath(picture).toUri()
viewModelScope.launch {
FileUtils.copyFile(oldFile, newFile)
}

View file

@ -117,8 +117,8 @@ class ContactViewModel
MutableLiveData<Event<String>>()
}
val goToConversationEvent: MutableLiveData<Event<Pair<String, String>>> by lazy {
MutableLiveData<Event<Pair<String, String>>>()
val goToConversationEvent: MutableLiveData<Event<String>> by lazy {
MutableLiveData<Event<String>>()
}
val vCardTerminatedEvent: MutableLiveData<Event<Pair<String, File>>> by lazy {
@ -198,21 +198,14 @@ class ContactViewModel
val state = chatRoom.state
if (state == ChatRoom.State.Instantiated) return
val id = LinphoneUtils.getChatRoomId(chatRoom)
val id = LinphoneUtils.getConversationId(chatRoom)
Log.i("$TAG Conversation [$id] (${chatRoom.subject}) state changed: [$state]")
if (state == ChatRoom.State.Created) {
Log.i("$TAG Conversation [$id] successfully created")
chatRoom.removeListener(this)
operationInProgress.postValue(false)
goToConversationEvent.postValue(
Event(
Pair(
chatRoom.localAddress.asStringUriOnly(),
chatRoom.peerAddress.asStringUriOnly()
)
)
)
goToConversationEvent.postValue(Event(LinphoneUtils.getConversationId(chatRoom)))
} else if (state == ChatRoom.State.CreationFailed) {
Log.e("$TAG Conversation [$id] creation has failed!")
chatRoom.removeListener(this)
@ -506,20 +499,22 @@ class ContactViewModel
params.isChatEnabled = true
params.isGroupEnabled = false
params.subject = AppUtils.getString(R.string.conversation_one_to_one_hidden_subject)
params.account = account
val chatParams = params.chatParams ?: return
chatParams.ephemeralLifetime = 0 // Make sure ephemeral is disabled by default
val sameDomain = remote.domain == corePreferences.defaultDomain && remote.domain == account.params.domain
if (account.params.instantMessagingEncryptionMandatory && sameDomain) {
Log.i(
"$TAG Account is in secure mode & domain matches, creating a E2E conversation"
"$TAG Account is in secure mode & domain matches, creating an E2E encrypted conversation"
)
chatParams.backend = ChatRoom.Backend.FlexisipChat
params.securityLevel = Conference.SecurityLevel.EndToEnd
} else if (!account.params.instantMessagingEncryptionMandatory) {
if (LinphoneUtils.isEndToEndEncryptedChatAvailable(core)) {
Log.i(
"$TAG Account is in interop mode but LIME is available, creating a E2E conversation"
"$TAG Account is in interop mode but LIME is available, creating an E2E encrypted conversation"
)
chatParams.backend = ChatRoom.Backend.FlexisipChat
params.securityLevel = Conference.SecurityLevel.EndToEnd
@ -543,49 +538,33 @@ class ContactViewModel
val existingChatRoom = core.searchChatRoom(params, localAddress, null, participants)
if (existingChatRoom != null) {
Log.i(
"$TAG Found existing conversation [${LinphoneUtils.getChatRoomId(
"$TAG Found existing conversation [${LinphoneUtils.getConversationId(
existingChatRoom
)}], going to it"
)
goToConversationEvent.postValue(
Event(Pair(localSipUri, existingChatRoom.peerAddress.asStringUriOnly()))
)
goToConversationEvent.postValue(Event(LinphoneUtils.getConversationId(existingChatRoom)))
} else {
Log.i(
"$TAG No existing conversation between [$localSipUri] and [$remoteSipUri] was found, let's create it"
)
operationInProgress.postValue(true)
val chatRoom = core.createChatRoom(params, localAddress, participants)
val chatRoom = core.createChatRoom(params, participants)
if (chatRoom != null) {
if (chatParams.backend == ChatRoom.Backend.FlexisipChat) {
if (chatRoom.state == ChatRoom.State.Created) {
val id = LinphoneUtils.getChatRoomId(chatRoom)
val id = LinphoneUtils.getConversationId(chatRoom)
Log.i("$TAG 1-1 conversation [$id] has been created")
operationInProgress.postValue(false)
goToConversationEvent.postValue(
Event(
Pair(
chatRoom.localAddress.asStringUriOnly(),
chatRoom.peerAddress.asStringUriOnly()
)
)
)
goToConversationEvent.postValue(Event(LinphoneUtils.getConversationId(chatRoom)))
} else {
Log.i("$TAG Conversation isn't in Created state yet, wait for it")
chatRoom.addListener(chatRoomListener)
}
} else {
val id = LinphoneUtils.getChatRoomId(chatRoom)
val id = LinphoneUtils.getConversationId(chatRoom)
Log.i("$TAG Conversation successfully created [$id]")
operationInProgress.postValue(false)
goToConversationEvent.postValue(
Event(
Pair(
chatRoom.localAddress.asStringUriOnly(),
chatRoom.peerAddress.asStringUriOnly()
)
)
)
goToConversationEvent.postValue(Event(LinphoneUtils.getConversationId(chatRoom)))
}
} else {
Log.e(

View file

@ -242,9 +242,7 @@ class ContactsListViewModel
coreContext.contactsManager.contactRemoved(contactModel.friend)
contactModel.friend.remove()
coreContext.contactsManager.notifyContactsListChanged()
showGreenToastEvent.postValue(
Event(Pair(R.string.contact_deleted_toast, R.drawable.warning_circle))
)
showGreenToast(R.string.contact_deleted_toast, R.drawable.warning_circle)
}
}

View file

@ -20,7 +20,6 @@
package org.linphone.ui.main.fragment
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.Gravity
import android.view.LayoutInflater
@ -46,6 +45,7 @@ import org.linphone.ui.assistant.AssistantActivity
import org.linphone.ui.main.MainActivity
import org.linphone.ui.main.settings.fragment.AccountProfileFragmentDirections
import org.linphone.ui.main.viewmodel.DrawerMenuViewModel
import androidx.core.net.toUri
@UiThread
class DrawerMenuFragment : GenericMainFragment() {
@ -146,7 +146,7 @@ class DrawerMenuFragment : GenericMainFragment() {
viewModel.openLinkInBrowserEvent.observe(viewLifecycleOwner) {
it.consume { link ->
try {
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(link))
val browserIntent = Intent(Intent.ACTION_VIEW, link.toUri())
startActivity(browserIntent)
} catch (ise: IllegalStateException) {
Log.e(

View file

@ -167,7 +167,7 @@ abstract class GenericAddressPickerFragment : GenericMainFragment() {
coreContext.postOnCoreThread { core ->
val friend = model.friend
if (friend == null) {
Log.i("$TAG Friend is null, using address [${model.address}]")
Log.i("$TAG Friend is null, using address [${model.address.asStringUriOnly()}]")
val fakeFriend = core.createFriend()
fakeFriend.addAddress(model.address)
onAddressSelected(model.address, fakeFriend)

View file

@ -39,10 +39,6 @@ import org.linphone.ui.main.fragment.GenericMainFragment
import org.linphone.ui.main.help.viewmodel.HelpViewModel
class DebugFragment : GenericMainFragment() {
companion object {
private const val TAG = "[Debug Fragment]"
}
private lateinit var binding: HelpDebugFragmentBinding
val viewModel: HelpViewModel by navGraphViewModels(

View file

@ -20,7 +20,6 @@
package org.linphone.ui.main.help.fragment
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
@ -36,6 +35,7 @@ import org.linphone.ui.main.fragment.GenericMainFragment
import org.linphone.ui.main.help.viewmodel.HelpViewModel
import org.linphone.utils.ConfirmationDialogModel
import org.linphone.utils.DialogUtils
import androidx.core.net.toUri
@UiThread
class HelpFragment : GenericMainFragment() {
@ -78,7 +78,7 @@ class HelpFragment : GenericMainFragment() {
binding.setPrivacyPolicyClickListener {
val url = getString(R.string.website_privacy_policy_url)
try {
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
val browserIntent = Intent(Intent.ACTION_VIEW, url.toUri())
startActivity(browserIntent)
} catch (ise: IllegalStateException) {
Log.e(
@ -90,7 +90,7 @@ class HelpFragment : GenericMainFragment() {
binding.setLicensesClickListener {
val url = getString(R.string.website_open_source_licences_usage_url)
try {
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
val browserIntent = Intent(Intent.ACTION_VIEW, url.toUri())
startActivity(browserIntent)
} catch (ise: IllegalStateException) {
Log.e(
@ -102,7 +102,7 @@ class HelpFragment : GenericMainFragment() {
binding.setTranslateClickListener {
val url = getString(R.string.website_translate_weblate_url)
try {
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
val browserIntent = Intent(Intent.ACTION_VIEW, url.toUri())
startActivity(browserIntent)
} catch (ise: IllegalStateException) {
Log.e(
@ -157,7 +157,7 @@ class HelpFragment : GenericMainFragment() {
model.confirmEvent.observe(viewLifecycleOwner) {
it.consume {
try {
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
val browserIntent = Intent(Intent.ACTION_VIEW, url.toUri())
startActivity(browserIntent)
} catch (ise: IllegalStateException) {
Log.e(

View file

@ -148,23 +148,20 @@ class HistoryFragment : SlidingPaneChildFragment() {
}
viewModel.goToConversationEvent.observe(viewLifecycleOwner) {
it.consume { pair ->
Log.i("$TAG Going to conversation [${pair.first}][${pair.second}]")
sharedViewModel.showConversationEvent.value = Event(pair)
it.consume { conversationId ->
Log.i("$TAG Going to conversation [$conversationId]")
sharedViewModel.showConversationEvent.value = Event(conversationId)
sharedViewModel.navigateToConversationsEvent.value = Event(true)
}
}
viewModel.goToMeetingConversationEvent.observe(viewLifecycleOwner) {
it.consume { pair ->
val localAddress = pair.first
val remoteAddress = pair.second
it.consume { conversationId ->
if (findNavController().currentDestination?.id == R.id.historyFragment) {
Log.i("$TAG Going to meeting conversation [$localAddress][$remoteAddress]")
Log.i("$TAG Going to meeting conversation [$conversationId]")
val action =
HistoryFragmentDirections.actionHistoryFragmentToConferenceConversationFragment(
localAddress,
remoteAddress
conversationId
)
findNavController().navigate(action)
}

View file

@ -120,7 +120,11 @@ class StartCallFragment : GenericAddressPickerFragment() {
viewModel.leaveFragmentEvent.observe(viewLifecycleOwner) {
it.consume {
goBack()
// Post on main thread to allow for main activity to be resumed
coreContext.postOnMainThread {
Log.i("$TAG Going back")
goBack()
}
}
}

View file

@ -144,8 +144,8 @@ class HistoryListViewModel
val account = LinphoneUtils.getDefaultAccount()
val logs = account?.callLogs ?: coreContext.core.callLogs
for (callLog in logs) {
if (callLog.remoteAddress.asStringUriOnly().contains(filter)) {
val model = CallLogModel(callLog)
val model = CallLogModel(callLog)
if (isCallLogMatchingFilter(model, filter)) {
list.add(model)
count += 1
}
@ -158,4 +158,12 @@ class HistoryListViewModel
Log.i("$TAG Fetched [${list.size}] call log(s)")
callLogs.postValue(list)
}
@WorkerThread
private fun isCallLogMatchingFilter(model: CallLogModel, filter: String): Boolean {
if (filter.isEmpty()) return true
val friendName = model.avatarModel.friend.name ?: LinphoneUtils.getDisplayName(model.address)
return friendName.contains(filter, ignoreCase = true) || model.address.asStringUriOnly().contains(filter, ignoreCase = true)
}
}

Some files were not shown because too many files have changed in this diff Show more