diff --git a/.gitignore b/.gitignore index 56cbd2768..6c785d36f 100644 --- a/.gitignore +++ b/.gitignore @@ -24,5 +24,6 @@ linphone-sdk-android/*.aar app/debug app/release app/releaseAppBundle +app/releaseWithCrashlytics keystore.properties app/src/main/res/xml/contacts.xml diff --git a/.gitlab-ci-files/job-android.yml b/.gitlab-ci-files/job-android.yml index 1127eff87..98fae62c7 100644 --- a/.gitlab-ci-files/job-android.yml +++ b/.gitlab-ci-files/job-android.yml @@ -7,6 +7,7 @@ job-android: before_script: - if ! [ -z ${SCP_PRIVATE_KEY+x} ]; then eval $(ssh-agent -s); fi - if ! [ -z ${SCP_PRIVATE_KEY+x} ]; then echo "$SCP_PRIVATE_KEY" | tr -d '\r' | ssh-add - > /dev/null; fi + - echo "$ANDROID_SETTINGS_GRADLE" > settings.gradle script: - sdkmanager diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d361ca4c..f543da7f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,33 +10,58 @@ Group changes to describe their impact on the project, as follows: Fixed for any bug fixes. Security to invite users to upgrade in case of vulnerabilities. -## [4.6.0] - Unreleased +## [4.7.0] - Unreleased + +## [4.6.0] - 2022-02-09 ### Added - Reply to chat message feature (with original message preview) +- Swipe action on chat messages to reply / delete - Voice recordings in chat feature - Allow video recording in chat file sharing - Unread messages indicator in chat conversation that separates read & unread messages -- Notify incoming/outgoing calls on bluetooth devices using self-managed connections from telecom manager API +- Notify incoming/outgoing calls on bluetooth devices using self-managed connections from telecom manager API (disables SDK audio focus) +- Ask Android to not process what user types in an encrypted chat room to improve privacy, see [IME_FLAG_NO_PERSONALIZED_LEARNING](https://developer.android.com/reference/android/view/inputmethod/EditorInfo#IME_FLAG_NO_PERSONALIZED_LEARNING) +- SIP URIs in chat messages are clickable to easily initiate a call - New video call UI on foldable device like Galaxy Z Fold - Setting to automatically record all calls +- When using a physical keyboard, use left control + enter keys to send message +- Using CallStyle notifications for calls for devices running Android 12 or newer +- New fragment explaining generic SIP account limitations contrary to sip.linphone.org SIP accounts +- Link to Weblate added in about page ### Changed - UI has been reworked around SlidingPane component to better handle tablets & foldable devices - No longer scroll to bottom of chat room when new messages are received, a new button shows up to do it and it displays conversation's unread messages count - Animations have been replaced to use com.google.android.material.transition ones - Using new [Unified Content API](https://developer.android.com/about/versions/12/features/unified-content-api) to share files from keyboard (or other sources) +- Received messages are now trimmed - Bumped dependencies, gradle updated from 4.2.2 to 7.0.2 - Target Android SDK version set to 31 (Android 12) +- Splashscreen is using new APIs - SDK updated to 5.1.0 release +- Updated translations ### Fixed - Chat notifications disappearing when app restarts - "Infinite backstack", now each view is stored (at most) once in the backstack +- Voice messages / call recordings will be played on headset/headphones instead of speaker, if possible - Going back to the dialer when pressing back in a chat room after clicking on a chat message notification +- Missing international prefix / phone number in assistant after granting permission +- Display issue for incoming call notification preventing to use answer/hangup actions on some Xiaomi devices (like Redmi Note 9S) +- Missing foreground service notification for background mode ### Removed +- Launcher Activity has been replaced by [Splash Screen API](https://developer.android.com/reference/kotlin/androidx/core/splashscreen/SplashScreen) +- Dialer will no longer make DTMF sound when pressing digits +- Launcher activity - Global push notification setting in Network, use the switch in each Account instead +- No longer need to monitor device rotation and give information to the Core, it does it by itself + +## [4.5.6] - 2021-11-08 + +### Changed +- SDK updated to 5.0.49 ## [4.5.5] - 2021-10-28 @@ -79,16 +104,11 @@ Group changes to describe their impact on the project, as follows: - Fixed various crashes & other issues - SDK bumped to 5.0.10 -## [4.5.1] - Unreleased - -### Added -- Reply to chat message feature -- Voice recordings messages +## [4.5.1] - 2021-07-15 ### Changed -- Navigation was reworked using SlidingPane widget, reducing code & improving UI on foldables - -### Removed +- Bugs & crashes have been fixed +- SDK bumped to 5.0.1 ## [4.5.0] - 2021-07-08 diff --git a/README.md b/README.md index f04ebdbaa..4715b240a 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,9 @@ Also check you have built the SDK for the right CPU architecture using the `-DLI - Push notification might not work when app has been started by Android Studio consecutively to an install. Remove the app from the recent activity view and start it again using the launcher icon to resolve this. -## Troubleshouting +## Troubleshooting + +### Behavior issue When submitting an issue on our [Github repository](https://github.com/BelledonneCommunications/linphone-android), please follow the template and attach the matching library logs: @@ -105,7 +107,32 @@ When submitting an issue on our [Github repository](https://github.com/Belledonn 2. Then restart the app, reproduce the issue and upload the logs using the `Send logs` button on the About page. -3. Finally paste the link to the uploaded logs (link is already in the clipboard after a sucessful upload). +3. Finally paste the link to the uploaded logs (link is already in the clipboard after a successful upload). + +### Native crash + +First of all, to be able to get a symbolized stack trace, you need the debug version of our libraries. + +If you haven't built the SDK locally (see [building a local SDK](#BuildingalocalSDK)), here's how to get them: + +1. Go to our [maven repository](https://download.linphone.org/maven_repository/org/linphone/linphone-sdk-android-debug/), in the linphone-android-debug directory. + +2. Download the AAR file with **the exact same version** as the AAR that was used to generate the crash's stacktrace. + +3. Extract the AAR somewhere on your computer (it's a simple ZIP file even it's doesn't have the extension). Libraries are stored inside the ```jni``` folder (a directory for each architectured built, usually ```arm64-v8a, armeabi-v7a, x86_64 and x86```). + +4. To get consistent with locally built SDK, rename the ```jni``` directory into ```libs-debug```. + +Now you need the ```ndk-stack``` tool and possibly ```adb logcat```. + +If your computer isn't used for Android development, you can download those tools from [Google website](https://developer.android.com/studio#downloads), in the ```Command line tools only``` section. + +Once you have the debug libraries and the proper tools installed, you can use the ```ndk-stack``` tool to symbolize your stacktrace. Note that you also need to know the architecture (armv7, arm64, x86, etc...) of the libraries that were used. + +Here's how to get the stacktrace and the right architecture from a device plugged to your computer: +``` +adb logcat -d | ndk-stack -sym ./libs-debug/`adb shell getprop ro.product.cpu.abi | tr -d '\r'` +``` ## Create an APK with a different package name @@ -137,6 +164,6 @@ Due to the full app rewrite we can't re-use previous translations, so we'll be v In order to submit a patch for inclusion in linphone's source code: 1. First make sure your patch applies to latest git sources before submitting: patches made to old versions can't and won't be merged. -2. Fill out and send us an email with the link of pullrequest and the [Contributor Agreement](https://linphone.org/sites/default/files/bc-contributor-agreement_0.pdf) for your patch to be included in the git tree. +2. Fill out and send us an email with the link of pull-request and the [Contributor Agreement](https://linphone.org/sites/default/files/bc-contributor-agreement_0.pdf) for your patch to be included in the git tree. The goal of this agreement to grant us peaceful exercise of our rights on the linphone source code, while not losing your rights on your contribution. diff --git a/app/build.gradle b/app/build.gradle index 34c884a21..e4e215d34 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -5,6 +5,9 @@ plugins { id 'org.jlleitschuh.gradle.ktlint' } +def appVersionName = "4.6.0" +def appVersionCode = 40600 // 4.06.00 + static def getPackageName() { return "org.linphone" } @@ -24,7 +27,7 @@ if (crashlyticsEnabled) { def gitBranch = new ByteArrayOutputStream() task getGitVersion() { - def gitVersion = "4.7.0" + def gitVersion = appVersionName def gitVersionStream = new ByteArrayOutputStream() def gitCommitsCount = new ByteArrayOutputStream() def gitCommitHash = new ByteArrayOutputStream() @@ -52,9 +55,9 @@ task getGitVersion() { } else { gitVersion = gitVersionStream.toString().trim() + "." + gitCommitsCount.toString().trim() + "+" + gitCommitHash.toString().trim() } - println("Git version: " + gitVersion) + println("Git version: " + gitVersion + " (" + appVersionCode + ")") } catch (ignored) { - println("Git not found") + println("Git not found, using " + gitVersion + " (" + appVersionCode + ")") } project.version = gitVersion } @@ -84,7 +87,7 @@ android { defaultConfig { minSdkVersion 23 targetSdkVersion 31 - versionCode 4700 + versionCode appVersionCode versionName "${project.version}" applicationId getPackageName() } @@ -95,7 +98,7 @@ android { } // See https://developer.android.com/studio/releases/gradle-plugin#3-6-0-behavior for why extractNativeLibs is set to true in debug flavor - if (variant.buildType.name == "release" || variant.buildType.name == "releaseAppBundle") { + if (variant.buildType.name == "release" || variant.buildType.name == "releaseWithCrashlytics") { variant.getMergedFlavor().manifestPlaceholders = [linphone_address_mime_type: "vnd.android.cursor.item/vnd." + getPackageName() + ".provider.sip_address", linphone_file_provider: getPackageName() + ".fileprovider", appLabel: "@string/app_name", @@ -143,8 +146,8 @@ android { initWith release resValue "bool", "crashlytics_enabled", crashlyticsEnabled.toString() - if (crashlyticsEnabled) { + if (crashlyticsEnabled) { firebaseCrashlytics { nativeSymbolUploadEnabled true unstrippedNativeLibsDir file(LinphoneSdkBuildDir + '/libs-debug/').toString() @@ -161,14 +164,13 @@ android { resValue "string", "sync_account_type", getPackageName() + ".sync" resValue "string", "file_provider", getPackageName() + ".debug.fileprovider" resValue "string", "linphone_address_mime_type", "vnd.android.cursor.item/vnd." + getPackageName() + ".provider.sip_address" + resValue "bool", "crashlytics_enabled", crashlyticsEnabled.toString() if (!firebaseEnabled) { resValue "string", "gcm_defaultSenderId", "none" } - resValue "bool", "crashlytics_enabled", crashlyticsEnabled.toString() if (crashlyticsEnabled) { - firebaseCrashlytics { nativeSymbolUploadEnabled true unstrippedNativeLibsDir file(LinphoneSdkBuildDir + '/libs-debug/').toString() @@ -191,47 +193,28 @@ android { } } -repositories { - maven { - name "local linphone-sdk maven repository" - url file(LinphoneSdkBuildDir + '/maven_repository/') - content { - includeGroup "org.linphone" - } - } - - maven { - name "linphone.org maven repository" - url "https://download.linphone.org/maven_repository" - content { - includeGroup "org.linphone" - } - } -} - dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" - - implementation 'androidx.appcompat:appcompat:1.3.1' + implementation 'androidx.appcompat:appcompat:1.4.1' implementation 'androidx.media:media:1.4.3' - implementation 'androidx.fragment:fragment-ktx:1.4.0-beta01' + implementation 'androidx.fragment:fragment-ktx:1.4.1' implementation 'androidx.core:core-ktx:1.7.0' - def nav_version = "2.4.0-beta01" + def nav_version = "2.4.0" implementation "androidx.navigation:navigation-fragment-ktx:$nav_version" implementation "androidx.navigation:navigation-ui-ktx:$nav_version" - implementation "androidx.slidingpanelayout:slidingpanelayout:1.2.0-beta01" - implementation "androidx.window:window:1.0.0-beta03" + implementation "androidx.slidingpanelayout:slidingpanelayout:1.2.0" + implementation "androidx.window:window:1.0.0" - implementation 'androidx.constraintlayout:constraintlayout:2.1.1' + implementation 'androidx.constraintlayout:constraintlayout:2.1.3' implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.0' implementation 'androidx.recyclerview:recyclerview:1.2.1' implementation "androidx.security:security-crypto-ktx:1.1.0-alpha03" + implementation 'androidx.core:core-splashscreen:1.0.0-beta01' - implementation 'com.google.android.material:material:1.4.0' + implementation 'com.google.android.material:material:1.5.0' implementation 'com.google.android.flexbox:flexbox:3.0.0' implementation 'androidx.emoji:emoji:1.1.0' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7fb8213c6..88547b8ef 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -42,25 +42,21 @@ android:theme="@style/AppTheme" android:allowNativeHeapPointerTagging="false"> - + android:launchMode="singleTask" + android:windowSoftInputMode="adjustResize" + android:theme="@style/AppSplashScreenTheme"> + + + - - - - diff --git a/app/src/main/assets/assistant_default_values b/app/src/main/assets/assistant_default_values index 7e5d7543b..86d8e0be7 100644 --- a/app/src/main/assets/assistant_default_values +++ b/app/src/main/assets/assistant_default_values @@ -16,6 +16,7 @@ 0 + 0
diff --git a/app/src/main/assets/assistant_linphone_default_values b/app/src/main/assets/assistant_linphone_default_values index f7561ee9b..9f2badee0 100644 --- a/app/src/main/assets/assistant_linphone_default_values +++ b/app/src/main/assets/assistant_linphone_default_values @@ -16,6 +16,7 @@ sip.linphone.org sip:conference-factory@sip.linphone.org 1 + 1
stun.linphone.org diff --git a/app/src/main/java/org/linphone/activities/GenericActivity.kt b/app/src/main/java/org/linphone/activities/GenericActivity.kt index 0eec20c2b..f6c8a596e 100644 --- a/app/src/main/java/org/linphone/activities/GenericActivity.kt +++ b/app/src/main/java/org/linphone/activities/GenericActivity.kt @@ -25,13 +25,12 @@ import android.content.res.Configuration import android.os.Bundle import android.util.DisplayMetrics import android.view.Display -import android.view.Surface import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate import androidx.lifecycle.lifecycleScope import androidx.navigation.ActivityNavigator import androidx.window.layout.FoldingFeature -import androidx.window.layout.WindowInfoRepository.Companion.windowInfoRepository +import androidx.window.layout.WindowInfoTracker import androidx.window.layout.WindowLayoutInfo import java.util.* import kotlinx.coroutines.Dispatchers @@ -58,9 +57,12 @@ abstract class GenericActivity : AppCompatActivity() { ensureCoreExists(applicationContext) lifecycleScope.launch(Dispatchers.Main) { - windowInfoRepository().windowLayoutInfo.collect { newLayoutInfo -> - updateCurrentLayout(newLayoutInfo) - } + WindowInfoTracker + .getOrCreate(this@GenericActivity) + .windowLayoutInfo(this@GenericActivity) + .collect { newLayoutInfo -> + updateCurrentLayout(newLayoutInfo) + } } requestedOrientation = if (corePreferences.forcePortrait) { @@ -97,18 +99,6 @@ abstract class GenericActivity : AppCompatActivity() { override fun onResume() { super.onResume() - var degrees = 270 - val orientation = windowManager.defaultDisplay.rotation - when (orientation) { - Surface.ROTATION_0 -> degrees = 0 - Surface.ROTATION_90 -> degrees = 270 - Surface.ROTATION_180 -> degrees = 180 - Surface.ROTATION_270 -> degrees = 90 - } - Log.i("[Generic Activity] Device orientation is $degrees (raw value is $orientation)") - val rotation = (360 - degrees) % 360 - coreContext.core.deviceRotation = rotation - // Remove service notification if it has been started by device boot coreContext.notificationsManager.stopForegroundNotificationIfPossible() } diff --git a/app/src/main/java/org/linphone/activities/GenericFragment.kt b/app/src/main/java/org/linphone/activities/GenericFragment.kt index 94bbfaffa..8cbd2a1d0 100644 --- a/app/src/main/java/org/linphone/activities/GenericFragment.kt +++ b/app/src/main/java/org/linphone/activities/GenericFragment.kt @@ -28,8 +28,12 @@ import androidx.core.view.doOnPreDraw import androidx.databinding.DataBindingUtil import androidx.databinding.ViewDataBinding import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import com.google.android.material.transition.MaterialSharedAxis +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.linphone.LinphoneApplication.Companion.corePreferences import org.linphone.core.tools.Log @@ -38,9 +42,17 @@ abstract class GenericFragment : Fragment() { protected val binding get() = _binding!! protected var useMaterialSharedAxisXForwardAnimation = true + protected fun isBindingAvailable(): Boolean { + return _binding != null + } + protected val onBackPressedCallback = object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { - goBack() + lifecycleScope.launch { + withContext(Dispatchers.Main) { + goBack() + } + } } } diff --git a/app/src/main/java/org/linphone/activities/Navigation.kt b/app/src/main/java/org/linphone/activities/Navigation.kt index e8f289c27..5d6bd190a 100644 --- a/app/src/main/java/org/linphone/activities/Navigation.kt +++ b/app/src/main/java/org/linphone/activities/Navigation.kt @@ -72,6 +72,30 @@ internal fun MainActivity.navigateToDialer(args: Bundle?) { ) } +internal fun MainActivity.navigateToChatRooms(args: Bundle? = null) { + findNavController(R.id.nav_host_fragment).navigate( + R.id.action_global_masterChatRoomsFragment, + args, + popupTo(R.id.masterChatRoomsFragment, true) + ) +} + +internal fun MainActivity.navigateToChatRoom(localAddress: String?, peerAddress: String?) { + val deepLink = "linphone-android://chat-room/$localAddress/$peerAddress" + findNavController(R.id.nav_host_fragment).navigate( + Uri.parse(deepLink), + popupTo(R.id.masterChatRoomsFragment, true) + ) +} + +internal fun MainActivity.navigateToContact(contactId: String?) { + val deepLink = "linphone-android://contact/view/$contactId" + findNavController(R.id.nav_host_fragment).navigate( + Uri.parse(deepLink), + popupTo(R.id.masterContactsFragment, true) + ) +} + /* Tabs fragment related */ internal fun TabsFragment.navigateToCallHistory() { @@ -79,9 +103,8 @@ internal fun TabsFragment.navigateToCallHistory() { R.id.masterContactsFragment -> R.id.action_masterContactsFragment_to_masterCallLogsFragment R.id.dialerFragment -> R.id.action_dialerFragment_to_masterCallLogsFragment R.id.masterChatRoomsFragment -> R.id.action_masterChatRoomsFragment_to_masterCallLogsFragment - else -> 0 + else -> R.id.action_global_masterCallLogsFragment } - if (action == 0) return findNavController().navigate( action, null, @@ -94,9 +117,8 @@ internal fun TabsFragment.navigateToContacts() { R.id.masterCallLogsFragment -> R.id.action_masterCallLogsFragment_to_masterContactsFragment R.id.dialerFragment -> R.id.action_dialerFragment_to_masterContactsFragment R.id.masterChatRoomsFragment -> R.id.action_masterChatRoomsFragment_to_masterContactsFragment - else -> 0 + else -> R.id.action_global_masterContactsFragment } - if (action == 0) return findNavController().navigate( action, null, @@ -109,9 +131,8 @@ internal fun TabsFragment.navigateToDialer() { R.id.masterCallLogsFragment -> R.id.action_masterCallLogsFragment_to_dialerFragment R.id.masterContactsFragment -> R.id.action_masterContactsFragment_to_dialerFragment R.id.masterChatRoomsFragment -> R.id.action_masterChatRoomsFragment_to_dialerFragment - else -> 0 + else -> R.id.action_global_dialerFragment } - if (action == 0) return findNavController().navigate( action, null, @@ -124,9 +145,8 @@ internal fun TabsFragment.navigateToChatRooms() { R.id.masterCallLogsFragment -> R.id.action_masterCallLogsFragment_to_masterChatRoomsFragment R.id.masterContactsFragment -> R.id.action_masterContactsFragment_to_masterChatRoomsFragment R.id.dialerFragment -> R.id.action_dialerFragment_to_masterChatRoomsFragment - else -> 0 + else -> R.id.action_global_masterChatRoomsFragment } - if (action == 0) return findNavController().navigate( action, null, @@ -298,7 +318,15 @@ internal fun DetailChatRoomFragment.navigateToEmptyChatRoom() { findNavController().navigate( R.id.action_global_emptyChatFragment, null, - popupTo(R.id.emptyChatFragment, true) + popupTo(R.id.detailChatRoomFragment, true) + ) +} + +internal fun DetailChatRoomFragment.navigateToDialer(args: Bundle?) { + findMasterNavController().navigate( + R.id.action_global_dialerFragment, + args, + popupTo(R.id.dialerFragment, true) ) } @@ -317,7 +345,7 @@ internal fun ChatRoomCreationFragment.navigateToChatRoom(args: Bundle) { findNavController().navigate( R.id.action_chatRoomCreationFragment_to_detailChatRoomFragment, args, - popupTo(R.id.detailChatRoomFragment, true) + popupTo(R.id.chatRoomCreationFragment, true) ) } } @@ -326,7 +354,7 @@ internal fun ChatRoomCreationFragment.navigateToEmptyChatRoom() { findNavController().navigate( R.id.action_global_emptyChatFragment, null, - popupTo(R.id.emptyChatFragment, true) + popupTo(R.id.chatRoomCreationFragment, true) ) } @@ -782,10 +810,10 @@ internal fun WelcomeFragment.navigateToAccountLogin() { } } -internal fun WelcomeFragment.navigateToGenericLogin() { +internal fun WelcomeFragment.navigateToGenericLoginWarning() { if (findNavController().currentDestination?.id == R.id.welcomeFragment) { findNavController().navigate( - R.id.action_welcomeFragment_to_genericAccountLoginFragment, + R.id.action_welcomeFragment_to_genericAccountWarningFragment, null, popupTo() ) @@ -822,6 +850,16 @@ internal fun AccountLoginFragment.navigateToPhoneAccountValidation(args: Bundle? } } +internal fun GenericAccountWarningFragment.navigateToGenericLogin() { + if (findNavController().currentDestination?.id == R.id.genericAccountWarningFragment) { + findNavController().navigate( + R.id.action_genericAccountWarningFragment_to_genericAccountLoginFragment, + null, + popupTo(R.id.welcomeFragment, popUpInclusive = false) + ) + } +} + internal fun GenericAccountLoginFragment.navigateToEchoCancellerCalibration() { if (findNavController().currentDestination?.id == R.id.genericAccountLoginFragment) { findNavController().navigate( diff --git a/app/src/main/java/org/linphone/activities/SnackBarActivity.kt b/app/src/main/java/org/linphone/activities/SnackBarActivity.kt index 7eb57e548..b28d8e5d4 100644 --- a/app/src/main/java/org/linphone/activities/SnackBarActivity.kt +++ b/app/src/main/java/org/linphone/activities/SnackBarActivity.kt @@ -21,5 +21,6 @@ package org.linphone.activities interface SnackBarActivity { fun showSnackBar(resourceId: Int) + fun showSnackBar(resourceId: Int, action: Int, listener: () -> Unit) fun showSnackBar(message: String) } diff --git a/app/src/main/java/org/linphone/activities/assistant/AssistantActivity.kt b/app/src/main/java/org/linphone/activities/assistant/AssistantActivity.kt index f3524463d..52fd813f9 100644 --- a/app/src/main/java/org/linphone/activities/assistant/AssistantActivity.kt +++ b/app/src/main/java/org/linphone/activities/assistant/AssistantActivity.kt @@ -23,6 +23,7 @@ import android.os.Bundle import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.lifecycle.ViewModelProvider import com.google.android.material.snackbar.Snackbar +import org.linphone.LinphoneApplication.Companion.corePreferences import org.linphone.R import org.linphone.activities.GenericActivity import org.linphone.activities.SnackBarActivity @@ -40,12 +41,23 @@ class AssistantActivity : GenericActivity(), SnackBarActivity { sharedViewModel = ViewModelProvider(this)[SharedAssistantViewModel::class.java] coordinator = findViewById(R.id.coordinator) + + corePreferences.firstStart = false } override fun showSnackBar(resourceId: Int) { Snackbar.make(coordinator, resourceId, Snackbar.LENGTH_LONG).show() } + override fun showSnackBar(resourceId: Int, action: Int, listener: () -> Unit) { + Snackbar + .make(findViewById(R.id.coordinator), resourceId, Snackbar.LENGTH_LONG) + .setAction(action) { + listener() + } + .show() + } + override fun showSnackBar(message: String) { Snackbar.make(coordinator, message, Snackbar.LENGTH_LONG).show() } diff --git a/app/src/main/java/org/linphone/activities/assistant/fragments/AbstractPhoneFragment.kt b/app/src/main/java/org/linphone/activities/assistant/fragments/AbstractPhoneFragment.kt index 7b92f5652..fbc2db617 100644 --- a/app/src/main/java/org/linphone/activities/assistant/fragments/AbstractPhoneFragment.kt +++ b/app/src/main/java/org/linphone/activities/assistant/fragments/AbstractPhoneFragment.kt @@ -20,6 +20,7 @@ package org.linphone.activities.assistant.fragments +import android.annotation.TargetApi import android.content.pm.PackageManager import androidx.databinding.ViewDataBinding import com.google.android.material.dialog.MaterialAlertDialogBuilder @@ -28,10 +29,15 @@ import org.linphone.activities.GenericFragment import org.linphone.activities.assistant.viewmodels.AbstractPhoneViewModel import org.linphone.compatibility.Compatibility import org.linphone.core.tools.Log +import org.linphone.mediastream.Version import org.linphone.utils.PermissionHelper import org.linphone.utils.PhoneNumberUtils abstract class AbstractPhoneFragment : GenericFragment() { + companion object { + const val READ_PHONE_STATE_PERMISSION_REQUEST_CODE = 0 + } + abstract val viewModel: AbstractPhoneViewModel override fun onRequestPermissionsResult( @@ -39,7 +45,7 @@ abstract class AbstractPhoneFragment : GenericFragment() permissions: Array, grantResults: IntArray ) { - if (requestCode == 0) { + if (requestCode == READ_PHONE_STATE_PERMISSION_REQUEST_CODE) { if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { Log.i("[Assistant] READ_PHONE_STATE/READ_PHONE_NUMBERS permission granted") updateFromDeviceInfo() @@ -49,11 +55,12 @@ abstract class AbstractPhoneFragment : GenericFragment() } } - protected fun checkPermission() { + @TargetApi(Version.API23_MARSHMALLOW_60) + protected fun checkPermissions() { if (!resources.getBoolean(R.bool.isTablet)) { if (!PermissionHelper.get().hasReadPhoneStateOrPhoneNumbersPermission()) { Log.i("[Assistant] Asking for READ_PHONE_STATE/READ_PHONE_NUMBERS permission") - Compatibility.requestReadPhoneStateOrNumbersPermission(requireActivity(), 0) + Compatibility.requestReadPhoneStateOrNumbersPermission(this, READ_PHONE_STATE_PERMISSION_REQUEST_CODE) } else { updateFromDeviceInfo() } diff --git a/app/src/main/java/org/linphone/activities/assistant/fragments/AccountLoginFragment.kt b/app/src/main/java/org/linphone/activities/assistant/fragments/AccountLoginFragment.kt index 1a6e1f264..4cf945451 100644 --- a/app/src/main/java/org/linphone/activities/assistant/fragments/AccountLoginFragment.kt +++ b/app/src/main/java/org/linphone/activities/assistant/fragments/AccountLoginFragment.kt @@ -35,6 +35,7 @@ import org.linphone.activities.main.viewmodels.DialogViewModel import org.linphone.activities.navigateToEchoCancellerCalibration import org.linphone.activities.navigateToPhoneAccountValidation import org.linphone.databinding.AssistantAccountLoginFragmentBinding +import org.linphone.mediastream.Version import org.linphone.utils.DialogUtils class AccountLoginFragment : AbstractPhoneFragment() { @@ -52,7 +53,10 @@ class AccountLoginFragment : AbstractPhoneFragment - (requireActivity() as AssistantActivity).showSnackBar(message) - } + viewLifecycleOwner + ) { + it.consume { message -> + (requireActivity() as AssistantActivity).showSnackBar(message) } - ) + } - checkPermission() + if (Version.sdkAboveOrEqual(Version.API23_MARSHMALLOW_60)) { + checkPermissions() + } } } diff --git a/app/src/main/java/org/linphone/activities/assistant/fragments/EchoCancellerCalibrationFragment.kt b/app/src/main/java/org/linphone/activities/assistant/fragments/EchoCancellerCalibrationFragment.kt index 087f7fea0..626e35017 100644 --- a/app/src/main/java/org/linphone/activities/assistant/fragments/EchoCancellerCalibrationFragment.kt +++ b/app/src/main/java/org/linphone/activities/assistant/fragments/EchoCancellerCalibrationFragment.kt @@ -31,6 +31,10 @@ import org.linphone.databinding.AssistantEchoCancellerCalibrationFragmentBinding import org.linphone.utils.PermissionHelper class EchoCancellerCalibrationFragment : GenericFragment() { + companion object { + const val RECORD_AUDIO_PERMISSION_REQUEST_CODE = 0 + } + private lateinit var viewModel: EchoCancellerCalibrationViewModel override fun getLayoutId(): Int = R.layout.assistant_echo_canceller_calibration_fragment @@ -44,17 +48,16 @@ class EchoCancellerCalibrationFragment : GenericFragment, grantResults: IntArray ) { - val granted = grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED - if (granted) { - Log.i("[Echo Canceller Calibration] RECORD_AUDIO permission granted") - viewModel.startEchoCancellerCalibration() - } else { - Log.w("[Echo Canceller Calibration] RECORD_AUDIO permission denied") - requireActivity().finish() + if (requestCode == RECORD_AUDIO_PERMISSION_REQUEST_CODE) { + val granted = + grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED + if (granted) { + Log.i("[Echo Canceller Calibration] RECORD_AUDIO permission granted") + viewModel.startEchoCancellerCalibration() + } else { + Log.w("[Echo Canceller Calibration] RECORD_AUDIO permission denied") + requireActivity().finish() + } } } } diff --git a/app/src/main/java/org/linphone/activities/assistant/fragments/EmailAccountCreationFragment.kt b/app/src/main/java/org/linphone/activities/assistant/fragments/EmailAccountCreationFragment.kt index fa5c9d718..d9ed6aa89 100644 --- a/app/src/main/java/org/linphone/activities/assistant/fragments/EmailAccountCreationFragment.kt +++ b/app/src/main/java/org/linphone/activities/assistant/fragments/EmailAccountCreationFragment.kt @@ -50,21 +50,19 @@ class EmailAccountCreationFragment : GenericFragment - (requireActivity() as AssistantActivity).showSnackBar(message) - } + viewLifecycleOwner + ) { + it.consume { message -> + (requireActivity() as AssistantActivity).showSnackBar(message) } - ) + } } } diff --git a/app/src/main/java/org/linphone/activities/assistant/fragments/EmailAccountValidationFragment.kt b/app/src/main/java/org/linphone/activities/assistant/fragments/EmailAccountValidationFragment.kt index dd2f5aeef..e6f4cd409 100644 --- a/app/src/main/java/org/linphone/activities/assistant/fragments/EmailAccountValidationFragment.kt +++ b/app/src/main/java/org/linphone/activities/assistant/fragments/EmailAccountValidationFragment.kt @@ -49,27 +49,25 @@ class EmailAccountValidationFragment : GenericFragment - (requireActivity() as AssistantActivity).showSnackBar(message) - } + viewLifecycleOwner + ) { + it.consume { message -> + (requireActivity() as AssistantActivity).showSnackBar(message) } - ) + } } } diff --git a/app/src/main/java/org/linphone/activities/assistant/fragments/GenericAccountLoginFragment.kt b/app/src/main/java/org/linphone/activities/assistant/fragments/GenericAccountLoginFragment.kt index 2b81c1336..9dc119313 100644 --- a/app/src/main/java/org/linphone/activities/assistant/fragments/GenericAccountLoginFragment.kt +++ b/app/src/main/java/org/linphone/activities/assistant/fragments/GenericAccountLoginFragment.kt @@ -54,52 +54,50 @@ class GenericAccountLoginFragment : GenericFragment - (requireActivity() as AssistantActivity).showSnackBar(message) - } + viewLifecycleOwner + ) { + it.consume { message -> + (requireActivity() as AssistantActivity).showSnackBar(message) } - ) + } } } diff --git a/app/src/main/java/org/linphone/activities/assistant/fragments/GenericAccountWarningFragment.kt b/app/src/main/java/org/linphone/activities/assistant/fragments/GenericAccountWarningFragment.kt new file mode 100644 index 000000000..8c41c0653 --- /dev/null +++ b/app/src/main/java/org/linphone/activities/assistant/fragments/GenericAccountWarningFragment.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2010-2022 Belledonne Communications SARL. + * + * This file is part of linphone-android + * (see https://www.linphone.org). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.linphone.activities.assistant.fragments + +import android.os.Bundle +import android.view.View +import org.linphone.R +import org.linphone.activities.GenericFragment +import org.linphone.activities.navigateToGenericLogin +import org.linphone.databinding.AssistantGenericAccountWarningFragmentBinding + +class GenericAccountWarningFragment : GenericFragment() { + override fun getLayoutId(): Int = R.layout.assistant_generic_account_warning_fragment + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.lifecycleOwner = viewLifecycleOwner + + binding.setUnderstoodClickListener { + navigateToGenericLogin() + } + } +} diff --git a/app/src/main/java/org/linphone/activities/assistant/fragments/PhoneAccountCreationFragment.kt b/app/src/main/java/org/linphone/activities/assistant/fragments/PhoneAccountCreationFragment.kt index f396bd059..cce9f7a9c 100644 --- a/app/src/main/java/org/linphone/activities/assistant/fragments/PhoneAccountCreationFragment.kt +++ b/app/src/main/java/org/linphone/activities/assistant/fragments/PhoneAccountCreationFragment.kt @@ -29,8 +29,10 @@ import org.linphone.activities.assistant.viewmodels.PhoneAccountCreationViewMode import org.linphone.activities.assistant.viewmodels.SharedAssistantViewModel import org.linphone.activities.navigateToPhoneAccountValidation import org.linphone.databinding.AssistantPhoneAccountCreationFragmentBinding +import org.linphone.mediastream.Version -class PhoneAccountCreationFragment : AbstractPhoneFragment() { +class PhoneAccountCreationFragment : + AbstractPhoneFragment() { private lateinit var sharedViewModel: SharedAssistantViewModel override lateinit var viewModel: PhoneAccountCreationViewModel @@ -45,7 +47,10 @@ class PhoneAccountCreationFragment : AbstractPhoneFragment - (requireActivity() as AssistantActivity).showSnackBar(message) - } + viewLifecycleOwner + ) { + it.consume { message -> + (requireActivity() as AssistantActivity).showSnackBar(message) } - ) + } - checkPermission() + if (Version.sdkAboveOrEqual(Version.API23_MARSHMALLOW_60)) { + checkPermissions() + } } } diff --git a/app/src/main/java/org/linphone/activities/assistant/fragments/PhoneAccountLinkingFragment.kt b/app/src/main/java/org/linphone/activities/assistant/fragments/PhoneAccountLinkingFragment.kt index 786a540fc..1f7fa1f26 100644 --- a/app/src/main/java/org/linphone/activities/assistant/fragments/PhoneAccountLinkingFragment.kt +++ b/app/src/main/java/org/linphone/activities/assistant/fragments/PhoneAccountLinkingFragment.kt @@ -30,6 +30,7 @@ import org.linphone.activities.navigateToEchoCancellerCalibration import org.linphone.activities.navigateToPhoneAccountValidation import org.linphone.core.tools.Log import org.linphone.databinding.AssistantPhoneAccountLinkingFragmentBinding +import org.linphone.mediastream.Version class PhoneAccountLinkingFragment : AbstractPhoneFragment() { private lateinit var sharedViewModel: SharedAssistantViewModel @@ -72,39 +73,38 @@ class PhoneAccountLinkingFragment : AbstractPhoneFragment - (requireActivity() as AssistantActivity).showSnackBar(message) - } + viewLifecycleOwner + ) { + it.consume { message -> + (requireActivity() as AssistantActivity).showSnackBar(message) } - ) + } - checkPermission() + if (Version.sdkAboveOrEqual(Version.API23_MARSHMALLOW_60)) { + checkPermissions() + } } } diff --git a/app/src/main/java/org/linphone/activities/assistant/fragments/PhoneAccountValidationFragment.kt b/app/src/main/java/org/linphone/activities/assistant/fragments/PhoneAccountValidationFragment.kt index 534f38234..4bac03647 100644 --- a/app/src/main/java/org/linphone/activities/assistant/fragments/PhoneAccountValidationFragment.kt +++ b/app/src/main/java/org/linphone/activities/assistant/fragments/PhoneAccountValidationFragment.kt @@ -59,37 +59,38 @@ class PhoneAccountValidationFragment : GenericFragment { - coreContext.contactsManager.updateLocalContacts() + viewLifecycleOwner + ) { + it.consume { + when { + viewModel.isLogin.value == true || viewModel.isCreation.value == true -> { + coreContext.contactsManager.updateLocalContacts() - if (coreContext.core.isEchoCancellerCalibrationRequired) { - navigateToEchoCancellerCalibration() - } else { - requireActivity().finish() - } - } - viewModel.isLinking.value == true -> { - val args = Bundle() - args.putString("Identity", "sip:${viewModel.accountCreator.username}@${viewModel.accountCreator.domain}") - navigateToAccountSettings(args) + if (coreContext.core.isEchoCancellerCalibrationRequired) { + navigateToEchoCancellerCalibration() + } else { + requireActivity().finish() } } + viewModel.isLinking.value == true -> { + val args = Bundle() + args.putString( + "Identity", + "sip:${viewModel.accountCreator.username}@${viewModel.accountCreator.domain}" + ) + navigateToAccountSettings(args) + } } } - ) + } viewModel.onErrorEvent.observe( - viewLifecycleOwner, - { - it.consume { message -> - (requireActivity() as AssistantActivity).showSnackBar(message) - } + viewLifecycleOwner + ) { + it.consume { message -> + (requireActivity() as AssistantActivity).showSnackBar(message) } - ) + } val clipboard = requireContext().getSystemService(CLIPBOARD_SERVICE) as ClipboardManager clipboard.addPrimaryClipChangedListener { diff --git a/app/src/main/java/org/linphone/activities/assistant/fragments/QrCodeFragment.kt b/app/src/main/java/org/linphone/activities/assistant/fragments/QrCodeFragment.kt index 2e110863e..45daa8761 100644 --- a/app/src/main/java/org/linphone/activities/assistant/fragments/QrCodeFragment.kt +++ b/app/src/main/java/org/linphone/activities/assistant/fragments/QrCodeFragment.kt @@ -34,6 +34,10 @@ import org.linphone.databinding.AssistantQrCodeFragmentBinding import org.linphone.utils.PermissionHelper class QrCodeFragment : GenericFragment() { + companion object { + const val CAMERA_PERMISSION_REQUEST_CODE = 0 + } + private lateinit var sharedViewModel: SharedAssistantViewModel private lateinit var viewModel: QrCodeViewModel @@ -52,19 +56,18 @@ class QrCodeFragment : GenericFragment() { binding.viewModel = viewModel viewModel.qrCodeFoundEvent.observe( - viewLifecycleOwner, - { - it.consume { url -> - sharedViewModel.remoteProvisioningUrl.value = url - findNavController().navigateUp() - } + viewLifecycleOwner + ) { + it.consume { url -> + sharedViewModel.remoteProvisioningUrl.value = url + findNavController().navigateUp() } - ) + } viewModel.setBackCamera() if (!PermissionHelper.required(requireContext()).hasCameraPermission()) { Log.i("[QR Code] Asking for CAMERA permission") - requestPermissions(arrayOf(android.Manifest.permission.CAMERA), 0) + requestPermissions(arrayOf(android.Manifest.permission.CAMERA), CAMERA_PERMISSION_REQUEST_CODE) } } @@ -72,14 +75,14 @@ class QrCodeFragment : GenericFragment() { super.onResume() coreContext.core.nativePreviewWindowId = binding.qrCodeCaptureTexture - coreContext.core.enableQrcodeVideoPreview(true) - coreContext.core.enableVideoPreview(true) + coreContext.core.isQrcodeVideoPreviewEnabled = true + coreContext.core.isVideoPreviewEnabled = true } override fun onPause() { coreContext.core.nativePreviewWindowId = null - coreContext.core.enableQrcodeVideoPreview(false) - coreContext.core.enableVideoPreview(false) + coreContext.core.isQrcodeVideoPreviewEnabled = false + coreContext.core.isVideoPreviewEnabled = false super.onPause() } @@ -89,14 +92,17 @@ class QrCodeFragment : GenericFragment() { permissions: Array, grantResults: IntArray ) { - val granted = grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED - if (granted) { - Log.i("[QR Code] CAMERA permission granted") - coreContext.core.reloadVideoDevices() - viewModel.setBackCamera() - } else { - Log.w("[QR Code] CAMERA permission denied") - findNavController().navigateUp() + if (requestCode == CAMERA_PERMISSION_REQUEST_CODE) { + val granted = + grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED + if (granted) { + Log.i("[QR Code] CAMERA permission granted") + coreContext.core.reloadVideoDevices() + viewModel.setBackCamera() + } else { + Log.w("[QR Code] CAMERA permission denied") + findNavController().navigateUp() + } } } } diff --git a/app/src/main/java/org/linphone/activities/assistant/fragments/RemoteProvisioningFragment.kt b/app/src/main/java/org/linphone/activities/assistant/fragments/RemoteProvisioningFragment.kt index 3dad2ddc6..d80738b57 100644 --- a/app/src/main/java/org/linphone/activities/assistant/fragments/RemoteProvisioningFragment.kt +++ b/app/src/main/java/org/linphone/activities/assistant/fragments/RemoteProvisioningFragment.kt @@ -55,22 +55,21 @@ class RemoteProvisioningFragment : GenericFragment - if (success) { - if (coreContext.core.isEchoCancellerCalibrationRequired) { - navigateToEchoCancellerCalibration() - } else { - requireActivity().finish() - } + viewLifecycleOwner + ) { + it.consume { success -> + if (success) { + if (coreContext.core.isEchoCancellerCalibrationRequired) { + navigateToEchoCancellerCalibration() } else { - val activity = requireActivity() as AssistantActivity - activity.showSnackBar(R.string.assistant_remote_provisioning_failure) + requireActivity().finish() } + } else { + val activity = requireActivity() as AssistantActivity + activity.showSnackBar(R.string.assistant_remote_provisioning_failure) } } - ) + } viewModel.urlToFetch.value = sharedViewModel.remoteProvisioningUrl.value ?: coreContext.core.provisioningUri } diff --git a/app/src/main/java/org/linphone/activities/assistant/fragments/WelcomeFragment.kt b/app/src/main/java/org/linphone/activities/assistant/fragments/WelcomeFragment.kt index 8afe92371..012f63682 100644 --- a/app/src/main/java/org/linphone/activities/assistant/fragments/WelcomeFragment.kt +++ b/app/src/main/java/org/linphone/activities/assistant/fragments/WelcomeFragment.kt @@ -35,7 +35,6 @@ import org.linphone.activities.* import org.linphone.activities.assistant.viewmodels.WelcomeViewModel import org.linphone.activities.navigateToAccountLogin import org.linphone.activities.navigateToEmailAccountCreation -import org.linphone.activities.navigateToGenericLogin import org.linphone.activities.navigateToRemoteProvisioning import org.linphone.databinding.AssistantWelcomeFragmentBinding @@ -65,7 +64,7 @@ class WelcomeFragment : GenericFragment() { } binding.setGenericAccountLoginClickListener { - navigateToGenericLogin() + navigateToGenericLoginWarning() } binding.setRemoteProvisioningClickListener { @@ -73,11 +72,10 @@ class WelcomeFragment : GenericFragment() { } viewModel.termsAndPrivacyAccepted.observe( - viewLifecycleOwner, - { - if (it) corePreferences.readAndAgreeTermsAndPrivacy = true - } - ) + viewLifecycleOwner + ) { + if (it) corePreferences.readAndAgreeTermsAndPrivacy = true + } setUpTermsAndPrivacyLinks() } diff --git a/app/src/main/java/org/linphone/activities/assistant/viewmodels/AbstractPhoneViewModel.kt b/app/src/main/java/org/linphone/activities/assistant/viewmodels/AbstractPhoneViewModel.kt index 3daca0267..bfc4c0e10 100644 --- a/app/src/main/java/org/linphone/activities/assistant/viewmodels/AbstractPhoneViewModel.kt +++ b/app/src/main/java/org/linphone/activities/assistant/viewmodels/AbstractPhoneViewModel.kt @@ -56,14 +56,19 @@ abstract class AbstractPhoneViewModel(val accountCreator: AccountCreator) : } fun updateFromPhoneNumberAndOrDialPlan(number: String?, dialPlan: DialPlan?) { + val internationalPrefix = "+${dialPlan?.countryCallingCode}" if (dialPlan != null) { Log.i("[Assistant] Found prefix from dial plan: ${dialPlan.countryCallingCode}") - prefix.value = "+${dialPlan.countryCallingCode}" + prefix.value = internationalPrefix } if (number != null) { Log.i("[Assistant] Found phone number: $number") - phoneNumber.value = number!! + phoneNumber.value = if (number.startsWith(internationalPrefix)) { + number.substring(internationalPrefix.length) + } else { + number + } } } diff --git a/app/src/main/java/org/linphone/activities/assistant/viewmodels/AccountLoginViewModel.kt b/app/src/main/java/org/linphone/activities/assistant/viewmodels/AccountLoginViewModel.kt index 01d80a054..3067024b3 100644 --- a/app/src/main/java/org/linphone/activities/assistant/viewmodels/AccountLoginViewModel.kt +++ b/app/src/main/java/org/linphone/activities/assistant/viewmodels/AccountLoginViewModel.kt @@ -25,6 +25,7 @@ import org.linphone.R import org.linphone.core.* import org.linphone.core.tools.Log import org.linphone.utils.Event +import org.linphone.utils.PhoneNumberUtils class AccountLoginViewModelFactory(private val accountCreator: AccountCreator) : ViewModelProvider.NewInstanceFactory() { @@ -220,6 +221,18 @@ class AccountLoginViewModel(accountCreator: AccountCreator) : AbstractPhoneViewM proxyConfig.isPushNotificationAllowed = true + if (proxyConfig.dialPrefix.isNullOrEmpty()) { + val dialPlan = PhoneNumberUtils.getDialPlanForCurrentCountry(coreContext.context) + if (dialPlan != null) { + Log.i("[Assistant] [Account Login] Found dial plan country ${dialPlan.country} with international prefix ${dialPlan.countryCallingCode}") + proxyConfig.edit() + proxyConfig.dialPrefix = dialPlan.countryCallingCode + proxyConfig.done() + } else { + Log.w("[Assistant] [Account Login] Failed to find dial plan") + } + } + Log.i("[Assistant] [Account Login] Proxy config created") return true } diff --git a/app/src/main/java/org/linphone/activities/assistant/viewmodels/EmailAccountValidationViewModel.kt b/app/src/main/java/org/linphone/activities/assistant/viewmodels/EmailAccountValidationViewModel.kt index 6032efaeb..1f67ba43d 100644 --- a/app/src/main/java/org/linphone/activities/assistant/viewmodels/EmailAccountValidationViewModel.kt +++ b/app/src/main/java/org/linphone/activities/assistant/viewmodels/EmailAccountValidationViewModel.kt @@ -22,11 +22,13 @@ package org.linphone.activities.assistant.viewmodels import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import org.linphone.LinphoneApplication import org.linphone.core.AccountCreator import org.linphone.core.AccountCreatorListenerStub import org.linphone.core.ProxyConfig import org.linphone.core.tools.Log import org.linphone.utils.Event +import org.linphone.utils.PhoneNumberUtils class EmailAccountValidationViewModelFactory(private val accountCreator: AccountCreator) : ViewModelProvider.NewInstanceFactory() { @@ -106,6 +108,18 @@ class EmailAccountValidationViewModel(val accountCreator: AccountCreator) : View proxyConfig.isPushNotificationAllowed = true + if (proxyConfig.dialPrefix.isNullOrEmpty()) { + val dialPlan = PhoneNumberUtils.getDialPlanForCurrentCountry(LinphoneApplication.coreContext.context) + if (dialPlan != null) { + Log.i("[Assistant] [Account Validation] Found dial plan country ${dialPlan.country} with international prefix ${dialPlan.countryCallingCode}") + proxyConfig.edit() + proxyConfig.dialPrefix = dialPlan.countryCallingCode + proxyConfig.done() + } else { + Log.w("[Assistant] [Account Validation] Failed to find dial plan") + } + } + Log.i("[Assistant] [Account Validation] Proxy config created") return true } diff --git a/app/src/main/java/org/linphone/activities/assistant/viewmodels/GenericLoginViewModel.kt b/app/src/main/java/org/linphone/activities/assistant/viewmodels/GenericLoginViewModel.kt index 89fe487f7..6ecdfc9d6 100644 --- a/app/src/main/java/org/linphone/activities/assistant/viewmodels/GenericLoginViewModel.kt +++ b/app/src/main/java/org/linphone/activities/assistant/viewmodels/GenericLoginViewModel.kt @@ -136,6 +136,7 @@ class GenericLoginViewModel(private val accountCreator: AccountCreator) : ViewMo Log.e("[Assistant] [Generic Login] Account creator couldn't create proxy config") coreContext.core.removeListener(coreListener) onErrorEvent.value = Event("Error: Failed to create account object") + waitForServerAnswer.value = false return } diff --git a/app/src/main/java/org/linphone/activities/call/CallActivity.kt b/app/src/main/java/org/linphone/activities/call/CallActivity.kt index 09ea2bb8a..2ef7eb13e 100644 --- a/app/src/main/java/org/linphone/activities/call/CallActivity.kt +++ b/app/src/main/java/org/linphone/activities/call/CallActivity.kt @@ -61,40 +61,36 @@ class CallActivity : ProximitySensorActivity() { sharedViewModel = ViewModelProvider(this)[SharedCallViewModel::class.java] sharedViewModel.toggleDrawerEvent.observe( - this, - { - it.consume { - if (binding.statsMenu.isDrawerOpen(Gravity.LEFT)) { - binding.statsMenu.closeDrawer(binding.sideMenuContent, true) - } else { - binding.statsMenu.openDrawer(binding.sideMenuContent, true) - } + this + ) { + it.consume { + if (binding.statsMenu.isDrawerOpen(Gravity.LEFT)) { + binding.statsMenu.closeDrawer(binding.sideMenuContent, true) + } else { + binding.statsMenu.openDrawer(binding.sideMenuContent, true) } } - ) + } sharedViewModel.resetHiddenInterfaceTimerInVideoCallEvent.observe( - this, - { - it.consume { - viewModel.showMomentarily() - } + this + ) { + it.consume { + viewModel.showMomentarily() } - ) + } viewModel.proximitySensorEnabled.observe( - this, - { - enableProximitySensor(it) - } - ) + this + ) { + enableProximitySensor(it) + } viewModel.videoEnabled.observe( - this, - { - updateConstraintSetDependingOnFoldingState() - } - ) + this + ) { + updateConstraintSetDependingOnFoldingState() + } } override fun onLayoutChanges(foldingFeature: FoldingFeature?) { diff --git a/app/src/main/java/org/linphone/activities/call/IncomingCallActivity.kt b/app/src/main/java/org/linphone/activities/call/IncomingCallActivity.kt index 76fb75e3d..c150de7a0 100644 --- a/app/src/main/java/org/linphone/activities/call/IncomingCallActivity.kt +++ b/app/src/main/java/org/linphone/activities/call/IncomingCallActivity.kt @@ -79,24 +79,22 @@ class IncomingCallActivity : GenericActivity() { binding.viewModel = viewModel viewModel.callEndedEvent.observe( - this, - { - it.consume { - Log.i("[Incoming Call Activity] Call ended, finish activity") - finish() - } + this + ) { + it.consume { + Log.i("[Incoming Call Activity] Call ended, finish activity") + finish() } - ) + } viewModel.earlyMediaVideoEnabled.observe( - this, - { - if (it) { - Log.i("[Incoming Call Activity] Early media video being received, set native window id") - coreContext.core.nativeVideoWindowId = binding.remoteVideoSurface - } + this + ) { + if (it) { + Log.i("[Incoming Call Activity] Early media video being received, set native window id") + coreContext.core.nativeVideoWindowId = binding.remoteVideoSurface } - ) + } val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager val keyguardLocked = keyguardManager.isKeyguardLocked @@ -139,7 +137,7 @@ class IncomingCallActivity : GenericActivity() { permissionsRequiredList.add(Manifest.permission.RECORD_AUDIO) } - if (viewModel.call.currentParams.videoEnabled() && !PermissionHelper.get().hasCameraPermission()) { + if (viewModel.call.currentParams.isVideoEnabled && !PermissionHelper.get().hasCameraPermission()) { Log.i("[Incoming Call Activity] Asking for CAMERA permission") permissionsRequiredList.add(Manifest.permission.CAMERA) } diff --git a/app/src/main/java/org/linphone/activities/call/OutgoingCallActivity.kt b/app/src/main/java/org/linphone/activities/call/OutgoingCallActivity.kt index 5c1c9fe82..5dc2a1676 100644 --- a/app/src/main/java/org/linphone/activities/call/OutgoingCallActivity.kt +++ b/app/src/main/java/org/linphone/activities/call/OutgoingCallActivity.kt @@ -80,55 +80,58 @@ class OutgoingCallActivity : ProximitySensorActivity() { binding.controlsViewModel = controlsViewModel viewModel.callEndedEvent.observe( - this, - { - it.consume { - Log.i("[Outgoing Call Activity] Call ended, finish activity") - finish() - } + this + ) { + it.consume { + Log.i("[Outgoing Call Activity] Call ended, finish activity") + finish() } - ) + } viewModel.callConnectedEvent.observe( - this, - { - it.consume { - Log.i("[Outgoing Call Activity] Call connected, finish activity") - finish() - } + this + ) { + it.consume { + Log.i("[Outgoing Call Activity] Call connected, finish activity") + finish() } - ) + } controlsViewModel.isSpeakerSelected.observe( - this, - { - enableProximitySensor(!it) - } - ) + this + ) { + enableProximitySensor(!it) + } - controlsViewModel.askPermissionEvent.observe( - this, - { - it.consume { permission -> - requestPermissions(arrayOf(permission), 0) - } + controlsViewModel.askAudioRecordPermissionEvent.observe( + this + ) { + it.consume { permission -> + requestPermissions(arrayOf(permission), 0) } - ) + } + + controlsViewModel.askCameraPermissionEvent.observe( + this + ) { + it.consume { permission -> + requestPermissions(arrayOf(permission), 0) + } + } controlsViewModel.toggleNumpadEvent.observe( - this, - { - it.consume { open -> - if (this::numpadAnimator.isInitialized) { - if (open) { - numpadAnimator.start() - } else { - numpadAnimator.reverse() - } + this + ) { + it.consume { open -> + if (this::numpadAnimator.isInitialized) { + if (open) { + numpadAnimator.start() + } else { + numpadAnimator.reverse() } } } - ) + } if (Version.sdkAboveOrEqual(Version.API23_MARSHMALLOW_60)) { checkPermissions() @@ -170,7 +173,7 @@ class OutgoingCallActivity : ProximitySensorActivity() { Log.i("[Outgoing Call Activity] Asking for RECORD_AUDIO permission") permissionsRequiredList.add(Manifest.permission.RECORD_AUDIO) } - if (viewModel.call.currentParams.videoEnabled() && !PermissionHelper.get().hasCameraPermission()) { + if (viewModel.call.currentParams.isVideoEnabled && !PermissionHelper.get().hasCameraPermission()) { Log.i("[Outgoing Call Activity] Asking for CAMERA permission") permissionsRequiredList.add(Manifest.permission.CAMERA) } @@ -207,7 +210,8 @@ class OutgoingCallActivity : ProximitySensorActivity() { for (call in coreContext.core.calls) { if (call.state == Call.State.OutgoingInit || call.state == Call.State.OutgoingProgress || - call.state == Call.State.OutgoingRinging + call.state == Call.State.OutgoingRinging || + call.state == Call.State.OutgoingEarlyMedia ) { return call } diff --git a/app/src/main/java/org/linphone/activities/call/data/CallStatisticsData.kt b/app/src/main/java/org/linphone/activities/call/data/CallStatisticsData.kt index b99754158..bbfe7a887 100644 --- a/app/src/main/java/org/linphone/activities/call/data/CallStatisticsData.kt +++ b/app/src/main/java/org/linphone/activities/call/data/CallStatisticsData.kt @@ -36,7 +36,7 @@ class CallStatisticsData(val call: Call) : GenericContactData(call.remoteAddress private val listener = object : CoreListenerStub() { override fun onCallStatsUpdated(core: Core, call: Call, stats: CallStats) { if (call == this@CallStatisticsData.call) { - isVideoEnabled.value = call.currentParams.videoEnabled() + isVideoEnabled.value = call.currentParams.isVideoEnabled updateCallStats(stats) } } @@ -50,7 +50,7 @@ class CallStatisticsData(val call: Call) : GenericContactData(call.remoteAddress initCallStats() - val videoEnabled = call.currentParams.videoEnabled() + val videoEnabled = call.currentParams.isVideoEnabled isVideoEnabled.value = videoEnabled isExpanded.value = coreContext.core.currentCall == call diff --git a/app/src/main/java/org/linphone/activities/call/fragments/ControlsFragment.kt b/app/src/main/java/org/linphone/activities/call/fragments/ControlsFragment.kt index 859b47611..3765a4183 100644 --- a/app/src/main/java/org/linphone/activities/call/fragments/ControlsFragment.kt +++ b/app/src/main/java/org/linphone/activities/call/fragments/ControlsFragment.kt @@ -88,130 +88,129 @@ class ControlsFragment : GenericFragment() { binding.conferenceViewModel = conferenceViewModel callsViewModel.currentCallViewModel.observe( - viewLifecycleOwner, - { - if (it != null) { - binding.activeCallTimer.base = - SystemClock.elapsedRealtime() - (1000 * it.call.duration) // Linphone timestamps are in seconds - binding.activeCallTimer.start() - } + viewLifecycleOwner + ) { + if (it != null) { + binding.activeCallTimer.base = + SystemClock.elapsedRealtime() - (1000 * it.call.duration) // Linphone timestamps are in seconds + binding.activeCallTimer.start() } - ) + } callsViewModel.noMoreCallEvent.observe( - viewLifecycleOwner, - { - it.consume { - requireActivity().finish() - } + viewLifecycleOwner + ) { + it.consume { + requireActivity().finish() } - ) + } callsViewModel.askWriteExternalStoragePermissionEvent.observe( - viewLifecycleOwner, - { - it.consume { - if (!PermissionHelper.get().hasWriteExternalStoragePermission()) { - Log.i("[Controls Fragment] Asking for WRITE_EXTERNAL_STORAGE permission") - requestPermissions(arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), 1) - } + viewLifecycleOwner + ) { + it.consume { + if (!PermissionHelper.get().hasWriteExternalStoragePermission()) { + Log.i("[Controls Fragment] Asking for WRITE_EXTERNAL_STORAGE permission") + requestPermissions(arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), 2) } } - ) + } callsViewModel.callUpdateEvent.observe( - viewLifecycleOwner, - { - it.consume { call -> - if (call.state == Call.State.StreamsRunning) { - dialog?.dismiss() - } else if (call.state == Call.State.UpdatedByRemote) { - if (coreContext.core.videoCaptureEnabled() || coreContext.core.videoDisplayEnabled()) { - if (call.currentParams.videoEnabled() != call.remoteParams?.videoEnabled()) { - showCallVideoUpdateDialog(call) - } - } else { - Log.w("[Controls Fragment] Video display & capture are disabled, don't show video dialog") + viewLifecycleOwner + ) { + it.consume { call -> + if (call.state == Call.State.StreamsRunning) { + dialog?.dismiss() + } else if (call.state == Call.State.UpdatedByRemote) { + if (coreContext.core.isVideoCaptureEnabled || coreContext.core.isVideoDisplayEnabled) { + if (call.currentParams.isVideoEnabled != call.remoteParams?.isVideoEnabled) { + showCallVideoUpdateDialog(call) } + } else { + Log.w("[Controls Fragment] Video display & capture are disabled, don't show video dialog") } } } - ) + } controlsViewModel.chatClickedEvent.observe( - viewLifecycleOwner, - { - it.consume { - val intent = Intent() - intent.setClass(requireContext(), MainActivity::class.java) - intent.putExtra("Chat", true) - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - startActivity(intent) - } + viewLifecycleOwner + ) { + it.consume { + val intent = Intent() + intent.setClass(requireContext(), MainActivity::class.java) + intent.putExtra("Chat", true) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + startActivity(intent) } - ) + } controlsViewModel.addCallClickedEvent.observe( - viewLifecycleOwner, - { - it.consume { - val intent = Intent() - intent.setClass(requireContext(), MainActivity::class.java) - intent.putExtra("Dialer", true) - intent.putExtra("Transfer", false) - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - startActivity(intent) - } + viewLifecycleOwner + ) { + it.consume { + val intent = Intent() + intent.setClass(requireContext(), MainActivity::class.java) + intent.putExtra("Dialer", true) + intent.putExtra("Transfer", false) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + startActivity(intent) } - ) + } controlsViewModel.transferCallClickedEvent.observe( - viewLifecycleOwner, - { - it.consume { - val intent = Intent() - intent.setClass(requireContext(), MainActivity::class.java) - intent.putExtra("Dialer", true) - intent.putExtra("Transfer", true) - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - startActivity(intent) - } + viewLifecycleOwner + ) { + it.consume { + val intent = Intent() + intent.setClass(requireContext(), MainActivity::class.java) + intent.putExtra("Dialer", true) + intent.putExtra("Transfer", true) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + startActivity(intent) } - ) + } - controlsViewModel.askPermissionEvent.observe( - viewLifecycleOwner, - { - it.consume { permission -> - Log.i("[Controls Fragment] Asking for $permission permission") - requestPermissions(arrayOf(permission), 0) - } + controlsViewModel.askAudioRecordPermissionEvent.observe( + viewLifecycleOwner + ) { + it.consume { permission -> + Log.i("[Controls Fragment] Asking for $permission permission") + requestPermissions(arrayOf(permission), 0) } - ) + } + + controlsViewModel.askCameraPermissionEvent.observe( + viewLifecycleOwner + ) { + it.consume { permission -> + Log.i("[Controls Fragment] Asking for $permission permission") + requestPermissions(arrayOf(permission), 1) + } + } controlsViewModel.toggleNumpadEvent.observe( - viewLifecycleOwner, - { - it.consume { open -> - if (this::numpadAnimator.isInitialized) { - if (open) { - numpadAnimator.start() - } else { - numpadAnimator.reverse() - } + viewLifecycleOwner + ) { + it.consume { open -> + if (this::numpadAnimator.isInitialized) { + if (open) { + numpadAnimator.start() + } else { + numpadAnimator.reverse() } } } - ) + } controlsViewModel.somethingClickedEvent.observe( - viewLifecycleOwner, - { - it.consume { - sharedViewModel.resetHiddenInterfaceTimerInVideoCallEvent.value = Event(true) - } + viewLifecycleOwner + ) { + it.consume { + sharedViewModel.resetHiddenInterfaceTimerInVideoCallEvent.value = Event(true) } - ) + } if (Version.sdkAboveOrEqual(Version.API23_MARSHMALLOW_60)) { checkPermissions() @@ -251,7 +250,13 @@ class ControlsFragment : GenericFragment() { } } } - } else if (requestCode == 1 && grantResults.isNotEmpty() && grantResults[0] == PERMISSION_GRANTED) { + } else if (requestCode == 1) { + if (grantResults.isNotEmpty() && grantResults[0] == PERMISSION_GRANTED) { + Log.i("[Controls Fragment] CAMERA permission has been granted") + coreContext.core.reloadVideoDevices() + controlsViewModel.toggleVideo() + } + } else if (requestCode == 2 && grantResults.isNotEmpty() && grantResults[0] == PERMISSION_GRANTED) { callsViewModel.takeScreenshot() } super.onRequestPermissionsResult(requestCode, permissions, grantResults) diff --git a/app/src/main/java/org/linphone/activities/call/fragments/StatusFragment.kt b/app/src/main/java/org/linphone/activities/call/fragments/StatusFragment.kt index 0213bb9cf..7837b7482 100644 --- a/app/src/main/java/org/linphone/activities/call/fragments/StatusFragment.kt +++ b/app/src/main/java/org/linphone/activities/call/fragments/StatusFragment.kt @@ -64,15 +64,14 @@ class StatusFragment : GenericFragment() { } viewModel.showZrtpDialogEvent.observe( - viewLifecycleOwner, - { - it.consume { call -> - if (call.state == Call.State.Connected || call.state == Call.State.StreamsRunning) { - showZrtpDialog(call) - } + viewLifecycleOwner + ) { + it.consume { call -> + if (call.state == Call.State.Connected || call.state == Call.State.StreamsRunning) { + showZrtpDialog(call) } } - ) + } } override fun onCreate(savedInstanceState: Bundle?) { @@ -114,8 +113,8 @@ class StatusFragment : GenericFragment() { val viewModel = DialogViewModel(getString(R.string.zrtp_dialog_message), getString(R.string.zrtp_dialog_title)) viewModel.showZrtp = true - viewModel.zrtpReadSas = toRead.toUpperCase(Locale.getDefault()) - viewModel.zrtpListenSas = toListen.toUpperCase(Locale.getDefault()) + viewModel.zrtpReadSas = toRead.uppercase(Locale.getDefault()) + viewModel.zrtpListenSas = toListen.uppercase(Locale.getDefault()) viewModel.showIcon = true viewModel.iconResource = R.drawable.security_2_indicator diff --git a/app/src/main/java/org/linphone/activities/call/viewmodels/CallViewModel.kt b/app/src/main/java/org/linphone/activities/call/viewmodels/CallViewModel.kt index d779b5fcc..59216ead9 100644 --- a/app/src/main/java/org/linphone/activities/call/viewmodels/CallViewModel.kt +++ b/app/src/main/java/org/linphone/activities/call/viewmodels/CallViewModel.kt @@ -114,6 +114,7 @@ open class CallViewModel(val call: Call) : GenericContactViewModel(call.remoteAd call.addListener(listener) isPaused.value = call.state == Call.State.Paused + isOutgoingEarlyMedia.value = call.state == Call.State.OutgoingEarlyMedia } override fun onCleared() { @@ -138,7 +139,7 @@ open class CallViewModel(val call: Call) : GenericContactViewModel(call.remoteAd } fun takeScreenshot() { - if (call.currentParams.videoEnabled()) { + if (call.currentParams.isVideoEnabled) { val fileName = System.currentTimeMillis().toString() + ".jpeg" call.takeVideoSnapshot(FileUtils.getFileStoragePath(fileName).absolutePath) } diff --git a/app/src/main/java/org/linphone/activities/call/viewmodels/CallsViewModel.kt b/app/src/main/java/org/linphone/activities/call/viewmodels/CallsViewModel.kt index 2372b756d..e6165e651 100644 --- a/app/src/main/java/org/linphone/activities/call/viewmodels/CallsViewModel.kt +++ b/app/src/main/java/org/linphone/activities/call/viewmodels/CallsViewModel.kt @@ -75,11 +75,11 @@ class CallsViewModel : ViewModel() { } else if (call.state == Call.State.UpdatedByRemote) { // If the correspondent asks to turn on video while audio call, // defer update until user has chosen whether to accept it or not - val remoteVideo = call.remoteParams?.videoEnabled() ?: false - val localVideo = call.currentParams.videoEnabled() + val remoteVideo = call.remoteParams?.isVideoEnabled ?: false + val localVideo = call.currentParams.isVideoEnabled val autoAccept = call.core.videoActivationPolicy.automaticallyAccept if (remoteVideo && !localVideo && !autoAccept) { - if (coreContext.core.videoCaptureEnabled() || coreContext.core.videoDisplayEnabled()) { + if (coreContext.core.isVideoCaptureEnabled || coreContext.core.isVideoDisplayEnabled) { call.deferUpdate() callUpdateEvent.value = Event(call) } else { diff --git a/app/src/main/java/org/linphone/activities/call/viewmodels/ControlsViewModel.kt b/app/src/main/java/org/linphone/activities/call/viewmodels/ControlsViewModel.kt index f74bb0471..094cd64c2 100644 --- a/app/src/main/java/org/linphone/activities/call/viewmodels/ControlsViewModel.kt +++ b/app/src/main/java/org/linphone/activities/call/viewmodels/ControlsViewModel.kt @@ -87,7 +87,11 @@ class ControlsViewModel : ViewModel() { MutableLiveData>() } - val askPermissionEvent: MutableLiveData> by lazy { + val askAudioRecordPermissionEvent: MutableLiveData> by lazy { + MutableLiveData>() + } + + val askCameraPermissionEvent: MutableLiveData> by lazy { MutableLiveData>() } @@ -176,7 +180,7 @@ class ControlsViewModel : ViewModel() { } if (coreContext.isVideoCallOrConferenceActive() && !PermissionHelper.get().hasCameraPermission()) { - askPermissionEvent.value = Event(Manifest.permission.CAMERA) + askCameraPermissionEvent.value = Event(Manifest.permission.CAMERA) } updateUI() @@ -244,13 +248,13 @@ class ControlsViewModel : ViewModel() { fun toggleMuteMicrophone() { if (!PermissionHelper.get().hasRecordAudioPermission()) { - askPermissionEvent.value = Event(Manifest.permission.RECORD_AUDIO) + askAudioRecordPermissionEvent.value = Event(Manifest.permission.RECORD_AUDIO) return } somethingClickedEvent.value = Event(true) - val micEnabled = coreContext.core.micEnabled() - coreContext.core.enableMic(!micEnabled) + val micEnabled = coreContext.core.isMicEnabled + coreContext.core.isMicEnabled = !micEnabled updateMuteMicState() } @@ -279,7 +283,7 @@ class ControlsViewModel : ViewModel() { fun toggleVideo() { if (!PermissionHelper.get().hasCameraPermission()) { - askPermissionEvent.value = Event(Manifest.permission.CAMERA) + askCameraPermissionEvent.value = Event(Manifest.permission.CAMERA) return } @@ -300,7 +304,7 @@ class ControlsViewModel : ViewModel() { isVideoUpdateInProgress.value = true val params = core.createCallParams(currentCall) - params?.enableVideo(!currentCall.currentParams.videoEnabled()) + params?.isVideoEnabled = !currentCall.currentParams.isVideoEnabled currentCall.update(params) } } @@ -338,23 +342,27 @@ class ControlsViewModel : ViewModel() { val currentCall = core.currentCall val conference = core.conference - if (currentCall != null) { - if (currentCall.isRecording) { - currentCall.stopRecording() - } else { - currentCall.startRecording() + when { + currentCall != null -> { + if (currentCall.isRecording) { + currentCall.stopRecording() + } else { + currentCall.startRecording() + } + isRecording.value = currentCall.isRecording } - isRecording.value = currentCall.isRecording - } else if (conference != null) { - val path = LinphoneUtils.getRecordingFilePathForConference() - if (conference.isRecording) { - conference.stopRecording() - } else { - conference.startRecording(path) + conference != null -> { + val path = LinphoneUtils.getRecordingFilePathForConference() + if (conference.isRecording) { + conference.stopRecording() + } else { + conference.startRecording(path) + } + isRecording.value = conference.isRecording + } + else -> { + isRecording.value = false } - isRecording.value = conference.isRecording - } else { - isRecording.value = false } if (closeMenu) toggleOptionsMenu() @@ -378,7 +386,7 @@ class ControlsViewModel : ViewModel() { somethingClickedEvent.value = Event(true) val core = coreContext.core - val currentCallVideoEnabled = core.currentCall?.currentParams?.videoEnabled() ?: false + val currentCallVideoEnabled = core.currentCall?.currentParams?.isVideoEnabled ?: false val params = core.createConferenceParams() params.isVideoEnabled = currentCallVideoEnabled @@ -411,7 +419,7 @@ class ControlsViewModel : ViewModel() { } fun updateMuteMicState() { - isMicrophoneMuted.value = !PermissionHelper.get().hasRecordAudioPermission() || !coreContext.core.micEnabled() + isMicrophoneMuted.value = !PermissionHelper.get().hasRecordAudioPermission() || !coreContext.core.isMicEnabled isMuteMicrophoneEnabled.value = coreContext.core.currentCall != null || coreContext.core.conference?.isIn == true } @@ -457,7 +465,7 @@ class ControlsViewModel : ViewModel() { private fun updateVideoAvailable() { val core = coreContext.core val currentCall = core.currentCall - isVideoAvailable.value = (core.videoCaptureEnabled() || core.videoPreviewEnabled()) && + isVideoAvailable.value = (core.isVideoCaptureEnabled || core.isVideoPreviewEnabled) && ( (currentCall != null && !currentCall.mediaInProgress()) || core.conference?.isIn == true diff --git a/app/src/main/java/org/linphone/activities/call/viewmodels/IncomingCallViewModel.kt b/app/src/main/java/org/linphone/activities/call/viewmodels/IncomingCallViewModel.kt index 639a43a22..f1dc0bfb6 100644 --- a/app/src/main/java/org/linphone/activities/call/viewmodels/IncomingCallViewModel.kt +++ b/app/src/main/java/org/linphone/activities/call/viewmodels/IncomingCallViewModel.kt @@ -60,10 +60,10 @@ class IncomingCallViewModel(call: Call) : CallViewModel(call) { coreContext.core.addListener(listener) screenLocked.value = false - inviteWithVideo.value = call.remoteParams?.videoEnabled() == true && coreContext.core.videoActivationPolicy.automaticallyAccept + inviteWithVideo.value = call.remoteParams?.isVideoEnabled == true && coreContext.core.videoActivationPolicy.automaticallyAccept earlyMediaVideoEnabled.value = corePreferences.acceptEarlyMedia && call.state == Call.State.IncomingEarlyMedia && - call.currentParams.videoEnabled() + call.currentParams.isVideoEnabled } override fun onCleared() { diff --git a/app/src/main/java/org/linphone/activities/chat_bubble/ChatBubbleActivity.kt b/app/src/main/java/org/linphone/activities/chat_bubble/ChatBubbleActivity.kt index 0169412cc..08d3d8d1e 100644 --- a/app/src/main/java/org/linphone/activities/chat_bubble/ChatBubbleActivity.kt +++ b/app/src/main/java/org/linphone/activities/chat_bubble/ChatBubbleActivity.kt @@ -37,6 +37,8 @@ import org.linphone.activities.main.chat.adapters.ChatMessagesListAdapter import org.linphone.activities.main.chat.viewmodels.* import org.linphone.activities.main.viewmodels.ListTopBarViewModel import org.linphone.core.ChatRoom +import org.linphone.core.ChatRoomListenerStub +import org.linphone.core.EventLog import org.linphone.core.Factory import org.linphone.core.tools.Log import org.linphone.databinding.ChatBubbleActivityBinding @@ -58,6 +60,12 @@ class ChatBubbleActivity : GenericActivity() { } } + private val listener = object : ChatRoomListenerStub() { + override fun onChatMessageReceived(chatRoom: ChatRoom, eventLog: EventLog) { + chatRoom.markAsRead() + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -86,11 +94,6 @@ class ChatBubbleActivity : GenericActivity() { return } - // Workaround for the removed notification when a chat room is marked as read - coreContext.notificationsManager.dismissNotificationUponReadChatRoom = false - chatRoom.markAsRead() - coreContext.notificationsManager.dismissNotificationUponReadChatRoom = true - viewModel = ViewModelProvider( this, ChatRoomViewModelFactory(chatRoom) @@ -119,38 +122,40 @@ class ChatBubbleActivity : GenericActivity() { adapter.disableContextMenu() adapter.openContentEvent.observe( - this, - { - it.consume { content -> - if (content.isFileEncrypted) { - Toast.makeText(this, R.string.chat_bubble_cant_open_enrypted_file, Toast.LENGTH_LONG).show() - } else { - FileUtils.openFileInThirdPartyApp(this, content.filePath.orEmpty(), true) - } + this + ) { + it.consume { content -> + if (content.isFileEncrypted) { + Toast.makeText( + this, + R.string.chat_bubble_cant_open_enrypted_file, + Toast.LENGTH_LONG + ).show() + } else { + FileUtils.openFileInThirdPartyApp(this, content.filePath.orEmpty(), true) } } - ) + } val layoutManager = LinearLayoutManager(this) layoutManager.stackFromEnd = true binding.chatMessagesList.layoutManager = layoutManager listViewModel.events.observe( - this, - { events -> - adapter.submitList(events) - } - ) + this + ) { events -> + adapter.submitList(events) + } chatSendingViewModel.textToSend.observe( - this, - { - chatSendingViewModel.onTextToSendChanged(it) - } - ) + this + ) { + chatSendingViewModel.onTextToSendChanged(it) + } binding.setOpenAppClickListener { coreContext.notificationsManager.currentlyDisplayedChatRoomAddress = null + coreContext.notificationsManager.changeDismissNotificationUponReadForChatRoom(viewModel.chatRoom, false) val intent = Intent(this, MainActivity::class.java) intent.putExtra("RemoteSipUri", remoteSipUri) @@ -173,6 +178,12 @@ class ChatBubbleActivity : GenericActivity() { override fun onResume() { super.onResume() + viewModel.chatRoom.addListener(listener) + + // Workaround for the removed notification when a chat room is marked as read + coreContext.notificationsManager.changeDismissNotificationUponReadForChatRoom(viewModel.chatRoom, true) + viewModel.chatRoom.markAsRead() + val peerAddress = viewModel.chatRoom.peerAddress.asStringUriOnly() coreContext.notificationsManager.currentlyDisplayedChatRoomAddress = peerAddress coreContext.notificationsManager.resetChatNotificationCounterForSipUri(peerAddress) @@ -185,7 +196,10 @@ class ChatBubbleActivity : GenericActivity() { } override fun onPause() { + viewModel.chatRoom.removeListener(listener) + coreContext.notificationsManager.currentlyDisplayedChatRoomAddress = null + coreContext.notificationsManager.changeDismissNotificationUponReadForChatRoom(viewModel.chatRoom, false) super.onPause() } diff --git a/app/src/main/java/org/linphone/activities/launcher/LauncherActivity.kt b/app/src/main/java/org/linphone/activities/launcher/LauncherActivity.kt deleted file mode 100644 index d8a206adf..000000000 --- a/app/src/main/java/org/linphone/activities/launcher/LauncherActivity.kt +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright (c) 2010-2020 Belledonne Communications SARL. - * - * This file is part of linphone-android - * (see https://www.linphone.org). - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.linphone.activities.launcher - -import android.content.Intent -import android.os.Bundle -import org.linphone.LinphoneApplication.Companion.coreContext -import org.linphone.LinphoneApplication.Companion.corePreferences -import org.linphone.R -import org.linphone.activities.GenericActivity -import org.linphone.activities.main.MainActivity -import org.linphone.core.tools.Log - -class LauncherActivity : GenericActivity() { - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - setContentView(R.layout.launcher_activity) - } - - override fun onStart() { - super.onStart() - coreContext.handler.postDelayed({ onReady() }, 500) - } - - private fun onReady() { - Log.i("[Launcher] Core is ready") - - if (corePreferences.preventInterfaceFromShowingUp) { - Log.w("[Context] We were asked to not show the user interface") - finish() - return - } - - val intent = Intent() - intent.setClass(this, MainActivity::class.java) - - // Propagate current intent action, type and data - if (getIntent() != null) { - val extras = getIntent().extras - if (extras != null) intent.putExtras(extras) - } - intent.action = getIntent().action - intent.type = getIntent().type - intent.data = getIntent().data - - startActivity(intent) - if (corePreferences.enableAnimations) { - overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out) - } - } -} diff --git a/app/src/main/java/org/linphone/activities/main/MainActivity.kt b/app/src/main/java/org/linphone/activities/main/MainActivity.kt index bd2aabd1e..b995f5371 100644 --- a/app/src/main/java/org/linphone/activities/main/MainActivity.kt +++ b/app/src/main/java/org/linphone/activities/main/MainActivity.kt @@ -30,8 +30,10 @@ import android.view.Gravity import android.view.MotionEvent import android.view.View import android.view.inputmethod.InputMethodManager +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat +import androidx.core.view.doOnAttach import androidx.databinding.DataBindingUtil import androidx.fragment.app.FragmentContainerView import androidx.lifecycle.ViewModelProvider @@ -48,8 +50,7 @@ import kotlinx.coroutines.* import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.LinphoneApplication.Companion.corePreferences import org.linphone.R -import org.linphone.activities.GenericActivity -import org.linphone.activities.SnackBarActivity +import org.linphone.activities.* import org.linphone.activities.assistant.AssistantActivity import org.linphone.activities.main.viewmodels.CallOverlayViewModel import org.linphone.activities.main.viewmodels.SharedMainViewModel @@ -112,6 +113,8 @@ class MainActivity : GenericActivity(), SnackBarActivity, NavController.OnDestin override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + val splashScreen = installSplashScreen() + binding = DataBindingUtil.setContentView(this, R.layout.main_activity) binding.lifecycleOwner = this @@ -122,30 +125,27 @@ class MainActivity : GenericActivity(), SnackBarActivity, NavController.OnDestin binding.callOverlayViewModel = callOverlayViewModel sharedViewModel.toggleDrawerEvent.observe( - this, - { - it.consume { - if (binding.sideMenu.isDrawerOpen(Gravity.LEFT)) { - binding.sideMenu.closeDrawer(binding.sideMenuContent, true) - } else { - binding.sideMenu.openDrawer(binding.sideMenuContent, true) - } + this + ) { + it.consume { + if (binding.sideMenu.isDrawerOpen(Gravity.LEFT)) { + binding.sideMenu.closeDrawer(binding.sideMenuContent, true) + } else { + binding.sideMenu.openDrawer(binding.sideMenuContent, true) } } - ) + } coreContext.callErrorMessageResourceId.observe( - this, - { - it.consume { message -> - showSnackBar(message) - } + this + ) { + it.consume { message -> + showSnackBar(message) } - ) + } if (coreContext.core.accountList.isEmpty()) { if (corePreferences.firstStart) { - corePreferences.firstStart = false startActivity(Intent(this, AssistantActivity::class.java)) } } @@ -153,7 +153,14 @@ class MainActivity : GenericActivity(), SnackBarActivity, NavController.OnDestin tabsFragment = findViewById(R.id.tabs_fragment) statusFragment = findViewById(R.id.status_fragment) - initOverlay() + binding.root.doOnAttach { + Log.i("[Main Activity] Report UI has been fully drawn (TTFD)") + try { + reportFullyDrawn() + } catch (se: SecurityException) { + Log.e("[Main Activity] Security exception when doing reportFullyDrawn(): $se") + } + } } override fun onNewIntent(intent: Intent?) { @@ -176,6 +183,16 @@ class MainActivity : GenericActivity(), SnackBarActivity, NavController.OnDestin Snackbar.make(findViewById(R.id.coordinator), resourceId, Snackbar.LENGTH_LONG).show() } + override fun showSnackBar(resourceId: Int, action: Int, listener: () -> Unit) { + Snackbar + .make(findViewById(R.id.coordinator), resourceId, Snackbar.LENGTH_LONG) + .setAction(action) { + Log.i("[Snack Bar] Action listener triggered") + listener() + } + .show() + } + override fun showSnackBar(message: String) { Snackbar.make(findViewById(R.id.coordinator), message, Snackbar.LENGTH_LONG).show() } @@ -195,6 +212,8 @@ class MainActivity : GenericActivity(), SnackBarActivity, NavController.OnDestin updateTabsFragmentVisibility() } + initOverlay() + if (intent != null) handleIntentParams(intent) } @@ -209,7 +228,7 @@ class MainActivity : GenericActivity(), SnackBarActivity, NavController.OnDestin destination: NavDestination, arguments: Bundle? ) { - currentFocus?.hideKeyboard() + hideKeyboard() if (statusFragment.visibility == View.GONE) { statusFragment.visibility = View.VISIBLE } @@ -222,6 +241,10 @@ class MainActivity : GenericActivity(), SnackBarActivity, NavController.OnDestin updateTabsFragmentVisibility() } + fun hideKeyboard() { + currentFocus?.hideKeyboard() + } + private fun updateTabsFragmentVisibility() { tabsFragment.visibility = if (tabsFragmentVisible1 && tabsFragmentVisible2) View.VISIBLE else View.GONE } @@ -253,9 +276,8 @@ class MainActivity : GenericActivity(), SnackBarActivity, NavController.OnDestin if (uri != null) { val contactId = coreContext.contactsManager.getAndroidContactIdFromUri(uri) if (contactId != null) { - val deepLink = "linphone-android://contact/view/$contactId" - Log.i("[Main Activity] Found contact URI parameter in intent: $uri, starting deep link: $deepLink") - findNavController(R.id.nav_host_fragment).navigate(Uri.parse(deepLink)) + Log.i("[Main Activity] Found contact URI parameter in intent: $uri") + navigateToContact(contactId) } } } else { @@ -282,9 +304,8 @@ class MainActivity : GenericActivity(), SnackBarActivity, NavController.OnDestin when { intent.hasExtra("ContactId") -> { val id = intent.getStringExtra("ContactId") - val deepLink = "linphone-android://contact/view/$id" - Log.i("[Main Activity] Found contact id parameter in intent: $id, starting deep link: $deepLink") - findNavController(R.id.nav_host_fragment).navigate(Uri.parse(deepLink)) + Log.i("[Main Activity] Found contact ID in extras: $id") + navigateToContact(id) } intent.hasExtra("Chat") -> { if (corePreferences.disableChat) return @@ -293,10 +314,10 @@ class MainActivity : GenericActivity(), SnackBarActivity, NavController.OnDestin val peerAddress = intent.getStringExtra("RemoteSipUri") val localAddress = intent.getStringExtra("LocalSipUri") Log.i("[Main Activity] Found chat room intent extra: local SIP URI=[$localAddress], peer SIP URI=[$peerAddress]") - findNavController(R.id.nav_host_fragment).navigate(Uri.parse("linphone-android://chat-room/$localAddress/$peerAddress")) + navigateToChatRoom(localAddress, peerAddress) } else { Log.i("[Main Activity] Found chat intent extra, go to chat rooms list") - findNavController(R.id.nav_host_fragment).navigate(R.id.action_global_masterChatRoomsFragment) + navigateToChatRooms() } } intent.hasExtra("Dialer") -> { @@ -427,12 +448,11 @@ class MainActivity : GenericActivity(), SnackBarActivity, NavController.OnDestin addressToIM = addressToIM.substring("mmsto:".length) } - val peerAddress = coreContext.core.interpretUrl(addressToIM)?.asStringUriOnly() val localAddress = coreContext.core.defaultAccount?.params?.identityAddress?.asStringUriOnly() - val deepLink = "linphone-android://chat-room/$localAddress/$peerAddress" - Log.i("[Main Activity] Starting deep link: $deepLink") - findNavController(R.id.nav_host_fragment).navigate(Uri.parse(deepLink)) + val peerAddress = coreContext.core.interpretUrl(addressToIM)?.asStringUriOnly() + Log.i("[Main Activity] Navigating to chat room with local [$localAddress] and peer [$peerAddress] addresses") + navigateToChatRoom(localAddress, peerAddress) } else { val shortcutId = intent.getStringExtra("android.intent.extra.shortcut.ID") // Intent.EXTRA_SHORTCUT_ID if (shortcutId != null) { @@ -440,7 +460,7 @@ class MainActivity : GenericActivity(), SnackBarActivity, NavController.OnDestin handleLocusOrShortcut(shortcutId) } else { Log.i("[Main Activity] Going into chat rooms list") - findNavController(R.id.nav_host_fragment).navigate(R.id.action_global_masterChatRoomsFragment) + navigateToChatRooms() } } } @@ -450,11 +470,11 @@ class MainActivity : GenericActivity(), SnackBarActivity, NavController.OnDestin if (split.size == 2) { val localAddress = split[0] val peerAddress = split[1] - val deepLink = "linphone-android://chat-room/$localAddress/$peerAddress" - findNavController(R.id.nav_host_fragment).navigate(Uri.parse(deepLink)) + Log.i("[Main Activity] Navigating to chat room with local [$localAddress] and peer [$peerAddress] addresses, computed from shortcut/locus id") + navigateToChatRoom(localAddress, peerAddress) } else { - Log.e("[Main Activity] Failed to parse shortcut/locus id: $id") - findNavController(R.id.nav_host_fragment).navigate(R.id.action_global_masterChatRoomsFragment) + Log.e("[Main Activity] Failed to parse shortcut/locus id: $id, going to chat rooms list") + navigateToChatRooms() } } diff --git a/app/src/main/java/org/linphone/activities/main/about/AboutFragment.kt b/app/src/main/java/org/linphone/activities/main/about/AboutFragment.kt index 497c26f95..bb3ff4969 100644 --- a/app/src/main/java/org/linphone/activities/main/about/AboutFragment.kt +++ b/app/src/main/java/org/linphone/activities/main/about/AboutFragment.kt @@ -58,5 +58,13 @@ class AboutFragment : SecureFragment() { ) startActivity(browserIntent) } + + binding.setWeblateClickListener { + val browserIntent = Intent( + Intent.ACTION_VIEW, + Uri.parse(getString(R.string.about_weblate_link)) + ) + startActivity(browserIntent) + } } } diff --git a/app/src/main/java/org/linphone/activities/main/chat/ChatScrollListener.kt b/app/src/main/java/org/linphone/activities/main/chat/ChatScrollListener.kt index 440f1d239..d2e2c7622 100644 --- a/app/src/main/java/org/linphone/activities/main/chat/ChatScrollListener.kt +++ b/app/src/main/java/org/linphone/activities/main/chat/ChatScrollListener.kt @@ -29,7 +29,7 @@ internal abstract class ChatScrollListener(private val mLayoutManager: LinearLay // True if we are still waiting for the last set of data to load. private var loading = true - var userHasScrolledUp: Boolean = false + private var userHasScrolledUp: Boolean = false // This happens many times a second during a scroll, so be wary of the code you place here. // We are given a few useful parameters to help us work out if we need to load some more data, diff --git a/app/src/main/java/org/linphone/activities/main/chat/adapters/ChatMessagesListAdapter.kt b/app/src/main/java/org/linphone/activities/main/chat/adapters/ChatMessagesListAdapter.kt index 676be21b0..12a69892f 100644 --- a/app/src/main/java/org/linphone/activities/main/chat/adapters/ChatMessagesListAdapter.kt +++ b/app/src/main/java/org/linphone/activities/main/chat/adapters/ChatMessagesListAdapter.kt @@ -86,6 +86,10 @@ class ChatMessagesListAdapter( MutableLiveData>() } + val sipUriClickedEvent: MutableLiveData> by lazy { + MutableLiveData>() + } + val scrollToChatMessageEvent: MutableLiveData> by lazy { MutableLiveData>() } @@ -94,6 +98,10 @@ class ChatMessagesListAdapter( override fun onContentClicked(content: Content) { openContentEvent.value = Event(content) } + + override fun onSipAddressClicked(sipUri: String) { + sipUriClickedEvent.value = Event(sipUri) + } } private var contextMenuDisabled: Boolean = false @@ -211,15 +219,14 @@ class ChatMessagesListAdapter( // This is for item selection through ListTopBarFragment selectionListViewModel = selectionViewModel selectionViewModel.isEditionEnabled.observe( - viewLifecycleOwner, - { - position = adapterPosition - } - ) + viewLifecycleOwner + ) { + position = bindingAdapterPosition + } setClickListener { if (selectionViewModel.isEditionEnabled.value == true) { - selectionViewModel.onToggleSelect(adapterPosition) + selectionViewModel.onToggleSelect(bindingAdapterPosition) } } @@ -234,8 +241,8 @@ class ChatMessagesListAdapter( var hasPrevious = false var hasNext = false - if (adapterPosition > 0) { - val previousItem = getItem(adapterPosition - 1) + if (bindingAdapterPosition > 0) { + val previousItem = getItem(bindingAdapterPosition - 1) if (previousItem.eventLog.type == EventLog.Type.ConferenceChatMessage) { val previousMessage = previousItem.eventLog.chatMessage if (previousMessage != null && previousMessage.fromAddress.weakEqual(chatMessage.fromAddress)) { @@ -246,8 +253,8 @@ class ChatMessagesListAdapter( } } - if (adapterPosition >= 0 && adapterPosition < itemCount - 1) { - val nextItem = getItem(adapterPosition + 1) + if (bindingAdapterPosition >= 0 && bindingAdapterPosition < itemCount - 1) { + val nextItem = getItem(bindingAdapterPosition + 1) if (nextItem.eventLog.type == EventLog.Type.ConferenceChatMessage) { val nextMessage = nextItem.eventLog.chatMessage if (nextMessage != null && nextMessage.fromAddress.weakEqual(chatMessage.fromAddress)) { @@ -272,9 +279,8 @@ class ChatMessagesListAdapter( val itemSize = AppUtils.getDimension(R.dimen.chat_message_popup_item_height).toInt() var totalSize = itemSize * 7 - if (chatMessage.chatRoom.hasCapability(ChatRoomCapabilities.OneToOne.toInt()) || - chatMessage.state == ChatMessage.State.NotDelivered - ) { // No message id + if (chatMessage.chatRoom.hasCapability(ChatRoomCapabilities.OneToOne.toInt())) { + // No message id popupView.imdnHidden = true totalSize -= itemSize } @@ -347,7 +353,7 @@ class ChatMessagesListAdapter( private fun resendMessage() { val chatMessage = binding.data?.chatMessage if (chatMessage != null) { - chatMessage.userData = adapterPosition + chatMessage.userData = bindingAdapterPosition resendMessageEvent.value = Event(chatMessage) } } @@ -389,7 +395,7 @@ class ChatMessagesListAdapter( private fun deleteMessage() { val chatMessage = binding.data?.chatMessage if (chatMessage != null) { - chatMessage.userData = adapterPosition + chatMessage.userData = bindingAdapterPosition deleteMessageEvent.value = Event(chatMessage) } } @@ -417,15 +423,14 @@ class ChatMessagesListAdapter( // This is for item selection through ListTopBarFragment selectionListViewModel = selectionViewModel selectionViewModel.isEditionEnabled.observe( - viewLifecycleOwner, - { - position = adapterPosition - } - ) + viewLifecycleOwner + ) { + position = bindingAdapterPosition + } binding.setClickListener { if (selectionViewModel.isEditionEnabled.value == true) { - selectionViewModel.onToggleSelect(adapterPosition) + selectionViewModel.onToggleSelect(bindingAdapterPosition) } } @@ -452,8 +457,18 @@ private class ChatMessageDiffCallback : DiffUtil.ItemCallback() { oldItem: EventLogData, newItem: EventLogData ): Boolean { - return if (newItem.eventLog.type == EventLog.Type.ConferenceChatMessage) { - newItem.eventLog.chatMessage?.state == ChatMessage.State.Displayed - } else true + return if (oldItem.eventLog.type == EventLog.Type.ConferenceChatMessage && + newItem.eventLog.type == EventLog.Type.ConferenceChatMessage + ) { + val oldData = (oldItem.data as ChatMessageData) + val newData = (newItem.data as ChatMessageData) + + val previous = oldData.hasPreviousMessage == newData.hasPreviousMessage + val next = oldData.hasNextMessage == newData.hasNextMessage + newItem.eventLog.chatMessage?.state == ChatMessage.State.Displayed && previous && next + } else { + oldItem.eventLog.type != EventLog.Type.ConferenceChatMessage && + newItem.eventLog.type != EventLog.Type.ConferenceChatMessage + } } } diff --git a/app/src/main/java/org/linphone/activities/main/chat/adapters/ChatRoomsListAdapter.kt b/app/src/main/java/org/linphone/activities/main/chat/adapters/ChatRoomsListAdapter.kt index d99eacb7d..18face997 100644 --- a/app/src/main/java/org/linphone/activities/main/chat/adapters/ChatRoomsListAdapter.kt +++ b/app/src/main/java/org/linphone/activities/main/chat/adapters/ChatRoomsListAdapter.kt @@ -73,17 +73,16 @@ class ChatRoomsListAdapter( // This is for item selection through ListTopBarFragment selectionListViewModel = selectionViewModel selectionViewModel.isEditionEnabled.observe( - viewLifecycleOwner, - { - position = adapterPosition - } - ) + viewLifecycleOwner + ) { + position = bindingAdapterPosition + } forwardPending = isForwardPending setClickListener { if (selectionViewModel.isEditionEnabled.value == true) { - selectionViewModel.onToggleSelect(adapterPosition) + selectionViewModel.onToggleSelect(bindingAdapterPosition) } else { selectedChatRoomEvent.value = Event(chatRoomViewModel.chatRoom) } @@ -116,6 +115,6 @@ private class ChatRoomDiffCallback : DiffUtil.ItemCallback() oldItem: ChatRoomViewModel, newItem: ChatRoomViewModel ): Boolean { - return newItem.unreadMessagesCount.value == 0 + return false // To force redraw when contacts are updated } } diff --git a/app/src/main/java/org/linphone/activities/main/chat/data/ChatMessageContentData.kt b/app/src/main/java/org/linphone/activities/main/chat/data/ChatMessageContentData.kt index 0623cf4ac..8c81a62cf 100644 --- a/app/src/main/java/org/linphone/activities/main/chat/data/ChatMessageContentData.kt +++ b/app/src/main/java/org/linphone/activities/main/chat/data/ChatMessageContentData.kt @@ -44,7 +44,6 @@ import org.linphone.utils.ImageUtils class ChatMessageContentData( private val chatMessage: ChatMessage, private val contentIndex: Int, - ) { var listener: OnContentClickedListener? = null @@ -60,7 +59,6 @@ class ChatMessageContentData( val fileName = MutableLiveData() val filePath = MutableLiveData() - val fileSize = MutableLiveData() val downloadable = MutableLiveData() val downloadEnabled = MutableLiveData() @@ -72,13 +70,11 @@ class ChatMessageContentData( val formattedDuration = MutableLiveData() val voiceRecordPlayingPosition = MutableLiveData() val isVoiceRecordPlaying = MutableLiveData() - var voiceRecordAudioFocusRequest: AudioFocusRequestCompat? = null val isAlone: Boolean get() { var count = 0 for (content in chatMessage.contents) { - val content = getContent() if (content.isFileTransfer || content.isFile) { count += 1 } @@ -86,7 +82,9 @@ class ChatMessageContentData( return count == 1 } - var isFileEncrypted: Boolean = false + private var isFileEncrypted: Boolean = false + + private var voiceRecordAudioFocusRequest: AudioFocusRequestCompat? = null private lateinit var voiceRecordingPlayer: Player private val playerListener = PlayerListener { @@ -145,13 +143,7 @@ class ChatMessageContentData( fun destroy() { scope.cancel() - val path = filePath.value.orEmpty() - if (path.isNotEmpty() && isFileEncrypted) { - Log.i("[Content] Deleting file used for preview: $path") - FileUtils.deleteFile(path) - filePath.value = "" - } - + deletePlainFilePath() chatMessage.removeListener(chatMessageListener) if (this::voiceRecordingPlayer.isInitialized) { @@ -181,9 +173,22 @@ class ChatMessageContentData( listener?.onContentClicked(getContent()) } + private fun deletePlainFilePath() { + val path = filePath.value.orEmpty() + if (path.isNotEmpty() && isFileEncrypted) { + Log.i("[Content] Deleting file used for preview: $path") + FileUtils.deleteFile(path) + filePath.value = "" + } + } + private fun updateContent() { + Log.i("[Content] Updating content") + deletePlainFilePath() + val content = getContent() isFileEncrypted = content.isFileEncrypted + Log.i("[Content] Is ${if (content.isFile) "file" else "file transfer"} content encrypted ? $isFileEncrypted") filePath.value = "" fileName.value = if (content.name.isNullOrEmpty() && !content.filePath.isNullOrEmpty()) { @@ -193,14 +198,18 @@ class ChatMessageContentData( } // Display download size and underline text - fileSize.value = AppUtils.bytesToDisplayableSize(content.fileSize.toLong()) - val spannable = SpannableString("${AppUtils.getString(R.string.chat_message_download_file)} (${fileSize.value})") + val fileSize = AppUtils.bytesToDisplayableSize(content.fileSize.toLong()) + val spannable = SpannableString("${AppUtils.getString(R.string.chat_message_download_file)} ($fileSize)") spannable.setSpan(UnderlineSpan(), 0, spannable.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) downloadLabel.value = spannable if (content.isFile || (content.isFileTransfer && chatMessage.isOutgoing)) { - Log.i("[Content] Is content encrypted ? $isFileEncrypted") - val path = if (isFileEncrypted) content.plainFilePath else content.filePath ?: "" + val path = if (isFileEncrypted) { + Log.i("[Content] Content is encrypted, requesting plain file path") + content.plainFilePath + } else { + content.filePath ?: "" + } downloadable.value = content.filePath.orEmpty().isEmpty() if (path.isNotEmpty()) { @@ -226,7 +235,7 @@ class ChatMessageContentData( } } } else { - Log.w("[Content] Found content with empty path...") + Log.w("[Content] Found ${if (content.isFile) "file" else "file transfer"} content with empty path...") isImage.value = false isVideo.value = false isAudio.value = false @@ -297,8 +306,9 @@ class ChatMessageContentData( private fun initVoiceRecordPlayer() { Log.i("[Voice Recording] Creating player for voice record") - // Use speaker sound card to play recordings, otherwise use earpiece + // In case no headphones/headset is connected, use speaker sound card to play recordings, otherwise use earpiece // If none are available, default one will be used + var headphonesCard: String? = null var speakerCard: String? = null var earpieceCard: String? = null for (device in coreContext.core.audioDevices) { @@ -307,12 +317,14 @@ class ChatMessageContentData( speakerCard = device.id } else if (device.type == AudioDevice.Type.Earpiece) { earpieceCard = device.id + } else if (device.type == AudioDevice.Type.Headphones || device.type == AudioDevice.Type.Headset) { + headphonesCard = device.id } } } - Log.i("[Voice Recording] Found speaker sound card [$speakerCard] and earpiece sound card [$earpieceCard]") + Log.i("[Voice Recording] Found headset/headphones sound card [$headphonesCard], speaker sound card [$speakerCard] and earpiece sound card [$earpieceCard]") - val localPlayer = coreContext.core.createLocalPlayer(speakerCard ?: earpieceCard, null, null) + val localPlayer = coreContext.core.createLocalPlayer(headphonesCard ?: speakerCard ?: earpieceCard, null, null) if (localPlayer != null) { voiceRecordingPlayer = localPlayer } else { @@ -321,8 +333,7 @@ class ChatMessageContentData( } voiceRecordingPlayer.addListener(playerListener) - val content = getContent() - val path = if (content.isFileEncrypted) content.plainFilePath else content.filePath ?: "" + val path = filePath.value voiceRecordingPlayer.open(path.orEmpty()) voiceRecordDuration.value = voiceRecordingPlayer.duration formattedDuration.value = SimpleDateFormat("mm:ss", Locale.getDefault()).format(voiceRecordingPlayer.duration) // is already in milliseconds @@ -346,4 +357,6 @@ class ChatMessageContentData( interface OnContentClickedListener { fun onContentClicked(content: Content) + + fun onSipAddressClicked(sipUri: String) } diff --git a/app/src/main/java/org/linphone/activities/main/chat/data/ChatMessageData.kt b/app/src/main/java/org/linphone/activities/main/chat/data/ChatMessageData.kt index cd8230096..3f588d008 100644 --- a/app/src/main/java/org/linphone/activities/main/chat/data/ChatMessageData.kt +++ b/app/src/main/java/org/linphone/activities/main/chat/data/ChatMessageData.kt @@ -24,12 +24,14 @@ import android.text.Spannable import android.text.util.Linkify import androidx.core.text.util.LinkifyCompat import androidx.lifecycle.MutableLiveData +import java.util.regex.Pattern import org.linphone.R import org.linphone.contact.GenericContactData import org.linphone.core.ChatMessage import org.linphone.core.ChatMessageListenerStub import org.linphone.core.tools.Log import org.linphone.utils.AppUtils +import org.linphone.utils.PatternClickableSpan import org.linphone.utils.TimestampUtils class ChatMessageData(val chatMessage: ChatMessage) : GenericContactData(chatMessage.fromAddress) { @@ -59,6 +61,9 @@ class ChatMessageData(val chatMessage: ChatMessage) : GenericContactData(chatMes val replyData = MutableLiveData() + var hasPreviousMessage = false + var hasNextMessage = false + private var countDownTimer: CountDownTimer? = null private val listener = object : ChatMessageListenerStub() { @@ -106,6 +111,11 @@ class ChatMessageData(val chatMessage: ChatMessage) : GenericContactData(chatMes } fun updateBubbleBackground(hasPrevious: Boolean, hasNext: Boolean) { + hasPreviousMessage = hasPrevious + hasNextMessage = hasNext + hideTime.value = false + hideAvatar.value = false + if (hasPrevious) { hideTime.value = true } @@ -165,16 +175,25 @@ class ChatMessageData(val chatMessage: ChatMessage) : GenericContactData(chatMes val list = arrayListOf() val contentsList = chatMessage.contents - for (index in 0 until contentsList.size) { + for (index in contentsList.indices) { val content = contentsList[index] if (content.isFileTransfer || content.isFile) { val data = ChatMessageContentData(chatMessage, index) data.listener = contentListener list.add(data) } else if (content.isText) { - val spannable = Spannable.Factory.getInstance().newSpannable(content.utf8Text) + val spannable = Spannable.Factory.getInstance().newSpannable(content.utf8Text?.trim()) LinkifyCompat.addLinks(spannable, Linkify.WEB_URLS) - text.value = spannable + text.value = PatternClickableSpan() + .add( + Pattern.compile("(sips?):([^@]+)(?:@([^ ]+))?"), + object : PatternClickableSpan.SpannableClickedListener { + override fun onSpanClicked(text: String) { + Log.i("[Chat Message Data] Clicked on SIP URI: $text") + contentListener?.onSipAddressClicked(text) + } + } + ).build(spannable) } } diff --git a/app/src/main/java/org/linphone/activities/main/chat/fragments/ChatRoomCreationFragment.kt b/app/src/main/java/org/linphone/activities/main/chat/fragments/ChatRoomCreationFragment.kt index 714f8834d..95b08e3bf 100644 --- a/app/src/main/java/org/linphone/activities/main/chat/fragments/ChatRoomCreationFragment.kt +++ b/app/src/main/java/org/linphone/activities/main/chat/fragments/ChatRoomCreationFragment.kt @@ -93,62 +93,55 @@ class ChatRoomCreationFragment : SecureFragment } viewModel.contactsList.observe( - viewLifecycleOwner, - { - adapter.submitList(it) - } - ) + viewLifecycleOwner + ) { + adapter.submitList(it) + } viewModel.isEncrypted.observe( - viewLifecycleOwner, - { - adapter.updateSecurity(it) - } - ) + viewLifecycleOwner + ) { + adapter.updateSecurity(it) + } viewModel.sipContactsSelected.observe( - viewLifecycleOwner, - { - viewModel.updateContactsList() - } - ) + viewLifecycleOwner + ) { + viewModel.updateContactsList() + } viewModel.selectedAddresses.observe( - viewLifecycleOwner, - { - adapter.updateSelectedAddresses(it) - } - ) + viewLifecycleOwner + ) { + adapter.updateSelectedAddresses(it) + } viewModel.chatRoomCreatedEvent.observe( - viewLifecycleOwner, - { - it.consume { chatRoom -> - sharedViewModel.selectedChatRoom.value = chatRoom - navigateToChatRoom(AppUtils.createBundleWithSharedTextAndFiles(sharedViewModel)) - } + viewLifecycleOwner + ) { + it.consume { chatRoom -> + sharedViewModel.selectedChatRoom.value = chatRoom + navigateToChatRoom(AppUtils.createBundleWithSharedTextAndFiles(sharedViewModel)) } - ) + } viewModel.filter.observe( - viewLifecycleOwner, - { - viewModel.applyFilter() - } - ) + viewLifecycleOwner + ) { + viewModel.applyFilter() + } adapter.selectedContact.observe( - viewLifecycleOwner, - { - it.consume { searchResult -> - if (createGroup) { - viewModel.toggleSelectionForSearchResult(searchResult) - } else { - viewModel.createOneToOneChat(searchResult) - } + viewLifecycleOwner + ) { + it.consume { searchResult -> + if (createGroup) { + viewModel.toggleSelectionForSearchResult(searchResult) + } else { + viewModel.createOneToOneChat(searchResult) } } - ) + } addParticipantsFromSharedViewModel() @@ -160,13 +153,12 @@ class ChatRoomCreationFragment : SecureFragment } viewModel.onErrorEvent.observe( - viewLifecycleOwner, - { - it.consume { messageResourceId -> - (activity as MainActivity).showSnackBar(messageResourceId) - } + viewLifecycleOwner + ) { + it.consume { messageResourceId -> + (activity as MainActivity).showSnackBar(messageResourceId) } - ) + } if (!PermissionHelper.get().hasReadContactsPermission()) { Log.i("[Chat Room Creation] Asking for READ_CONTACTS permission") diff --git a/app/src/main/java/org/linphone/activities/main/chat/fragments/DetailChatRoomFragment.kt b/app/src/main/java/org/linphone/activities/main/chat/fragments/DetailChatRoomFragment.kt index 273cf82f2..f8c2e012e 100644 --- a/app/src/main/java/org/linphone/activities/main/chat/fragments/DetailChatRoomFragment.kt +++ b/app/src/main/java/org/linphone/activities/main/chat/fragments/DetailChatRoomFragment.kt @@ -29,19 +29,20 @@ import android.provider.MediaStore import android.view.* import android.view.ViewTreeObserver.OnGlobalLayoutListener import android.widget.PopupWindow +import androidx.core.content.ContextCompat import androidx.core.content.FileProvider import androidx.core.view.doOnPreDraw import androidx.databinding.DataBindingUtil +import androidx.databinding.ViewDataBinding import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import java.io.File import java.lang.IllegalArgumentException -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext +import kotlinx.coroutines.* import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.LinphoneApplication.Companion.corePreferences import org.linphone.R @@ -52,6 +53,7 @@ import org.linphone.activities.main.chat.adapters.ChatMessagesListAdapter import org.linphone.activities.main.chat.data.ChatMessageData import org.linphone.activities.main.chat.data.EventLogData import org.linphone.activities.main.chat.viewmodels.* +import org.linphone.activities.main.chat.views.RichEditTextSendListener import org.linphone.activities.main.fragments.MasterFragment import org.linphone.activities.main.viewmodels.DialogViewModel import org.linphone.activities.main.viewmodels.SharedMainViewModel @@ -107,6 +109,20 @@ class DetailChatRoomFragment : MasterFragment - adapter.setUnreadMessageCount(viewModel.chatRoom.unreadMessagesCount, viewModel.isUserScrollingUp.value == true) - adapter.submitList(events) - } - ) + viewLifecycleOwner + ) { events -> + adapter.setUnreadMessageCount( + viewModel.chatRoom.unreadMessagesCount, + viewModel.isUserScrollingUp.value == true + ) + adapter.submitList(events) + } listViewModel.messageUpdatedEvent.observe( - viewLifecycleOwner, - { - it.consume { position -> - adapter.notifyItemChanged(position) - } + viewLifecycleOwner + ) { + it.consume { position -> + adapter.notifyItemChanged(position) } - ) + } listViewModel.requestWriteExternalStoragePermissionEvent.observe( - viewLifecycleOwner, - { - it.consume { - requestPermissions(arrayOf(android.Manifest.permission.WRITE_EXTERNAL_STORAGE), 1) - } + viewLifecycleOwner + ) { + it.consume { + requestPermissions(arrayOf(android.Manifest.permission.WRITE_EXTERNAL_STORAGE), 1) } - ) + } adapter.deleteMessageEvent.observe( - viewLifecycleOwner, - { - it.consume { chatMessage -> - listViewModel.deleteMessage(chatMessage) - } + viewLifecycleOwner + ) { + it.consume { chatMessage -> + listViewModel.deleteMessage(chatMessage) + viewModel.updateLastMessageToDisplay() } - ) + } adapter.resendMessageEvent.observe( - viewLifecycleOwner, - { - it.consume { chatMessage -> - listViewModel.resendMessage(chatMessage) - } + viewLifecycleOwner + ) { + it.consume { chatMessage -> + listViewModel.resendMessage(chatMessage) } - ) + } adapter.forwardMessageEvent.observe( - viewLifecycleOwner, - { - it.consume { chatMessage -> - // Remove observer before setting the message to forward - // as we don't want to forward it in this chat room - sharedViewModel.messageToForwardEvent.removeObservers(viewLifecycleOwner) - sharedViewModel.messageToForwardEvent.value = Event(chatMessage) - sharedViewModel.isPendingMessageForward.value = true + viewLifecycleOwner + ) { + it.consume { chatMessage -> + // Remove observer before setting the message to forward + // as we don't want to forward it in this chat room + sharedViewModel.messageToForwardEvent.removeObservers(viewLifecycleOwner) + sharedViewModel.messageToForwardEvent.value = Event(chatMessage) + sharedViewModel.isPendingMessageForward.value = true - if (sharedViewModel.isSlidingPaneSlideable.value == true) { - Log.i("[Chat Room] Forwarding message, going to chat rooms list") - sharedViewModel.closeSlidingPaneEvent.value = Event(true) - } else { - navigateToEmptyChatRoom() - } + if (sharedViewModel.isSlidingPaneSlideable.value == true) { + Log.i("[Chat Room] Forwarding message, going to chat rooms list") + sharedViewModel.closeSlidingPaneEvent.value = Event(true) + } else { + navigateToEmptyChatRoom() } } - ) + } adapter.replyMessageEvent.observe( - viewLifecycleOwner, - { - it.consume { chatMessage -> - chatSendingViewModel.pendingChatMessageToReplyTo.value?.destroy() - chatSendingViewModel.pendingChatMessageToReplyTo.value = ChatMessageData(chatMessage) - chatSendingViewModel.isPendingAnswer.value = true - } + viewLifecycleOwner + ) { + it.consume { chatMessage -> + chatSendingViewModel.pendingChatMessageToReplyTo.value?.destroy() + chatSendingViewModel.pendingChatMessageToReplyTo.value = + ChatMessageData(chatMessage) + chatSendingViewModel.isPendingAnswer.value = true } - ) + } adapter.showImdnForMessageEvent.observe( - viewLifecycleOwner, - { - it.consume { chatMessage -> - val args = Bundle() - args.putString("MessageId", chatMessage.messageId) - navigateToImdn(args) - } + viewLifecycleOwner + ) { + it.consume { chatMessage -> + val args = Bundle() + args.putString("MessageId", chatMessage.messageId) + navigateToImdn(args) } - ) + } adapter.addSipUriToContactEvent.observe( - viewLifecycleOwner, - { - it.consume { sipUri -> - Log.i("[Chat Room] Going to contacts list with SIP URI to add: $sipUri") - sharedViewModel.updateContactsAnimationsBasedOnDestination.value = Event(R.id.masterChatRoomsFragment) - navigateToContacts(sipUri) - } + viewLifecycleOwner + ) { + it.consume { sipUri -> + Log.i("[Chat Room] Going to contacts list with SIP URI to add: $sipUri") + sharedViewModel.updateContactsAnimationsBasedOnDestination.value = + Event(R.id.masterChatRoomsFragment) + navigateToContacts(sipUri) } - ) + } adapter.openContentEvent.observe( - viewLifecycleOwner, - { - it.consume { content -> - val path = content.filePath.orEmpty() + viewLifecycleOwner + ) { + it.consume { content -> + val path = content.filePath.orEmpty() - if (!File(path).exists()) { - (requireActivity() as MainActivity).showSnackBar(R.string.chat_room_file_not_found) - } else { - Log.i("[Chat Message] Opening file: $path") - sharedViewModel.contentToOpen.value = content + if (!File(path).exists()) { + (requireActivity() as MainActivity).showSnackBar(R.string.chat_room_file_not_found) + } else { + Log.i("[Chat Message] Opening file: $path") + sharedViewModel.contentToOpen.value = content - if (corePreferences.useInAppFileViewerForNonEncryptedFiles || content.isFileEncrypted) { - val preventScreenshots = - viewModel.chatRoom.currentParams.encryptionEnabled() - when { - FileUtils.isExtensionImage(path) -> navigateToImageFileViewer( - preventScreenshots - ) - FileUtils.isExtensionVideo(path) -> navigateToVideoFileViewer( - preventScreenshots - ) - FileUtils.isExtensionAudio(path) -> navigateToAudioFileViewer( - preventScreenshots - ) - FileUtils.isExtensionPdf(path) -> navigateToPdfFileViewer( - preventScreenshots - ) - FileUtils.isPlainTextFile(path) -> navigateToTextFileViewer( - preventScreenshots - ) - else -> { - if (content.isFileEncrypted) { - Log.w("[Chat Message] File is encrypted and can't be opened in one of our viewers...") - showDialogForUserConsentBeforeExportingFileInThirdPartyApp(content) - } else if (!FileUtils.openFileInThirdPartyApp(requireActivity(), path)) { - showDialogToSuggestOpeningFileAsText() - } + if (corePreferences.useInAppFileViewerForNonEncryptedFiles || content.isFileEncrypted) { + val preventScreenshots = + viewModel.chatRoom.currentParams.isEncryptionEnabled + when { + FileUtils.isExtensionImage(path) -> navigateToImageFileViewer( + preventScreenshots + ) + FileUtils.isExtensionVideo(path) -> navigateToVideoFileViewer( + preventScreenshots + ) + FileUtils.isExtensionAudio(path) -> navigateToAudioFileViewer( + preventScreenshots + ) + FileUtils.isExtensionPdf(path) -> navigateToPdfFileViewer( + preventScreenshots + ) + FileUtils.isPlainTextFile(path) -> navigateToTextFileViewer( + preventScreenshots + ) + else -> { + if (content.isFileEncrypted) { + Log.w("[Chat Message] File is encrypted and can't be opened in one of our viewers...") + showDialogForUserConsentBeforeExportingFileInThirdPartyApp( + content + ) + } else if (!FileUtils.openFileInThirdPartyApp( + requireActivity(), + path + ) + ) { + showDialogToSuggestOpeningFileAsText() } } - } else { - if (!FileUtils.openFileInThirdPartyApp(requireActivity(), path)) { - showDialogToSuggestOpeningFileAsText() - } + } + } else { + if (!FileUtils.openFileInThirdPartyApp(requireActivity(), path)) { + showDialogToSuggestOpeningFileAsText() } } } } - ) + } + + adapter.sipUriClickedEvent.observe( + viewLifecycleOwner + ) { + it.consume { sipUri -> + val args = Bundle() + args.putString("URI", sipUri) + args.putBoolean("Transfer", false) + // If auto start call setting is enabled, ignore it + args.putBoolean("SkipAutoCallStart", true) + navigateToDialer(args) + } + } adapter.scrollToChatMessageEvent.observe( - viewLifecycleOwner, - { - it.consume { chatMessage -> - val events = listViewModel.events.value.orEmpty() - val eventLog = events.find { eventLog -> - if (eventLog.eventLog.type == EventLog.Type.ConferenceChatMessage) { - (eventLog.data as ChatMessageData).chatMessage.messageId == chatMessage.messageId - } else false - } - val index = events.indexOf(eventLog) - try { - if (corePreferences.enableAnimations) { - binding.chatMessagesList.smoothScrollToPosition(index) - } else { - binding.chatMessagesList.scrollToPosition(index) - } - } catch (iae: IllegalArgumentException) { - Log.e("[Chat Room] Can't scroll to position $index") + viewLifecycleOwner + ) { + it.consume { chatMessage -> + val events = listViewModel.events.value.orEmpty() + val eventLog = events.find { eventLog -> + if (eventLog.eventLog.type == EventLog.Type.ConferenceChatMessage) { + (eventLog.data as ChatMessageData).chatMessage.messageId == chatMessage.messageId + } else false + } + val index = events.indexOf(eventLog) + try { + if (corePreferences.enableAnimations) { + binding.chatMessagesList.smoothScrollToPosition(index) + } else { + binding.chatMessagesList.scrollToPosition(index) } + } catch (iae: IllegalArgumentException) { + Log.e("[Chat Room] Can't scroll to position $index") } } - ) + } binding.setBackClickListener { goBack() @@ -493,8 +554,20 @@ class DetailChatRoomFragment : MasterFragment - Log.i("[Chat] Found rich content URI: $uri") - lifecycleScope.launch { - withContext(Dispatchers.Main) { - val path = FileUtils.getFilePath(requireContext(), uri) - Log.i("[Chat] Rich content URI: $uri matching path is: $path") - if (path != null) { - chatSendingViewModel.addAttachment(path) - } + viewLifecycleOwner + ) { + it.consume { uri -> + Log.i("[Chat] Found rich content URI: $uri") + lifecycleScope.launch { + withContext(Dispatchers.Main) { + val path = FileUtils.getFilePath(requireContext(), uri) + Log.i("[Chat] Rich content URI: $uri matching path is: $path") + if (path != null) { + chatSendingViewModel.addAttachment(path) } } } } - ) + } sharedViewModel.messageToForwardEvent.observe( - viewLifecycleOwner, - { - it.consume { chatMessage -> - Log.i("[Chat Room] Found message to transfer") - showForwardConfirmationDialog(chatMessage) - sharedViewModel.isPendingMessageForward.value = false - } + viewLifecycleOwner + ) { + it.consume { chatMessage -> + Log.i("[Chat Room] Found message to transfer") + showForwardConfirmationDialog(chatMessage) + sharedViewModel.isPendingMessageForward.value = false } - ) + } + + binding.stubbedMessageToReplyTo.setOnInflateListener { _, inflated -> + Log.i("[Chat Room] Replying to message layout inflated") + val binding = DataBindingUtil.bind(inflated) + binding?.lifecycleOwner = viewLifecycleOwner + } + + binding.stubbedVoiceRecording.setOnInflateListener { _, inflated -> + Log.i("[Chat Room] Voice recording layout inflated") + val binding = DataBindingUtil.bind(inflated) + binding?.lifecycleOwner = viewLifecycleOwner + } } override fun deleteItems(indexesOfItemToDelete: ArrayList) { @@ -545,6 +628,7 @@ class DetailChatRoomFragment : MasterFragment 0) { // Scroll to first unread message if any @@ -858,11 +972,13 @@ class DetailChatRoomFragment : MasterFragment() { return } - isSecure = chatRoom.currentParams.encryptionEnabled() + isSecure = chatRoom.currentParams.isEncryptionEnabled listViewModel = ViewModelProvider( this, @@ -66,4 +66,10 @@ class DevicesFragment : SecureFragment() { goBack() } } + + override fun onResume() { + super.onResume() + + listViewModel.updateParticipants() + } } diff --git a/app/src/main/java/org/linphone/activities/main/chat/fragments/GroupInfoFragment.kt b/app/src/main/java/org/linphone/activities/main/chat/fragments/GroupInfoFragment.kt index 7777b379a..4baa685a3 100644 --- a/app/src/main/java/org/linphone/activities/main/chat/fragments/GroupInfoFragment.kt +++ b/app/src/main/java/org/linphone/activities/main/chat/fragments/GroupInfoFragment.kt @@ -61,7 +61,7 @@ class GroupInfoFragment : SecureFragment() { } val chatRoom: ChatRoom? = sharedViewModel.selectedGroupChatRoom.value - isSecure = chatRoom?.currentParams?.encryptionEnabled() ?: false + isSecure = chatRoom?.currentParams?.isEncryptionEnabled ?: false viewModel = ViewModelProvider( this, @@ -84,36 +84,32 @@ class GroupInfoFragment : SecureFragment() { binding.participants.addItemDecoration(AppUtils.getDividerDecoration(requireContext(), layoutManager)) viewModel.participants.observe( - viewLifecycleOwner, - { - adapter.submitList(it) - } - ) + viewLifecycleOwner + ) { + adapter.submitList(it) + } viewModel.isMeAdmin.observe( - viewLifecycleOwner, - { isMeAdmin -> - adapter.showAdminControls(isMeAdmin && chatRoom != null) - } - ) + viewLifecycleOwner + ) { isMeAdmin -> + adapter.showAdminControls(isMeAdmin && chatRoom != null) + } viewModel.meAdminChangedEvent.observe( - viewLifecycleOwner, - { - it.consume { isMeAdmin -> - showMeAdminStateChanged(isMeAdmin) - } + viewLifecycleOwner + ) { + it.consume { isMeAdmin -> + showMeAdminStateChanged(isMeAdmin) } - ) + } adapter.participantRemovedEvent.observe( - viewLifecycleOwner, - { - it.consume { participant -> - viewModel.removeParticipant(participant) - } + viewLifecycleOwner + ) { + it.consume { participant -> + viewModel.removeParticipant(participant) } - ) + } addParticipantsFromSharedViewModel() @@ -122,22 +118,20 @@ class GroupInfoFragment : SecureFragment() { } viewModel.createdChatRoomEvent.observe( - viewLifecycleOwner, - { - it.consume { chatRoom -> - goToChatRoom(chatRoom, true) - } + viewLifecycleOwner + ) { + it.consume { chatRoom -> + goToChatRoom(chatRoom, true) } - ) + } viewModel.updatedChatRoomEvent.observe( - viewLifecycleOwner, - { - it.consume { chatRoom -> - goToChatRoom(chatRoom, false) - } + viewLifecycleOwner + ) { + it.consume { chatRoom -> + goToChatRoom(chatRoom, false) } - ) + } binding.setNextClickListener { if (viewModel.chatRoom != null) { @@ -182,13 +176,12 @@ class GroupInfoFragment : SecureFragment() { } viewModel.onErrorEvent.observe( - viewLifecycleOwner, - { - it.consume { messageResourceId -> - (activity as MainActivity).showSnackBar(messageResourceId) - } + viewLifecycleOwner + ) { + it.consume { messageResourceId -> + (activity as MainActivity).showSnackBar(messageResourceId) } - ) + } } private fun addParticipantsFromSharedViewModel() { diff --git a/app/src/main/java/org/linphone/activities/main/chat/fragments/ImdnFragment.kt b/app/src/main/java/org/linphone/activities/main/chat/fragments/ImdnFragment.kt index d59a75438..e50e5d0a1 100644 --- a/app/src/main/java/org/linphone/activities/main/chat/fragments/ImdnFragment.kt +++ b/app/src/main/java/org/linphone/activities/main/chat/fragments/ImdnFragment.kt @@ -61,7 +61,7 @@ class ImdnFragment : SecureFragment() { return } - isSecure = chatRoom.currentParams.encryptionEnabled() + isSecure = chatRoom.currentParams.isEncryptionEnabled if (arguments != null) { val messageId = arguments?.getString("MessageId") @@ -98,11 +98,10 @@ class ImdnFragment : SecureFragment() { binding.participantsList.addItemDecoration(headerItemDecoration) viewModel.participants.observe( - viewLifecycleOwner, - { - adapter.submitList(it) - } - ) + viewLifecycleOwner + ) { + adapter.submitList(it) + } binding.setBackClickListener { goBack() diff --git a/app/src/main/java/org/linphone/activities/main/chat/fragments/MasterChatRoomsFragment.kt b/app/src/main/java/org/linphone/activities/main/chat/fragments/MasterChatRoomsFragment.kt index 54968d878..eff7aedc1 100644 --- a/app/src/main/java/org/linphone/activities/main/chat/fragments/MasterChatRoomsFragment.kt +++ b/app/src/main/java/org/linphone/activities/main/chat/fragments/MasterChatRoomsFragment.kt @@ -98,7 +98,9 @@ class MasterChatRoomsFragment : MasterFragment - adapter.submitList(chatRooms) - } - ) + viewLifecycleOwner + ) { chatRooms -> + adapter.submitList(chatRooms) + } listViewModel.contactsUpdatedEvent.observe( - viewLifecycleOwner, - { - it.consume { - adapter.notifyDataSetChanged() - } + viewLifecycleOwner + ) { + it.consume { + adapter.notifyDataSetChanged() } - ) + } adapter.selectedChatRoomEvent.observe( - viewLifecycleOwner, - { - it.consume { chatRoom -> - if ((requireActivity() as GenericActivity).isDestructionPending) { - Log.w("[Chat] Activity is pending destruction, don't start navigating now!") - sharedViewModel.destructionPendingChatRoom = chatRoom - } else { - if (chatRoom.peerAddress.asStringUriOnly() == coreContext.notificationsManager.currentlyDisplayedChatRoomAddress) { - Log.w("[Chat] This chat room is already displayed!") + viewLifecycleOwner + ) { + it.consume { chatRoom -> + if ((requireActivity() as GenericActivity).isDestructionPending) { + Log.w("[Chat] Activity is pending destruction, don't start navigating now!") + sharedViewModel.destructionPendingChatRoom = chatRoom + } else { + if (chatRoom.peerAddress.asStringUriOnly() == coreContext.notificationsManager.currentlyDisplayedChatRoomAddress) { + if (!binding.slidingPane.isOpen) { + Log.w("[Chat] Chat room is displayed but sliding pane is closed...") + if (!binding.slidingPane.openPane()) { + Log.e("[Chat] Tried to open pane to workaround already displayed chat room issue, failed!") + } } else { - sharedViewModel.selectedChatRoom.value = chatRoom - navigateToChatRoom( - AppUtils.createBundleWithSharedTextAndFiles( - sharedViewModel - ) - ) + Log.w("[Chat] This chat room is already displayed!") } + } else { + sharedViewModel.selectedChatRoom.value = chatRoom + navigateToChatRoom( + AppUtils.createBundleWithSharedTextAndFiles( + sharedViewModel + ) + ) } } } - ) + } binding.setEditClickListener { listSelectionViewModel.isEditionEnabled.value = true @@ -315,56 +308,52 @@ class MasterChatRoomsFragment : MasterFragment - (activity as MainActivity).showSnackBar(messageResourceId) - } + viewLifecycleOwner + ) { + it.consume { messageResourceId -> + (activity as MainActivity).showSnackBar(messageResourceId) } - ) + } } } diff --git a/app/src/main/java/org/linphone/activities/main/chat/viewmodels/ChatMessageSendingViewModel.kt b/app/src/main/java/org/linphone/activities/main/chat/viewmodels/ChatMessageSendingViewModel.kt index f7e0de69c..37290f8f3 100644 --- a/app/src/main/java/org/linphone/activities/main/chat/viewmodels/ChatMessageSendingViewModel.kt +++ b/app/src/main/java/org/linphone/activities/main/chat/viewmodels/ChatMessageSendingViewModel.kt @@ -19,6 +19,7 @@ */ package org.linphone.activities.main.chat.viewmodels +import android.view.inputmethod.EditorInfo import android.widget.Toast import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel @@ -37,6 +38,7 @@ import org.linphone.LinphoneApplication.Companion.corePreferences import org.linphone.R import org.linphone.activities.main.chat.data.ChatMessageAttachmentData import org.linphone.activities.main.chat.data.ChatMessageData +import org.linphone.compatibility.Compatibility import org.linphone.core.* import org.linphone.core.tools.Log import org.linphone.utils.AppUtils @@ -86,11 +88,18 @@ class ChatMessageSendingViewModel(private val chatRoom: ChatRoom) : ViewModel() val isPlayingVoiceRecording = MutableLiveData() - val recorder: Recorder - val voiceRecordPlayingPosition = MutableLiveData() - var voiceRecordAudioFocusRequest: AudioFocusRequestCompat? = null + val imeFlags: Int = if (chatRoom.hasCapability(ChatRoomCapabilities.Encrypted.toInt())) { + // IME_FLAG_NO_PERSONALIZED_LEARNING is only available on Android 8 and newer + Compatibility.getImeFlagsForSecureChatRoom() + } else { + EditorInfo.IME_FLAG_NO_EXTRACT_UI + } + + private val recorder: Recorder + + private var voiceRecordAudioFocusRequest: AudioFocusRequestCompat? = null private lateinit var voiceRecordingPlayer: Player private val playerListener = PlayerListener { @@ -98,9 +107,19 @@ class ChatMessageSendingViewModel(private val chatRoom: ChatRoom) : ViewModel() stopVoiceRecordPlayer() } + private val chatRoomListener: ChatRoomListenerStub = object : ChatRoomListenerStub() { + override fun onStateChanged(chatRoom: ChatRoom, state: ChatRoom.State) { + if (state == ChatRoom.State.Created || state == ChatRoom.State.Terminated) { + isReadOnly.value = chatRoom.hasBeenLeft() + } + } + } + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) init { + chatRoom.addListener(chatRoomListener) + attachments.value = arrayListOf() attachFileEnabled.value = true @@ -118,6 +137,7 @@ class ChatMessageSendingViewModel(private val chatRoom: ChatRoom) : ViewModel() override fun onCleared() { attachments.value.orEmpty().forEach(ChatMessageAttachmentData::destroy) + pendingChatMessageToReplyTo.value?.destroy() if (recorder.state != RecorderState.Closed) { recorder.close() @@ -128,6 +148,7 @@ class ChatMessageSendingViewModel(private val chatRoom: ChatRoom) : ViewModel() voiceRecordingPlayer.removeListener(playerListener) } + chatRoom.removeListener(chatRoomListener) scope.cancel() super.onCleared() } @@ -418,8 +439,9 @@ class ChatMessageSendingViewModel(private val chatRoom: ChatRoom) : ViewModel() private fun initVoiceRecordPlayer() { Log.i("[Chat Message Sending] Creating player for voice record") - // Use speaker sound card to play recordings, otherwise use earpiece + // In case no headphones/headset is connected, use speaker sound card to play recordings, otherwise use earpiece // If none are available, default one will be used + var headphonesCard: String? = null var speakerCard: String? = null var earpieceCard: String? = null for (device in coreContext.core.audioDevices) { @@ -428,12 +450,14 @@ class ChatMessageSendingViewModel(private val chatRoom: ChatRoom) : ViewModel() speakerCard = device.id } else if (device.type == AudioDevice.Type.Earpiece) { earpieceCard = device.id + } else if (device.type == AudioDevice.Type.Headphones || device.type == AudioDevice.Type.Headset) { + headphonesCard = device.id } } } - Log.i("[Chat Message Sending] Found speaker sound card [$speakerCard] and earpiece sound card [$earpieceCard]") + Log.i("[Chat Message Sending] Found headset/headphones sound card [$headphonesCard], speaker sound card [$speakerCard] and earpiece sound card [$earpieceCard]") - val localPlayer = coreContext.core.createLocalPlayer(speakerCard ?: earpieceCard, null, null) + val localPlayer = coreContext.core.createLocalPlayer(headphonesCard ?: speakerCard ?: earpieceCard, null, null) if (localPlayer != null) { voiceRecordingPlayer = localPlayer } else { diff --git a/app/src/main/java/org/linphone/activities/main/chat/viewmodels/ChatMessagesListViewModel.kt b/app/src/main/java/org/linphone/activities/main/chat/viewmodels/ChatMessagesListViewModel.kt index 61a74bad6..8821cd8f1 100644 --- a/app/src/main/java/org/linphone/activities/main/chat/viewmodels/ChatMessagesListViewModel.kt +++ b/app/src/main/java/org/linphone/activities/main/chat/viewmodels/ChatMessagesListViewModel.kt @@ -158,27 +158,21 @@ class ChatMessagesListViewModel(private val chatRoom: ChatRoom) : ViewModel() { } fun deleteMessage(chatMessage: ChatMessage) { - val position: Int = chatMessage.userData as Int LinphoneUtils.deleteFilesAttachedToChatMessage(chatMessage) chatRoom.deleteMessage(chatMessage) - val list = arrayListOf() - list.addAll(events.value.orEmpty()) - list.removeAt(position) - events.value = list + events.value.orEmpty().forEach(EventLogData::destroy) + events.value = getEvents() } fun deleteEventLogs(listToDelete: ArrayList) { - val list = arrayListOf() - list.addAll(events.value.orEmpty()) - for (eventLog in listToDelete) { LinphoneUtils.deleteFilesAttachedToEventLog(eventLog.eventLog) eventLog.eventLog.deleteFromDatabase() - list.remove(eventLog) } - events.value = list + events.value.orEmpty().forEach(EventLogData::destroy) + events.value = getEvents() } fun loadMoreData(totalItemsCount: Int) { @@ -248,6 +242,8 @@ class ChatMessagesListViewModel(private val chatRoom: ChatRoom) : ViewModel() { LinphoneUtils.deleteFilesAttachedToChatMessage(chatMessage) chatRoom.deleteMessage(chatMessage) } + + events.value.orEmpty().forEach(EventLogData::destroy) events.value = getEvents() } } diff --git a/app/src/main/java/org/linphone/activities/main/chat/viewmodels/ChatRoomCreationViewModel.kt b/app/src/main/java/org/linphone/activities/main/chat/viewmodels/ChatRoomCreationViewModel.kt index 17cefca50..bb1cc356e 100644 --- a/app/src/main/java/org/linphone/activities/main/chat/viewmodels/ChatRoomCreationViewModel.kt +++ b/app/src/main/java/org/linphone/activities/main/chat/viewmodels/ChatRoomCreationViewModel.kt @@ -160,9 +160,9 @@ class ChatRoomCreationViewModel : ErrorReportingViewModel() { val encrypted = isEncrypted.value == true val params: ChatRoomParams = coreContext.core.createDefaultChatRoomParams() params.backend = ChatRoomBackend.Basic - params.enableGroup(false) + params.isGroupEnabled = false if (encrypted) { - params.enableEncryption(true) + params.isEncryptionEnabled = true params.backend = ChatRoomBackend.FlexisipChat params.ephemeralMode = if (corePreferences.useEphemeralPerDeviceMode) ChatRoomEphemeralMode.DeviceManaged diff --git a/app/src/main/java/org/linphone/activities/main/chat/viewmodels/ChatRoomViewModel.kt b/app/src/main/java/org/linphone/activities/main/chat/viewmodels/ChatRoomViewModel.kt index e91decdc7..b84b82090 100644 --- a/app/src/main/java/org/linphone/activities/main/chat/viewmodels/ChatRoomViewModel.kt +++ b/app/src/main/java/org/linphone/activities/main/chat/viewmodels/ChatRoomViewModel.kt @@ -74,6 +74,8 @@ class ChatRoomViewModel(val chatRoom: ChatRoom) : ViewModel(), ContactDataInterf val peerSipUri = MutableLiveData() + val ephemeralEnabled = MutableLiveData() + val oneToOneChatRoom: Boolean = chatRoom.hasCapability(ChatRoomCapabilities.OneToOne.toInt()) val encryptedChatRoom: Boolean = chatRoom.hasCapability(ChatRoomCapabilities.Encrypted.toInt()) @@ -88,12 +90,12 @@ class ChatRoomViewModel(val chatRoom: ChatRoom) : ViewModel(), ContactDataInterf var oneParticipantOneDevice: Boolean = false - var addressToCall: Address? = null - var onlyParticipantOnlyDeviceAddress: Address? = null val chatUnreadCountTranslateY = MutableLiveData() + private var addressToCall: Address? = null + private val bounceAnimator: ValueAnimator by lazy { ValueAnimator.ofFloat(AppUtils.getDimension(R.dimen.tabs_fragment_unread_count_bounce_offset), 0f).apply { addUpdateListener { @@ -111,6 +113,7 @@ class ChatRoomViewModel(val chatRoom: ChatRoom) : ViewModel(), ContactDataInterf override fun onContactsUpdated() { Log.i("[Chat Room] Contacts have changed") contactLookup() + updateLastMessageToDisplay() } } @@ -196,7 +199,11 @@ class ChatRoomViewModel(val chatRoom: ChatRoom) : ViewModel(), ContactDataInterf override fun onEphemeralMessageDeleted(chatRoom: ChatRoom, eventLog: EventLog) { Log.i("[Chat Room] Ephemeral message deleted, updated last message displayed") - lastMessageText.value = formatLastMessage(chatRoom.lastMessageInHistory) + updateLastMessageToDisplay() + } + + override fun onEphemeralEvent(chatRoom: ChatRoom, eventLog: EventLog) { + ephemeralEnabled.value = chatRoom.isEphemeralEnabled } override fun onParticipantAdminStatusChanged(chatRoom: ChatRoom, eventLog: EventLog) { @@ -209,16 +216,17 @@ class ChatRoomViewModel(val chatRoom: ChatRoom) : ViewModel(), ContactDataInterf chatRoom.addListener(chatRoomListener) coreContext.contactsManager.addListener(contactsUpdatedListener) - lastMessageText.value = formatLastMessage(chatRoom.lastMessageInHistory) unreadMessagesCount.value = chatRoom.unreadMessagesCount lastUpdate.value = TimestampUtils.toString(chatRoom.lastUpdateTime, true) subject.value = chatRoom.subject updateSecurityIcon() meAdmin.value = chatRoom.me?.isAdmin ?: false + ephemeralEnabled.value = chatRoom.isEphemeralEnabled contactLookup() updateParticipants() + updateLastMessageToDisplay() callInProgress.value = chatRoom.core.callsNb > 0 updateRemotesComposing() @@ -268,6 +276,10 @@ class ChatRoomViewModel(val chatRoom: ChatRoom) : ViewModel(), ContactDataInterf } } + fun updateLastMessageToDisplay() { + lastMessageText.value = formatLastMessage(chatRoom.lastMessageInHistory) + } + private fun formatLastMessage(msg: ChatMessage?): String { if (msg == null) return "" diff --git a/app/src/main/java/org/linphone/activities/main/chat/viewmodels/ChatRoomsListViewModel.kt b/app/src/main/java/org/linphone/activities/main/chat/viewmodels/ChatRoomsListViewModel.kt index 14f943db2..1e9982c5f 100644 --- a/app/src/main/java/org/linphone/activities/main/chat/viewmodels/ChatRoomsListViewModel.kt +++ b/app/src/main/java/org/linphone/activities/main/chat/viewmodels/ChatRoomsListViewModel.kt @@ -142,9 +142,7 @@ class ChatRoomsListViewModel : ErrorReportingViewModel() { } private fun updateChatRooms() { - for (chatRoomViewModel in chatRooms.value.orEmpty()) { - chatRoomViewModel.destroy() - } + chatRooms.value.orEmpty().forEach(ChatRoomViewModel::destroy) val list = arrayListOf() for (chatRoom in coreContext.core.chatRooms) { @@ -155,6 +153,14 @@ class ChatRoomsListViewModel : ErrorReportingViewModel() { } private fun addChatRoom(chatRoom: ChatRoom) { + val exists = chatRooms.value.orEmpty().find { + it.chatRoom.localAddress.weakEqual(chatRoom.localAddress) && it.chatRoom.peerAddress.weakEqual(chatRoom.peerAddress) + } + if (exists != null) { + Log.w("[Chat Rooms] Do not add chat room to list, it's already here") + return + } + val list = arrayListOf() val viewModel = ChatRoomViewModel(chatRoom) list.add(viewModel) @@ -170,12 +176,10 @@ class ChatRoomsListViewModel : ErrorReportingViewModel() { } private fun findChatRoomIndex(chatRoom: ChatRoom): Int { - var index = 0 - for (chatRoomViewModel in chatRooms.value.orEmpty()) { + for ((index, chatRoomViewModel) in chatRooms.value.orEmpty().withIndex()) { if (chatRoomViewModel.chatRoom == chatRoom) { return index } - index++ } return -1 } diff --git a/app/src/main/java/org/linphone/activities/main/chat/viewmodels/DevicesListViewModel.kt b/app/src/main/java/org/linphone/activities/main/chat/viewmodels/DevicesListViewModel.kt index 3f61d5497..625ed9419 100644 --- a/app/src/main/java/org/linphone/activities/main/chat/viewmodels/DevicesListViewModel.kt +++ b/app/src/main/java/org/linphone/activities/main/chat/viewmodels/DevicesListViewModel.kt @@ -59,16 +59,16 @@ class DevicesListViewModel(private val chatRoom: ChatRoom) : ViewModel() { init { chatRoom.addListener(listener) - updateParticipants() } override fun onCleared() { participants.value.orEmpty().forEach(DevicesListGroupData::destroy) chatRoom.removeListener(listener) + super.onCleared() } - private fun updateParticipants() { + fun updateParticipants() { participants.value.orEmpty().forEach(DevicesListGroupData::destroy) val list = arrayListOf() diff --git a/app/src/main/java/org/linphone/activities/main/chat/viewmodels/EphemeralViewModel.kt b/app/src/main/java/org/linphone/activities/main/chat/viewmodels/EphemeralViewModel.kt index 3704ef1ae..ce56f8034 100644 --- a/app/src/main/java/org/linphone/activities/main/chat/viewmodels/EphemeralViewModel.kt +++ b/app/src/main/java/org/linphone/activities/main/chat/viewmodels/EphemeralViewModel.kt @@ -50,8 +50,8 @@ class EphemeralViewModel(private val chatRoom: ChatRoom) : ViewModel() { } init { - Log.i("[Ephemeral Messages] Current lifetime is ${chatRoom.ephemeralLifetime}, ephemeral enabled? ${chatRoom.ephemeralEnabled()}") - currentSelectedDuration = if (chatRoom.ephemeralEnabled()) chatRoom.ephemeralLifetime else 0 + Log.i("[Ephemeral Messages] Current lifetime is ${chatRoom.ephemeralLifetime}, ephemeral enabled? ${chatRoom.isEphemeralEnabled}") + currentSelectedDuration = if (chatRoom.isEphemeralEnabled) chatRoom.ephemeralLifetime else 0 computeEphemeralDurationValues() } @@ -65,13 +65,13 @@ class EphemeralViewModel(private val chatRoom: ChatRoom) : ViewModel() { Log.i("[Ephemeral Messages] Configured lifetime for ephemeral messages was already $currentSelectedDuration") } - if (!chatRoom.ephemeralEnabled()) { + if (!chatRoom.isEphemeralEnabled) { Log.i("[Ephemeral Messages] Ephemeral messages were disabled, enable them") - chatRoom.enableEphemeral(true) + chatRoom.isEphemeralEnabled = true } - } else if (chatRoom.ephemeralEnabled()) { + } else if (chatRoom.isEphemeralEnabled) { Log.i("[Ephemeral Messages] Ephemeral messages were enabled, disable them") - chatRoom.enableEphemeral(false) + chatRoom.isEphemeralEnabled = false } } diff --git a/app/src/main/java/org/linphone/activities/main/chat/viewmodels/GroupInfoViewModel.kt b/app/src/main/java/org/linphone/activities/main/chat/viewmodels/GroupInfoViewModel.kt index aa5795d7a..7bfd94e48 100644 --- a/app/src/main/java/org/linphone/activities/main/chat/viewmodels/GroupInfoViewModel.kt +++ b/app/src/main/java/org/linphone/activities/main/chat/viewmodels/GroupInfoViewModel.kt @@ -117,8 +117,8 @@ class GroupInfoViewModel(val chatRoom: ChatRoom?) : ErrorReportingViewModel() { fun createChatRoom() { waitForChatRoomCreation.value = true val params: ChatRoomParams = coreContext.core.createDefaultChatRoomParams() - params.enableEncryption(isEncrypted.value == true) - params.enableGroup(true) + params.isEncryptionEnabled = isEncrypted.value == true + params.isGroupEnabled = true if (isEncrypted.value == true) { params.ephemeralMode = if (corePreferences.useEphemeralPerDeviceMode) ChatRoomEphemeralMode.DeviceManaged diff --git a/app/src/main/java/org/linphone/activities/main/chat/views/MultiLineWrapContentWidthTextView.kt b/app/src/main/java/org/linphone/activities/main/chat/views/MultiLineWrapContentWidthTextView.kt index 059f24c9c..f06828e54 100644 --- a/app/src/main/java/org/linphone/activities/main/chat/views/MultiLineWrapContentWidthTextView.kt +++ b/app/src/main/java/org/linphone/activities/main/chat/views/MultiLineWrapContentWidthTextView.kt @@ -21,6 +21,7 @@ package org.linphone.activities.main.chat.views import android.content.Context import android.text.Layout +import android.text.method.LinkMovementMethod import android.util.AttributeSet import androidx.appcompat.widget.AppCompatTextView import kotlin.math.ceil @@ -40,6 +41,12 @@ class MultiLineWrapContentWidthTextView : AppCompatTextView { defStyleAttr: Int ) : super(context, attrs, defStyleAttr) + override fun setText(text: CharSequence?, type: BufferType?) { + super.setText(text, type) + // Required for PatternClickableSpan + movementMethod = LinkMovementMethod.getInstance() + } + override fun onMeasure(widthSpec: Int, heightSpec: Int) { var wSpec = widthSpec val widthMode = MeasureSpec.getMode(wSpec) diff --git a/app/src/main/java/org/linphone/activities/main/chat/views/RichEditText.kt b/app/src/main/java/org/linphone/activities/main/chat/views/RichEditText.kt index f6978ce32..aa44cfc5c 100644 --- a/app/src/main/java/org/linphone/activities/main/chat/views/RichEditText.kt +++ b/app/src/main/java/org/linphone/activities/main/chat/views/RichEditText.kt @@ -22,6 +22,7 @@ package org.linphone.activities.main.chat.views import android.app.Activity import android.content.Context import android.util.AttributeSet +import android.view.KeyEvent import androidx.appcompat.widget.AppCompatEditText import androidx.core.view.ViewCompat import androidx.lifecycle.ViewModelProvider @@ -35,6 +36,10 @@ import org.linphone.utils.Event * Allows for image input inside an EditText, usefull for keyboards with gif support for example. */ class RichEditText : AppCompatEditText { + private var controlPressed = false + + private var sendListener: RichEditTextSendListener? = null + constructor(context: Context) : super(context) { initReceiveContentListener() } @@ -51,6 +56,10 @@ class RichEditText : AppCompatEditText { initReceiveContentListener() } + fun setControlEnterListener(listener: RichEditTextSendListener) { + sendListener = listener + } + private fun initReceiveContentListener() { ViewCompat.setOnReceiveContentListener( this, RichContentReceiver.MIME_TYPES, @@ -63,5 +72,25 @@ class RichEditText : AppCompatEditText { sharedViewModel.richContentUri.value = Event(uri) } ) + + setOnKeyListener { _, keyCode, event -> + if (keyCode == KeyEvent.KEYCODE_CTRL_LEFT) { + if (event.action == KeyEvent.ACTION_DOWN) { + controlPressed = true + } else if (event.action == KeyEvent.ACTION_UP) { + controlPressed = false + } + false + } else if (keyCode == KeyEvent.KEYCODE_ENTER && event.action == KeyEvent.ACTION_UP && controlPressed) { + sendListener?.onControlEnterPressedAndReleased() + true + } else { + false + } + } } } + +interface RichEditTextSendListener { + fun onControlEnterPressedAndReleased() +} diff --git a/app/src/main/java/org/linphone/activities/main/contact/adapters/ContactsListAdapter.kt b/app/src/main/java/org/linphone/activities/main/contact/adapters/ContactsListAdapter.kt index ee2e554cb..3772c35a4 100644 --- a/app/src/main/java/org/linphone/activities/main/contact/adapters/ContactsListAdapter.kt +++ b/app/src/main/java/org/linphone/activities/main/contact/adapters/ContactsListAdapter.kt @@ -71,15 +71,14 @@ class ContactsListAdapter( // This is for item selection through ListTopBarFragment selectionListViewModel = selectionViewModel selectionViewModel.isEditionEnabled.observe( - viewLifecycleOwner, - { - position = adapterPosition - } - ) + viewLifecycleOwner + ) { + position = bindingAdapterPosition + } setClickListener { if (selectionViewModel.isEditionEnabled.value == true) { - selectionViewModel.onToggleSelect(adapterPosition) + selectionViewModel.onToggleSelect(bindingAdapterPosition) } else { selectedContactEvent.value = Event(contactViewModel.contactInternal) } diff --git a/app/src/main/java/org/linphone/activities/main/contact/fragments/DetailContactFragment.kt b/app/src/main/java/org/linphone/activities/main/contact/fragments/DetailContactFragment.kt index 5c39ad78e..cf7384fae 100644 --- a/app/src/main/java/org/linphone/activities/main/contact/fragments/DetailContactFragment.kt +++ b/app/src/main/java/org/linphone/activities/main/contact/fragments/DetailContactFragment.kt @@ -83,47 +83,50 @@ class DetailContactFragment : GenericFragment() { binding.viewModel = viewModel viewModel.sendSmsToEvent.observe( - viewLifecycleOwner, - { - it.consume { number -> - sendSms(number) - } + viewLifecycleOwner + ) { + it.consume { number -> + sendSms(number) } - ) + } viewModel.startCallToEvent.observe( - viewLifecycleOwner, - { - it.consume { address -> - if (coreContext.core.callsNb > 0) { - Log.i("[Contact] Starting dialer with pre-filled URI ${address.asStringUriOnly()}, is transfer? ${sharedViewModel.pendingCallTransfer}") - sharedViewModel.updateContactsAnimationsBasedOnDestination.value = Event(R.id.dialerFragment) - sharedViewModel.updateDialerAnimationsBasedOnDestination.value = Event(R.id.masterContactsFragment) + viewLifecycleOwner + ) { + it.consume { address -> + if (coreContext.core.callsNb > 0) { + Log.i("[Contact] Starting dialer with pre-filled URI ${address.asStringUriOnly()}, is transfer? ${sharedViewModel.pendingCallTransfer}") + sharedViewModel.updateContactsAnimationsBasedOnDestination.value = + Event(R.id.dialerFragment) + sharedViewModel.updateDialerAnimationsBasedOnDestination.value = + Event(R.id.masterContactsFragment) - val args = Bundle() - args.putString("URI", address.asStringUriOnly()) - args.putBoolean("Transfer", sharedViewModel.pendingCallTransfer) - args.putBoolean("SkipAutoCallStart", true) // If auto start call setting is enabled, ignore it - navigateToDialer(args) - } else { - coreContext.startCall(address) - } + val args = Bundle() + args.putString("URI", address.asStringUriOnly()) + args.putBoolean("Transfer", sharedViewModel.pendingCallTransfer) + args.putBoolean( + "SkipAutoCallStart", + true + ) // If auto start call setting is enabled, ignore it + navigateToDialer(args) + } else { + coreContext.startCall(address) } } - ) + } viewModel.chatRoomCreatedEvent.observe( - viewLifecycleOwner, - { - it.consume { chatRoom -> - sharedViewModel.updateContactsAnimationsBasedOnDestination.value = Event(R.id.masterChatRoomsFragment) - val args = Bundle() - args.putString("LocalSipUri", chatRoom.localAddress.asStringUriOnly()) - args.putString("RemoteSipUri", chatRoom.peerAddress.asStringUriOnly()) - navigateToChatRoom(args) - } + viewLifecycleOwner + ) { + it.consume { chatRoom -> + sharedViewModel.updateContactsAnimationsBasedOnDestination.value = + Event(R.id.masterChatRoomsFragment) + val args = Bundle() + args.putString("LocalSipUri", chatRoom.localAddress.asStringUriOnly()) + args.putString("RemoteSipUri", chatRoom.peerAddress.asStringUriOnly()) + navigateToChatRoom(args) } - ) + } binding.setBackClickListener { goBack() @@ -138,13 +141,12 @@ class DetailContactFragment : GenericFragment() { } viewModel.onErrorEvent.observe( - viewLifecycleOwner, - { - it.consume { messageResourceId -> - (activity as MainActivity).showSnackBar(messageResourceId) - } + viewLifecycleOwner + ) { + it.consume { messageResourceId -> + (activity as MainActivity).showSnackBar(messageResourceId) } - ) + } view.doOnPreDraw { // Notifies fragment is ready to be drawn diff --git a/app/src/main/java/org/linphone/activities/main/contact/fragments/MasterContactsFragment.kt b/app/src/main/java/org/linphone/activities/main/contact/fragments/MasterContactsFragment.kt index ff87f2483..bc419400f 100644 --- a/app/src/main/java/org/linphone/activities/main/contact/fragments/MasterContactsFragment.kt +++ b/app/src/main/java/org/linphone/activities/main/contact/fragments/MasterContactsFragment.kt @@ -85,75 +85,59 @@ class MasterContactsFragment : MasterFragment - val forward = when (id) { - R.id.dialerFragment, R.id.masterChatRoomsFragment -> false - else -> true - } - if (corePreferences.enableAnimations) { - val portraitOrientation = resources.configuration.orientation != Configuration.ORIENTATION_LANDSCAPE - val axis = if (portraitOrientation) MaterialSharedAxis.X else MaterialSharedAxis.Y - enterTransition = MaterialSharedAxis(axis, forward) - reenterTransition = MaterialSharedAxis(axis, forward) - returnTransition = MaterialSharedAxis(axis, !forward) - exitTransition = MaterialSharedAxis(axis, !forward) - } + viewLifecycleOwner + ) { + it.consume { id -> + val forward = when (id) { + R.id.dialerFragment, R.id.masterChatRoomsFragment -> false + else -> true + } + if (corePreferences.enableAnimations) { + val portraitOrientation = + resources.configuration.orientation != Configuration.ORIENTATION_LANDSCAPE + val axis = + if (portraitOrientation) MaterialSharedAxis.X else MaterialSharedAxis.Y + enterTransition = MaterialSharedAxis(axis, forward) + reenterTransition = MaterialSharedAxis(axis, forward) + returnTransition = MaterialSharedAxis(axis, !forward) + exitTransition = MaterialSharedAxis(axis, !forward) } } - ) + } sharedViewModel.contactFragmentOpenedEvent.observe( - viewLifecycleOwner, - { - it.consume { - binding.slidingPane.openPane() - } + viewLifecycleOwner + ) { + it.consume { + binding.slidingPane.openPane() } - ) + } sharedViewModel.closeSlidingPaneEvent.observe( - viewLifecycleOwner, - { - it.consume { - if (!binding.slidingPane.closePane()) { - goBack() - } + viewLifecycleOwner + ) { + it.consume { + if (!binding.slidingPane.closePane()) { + goBack() } } - ) + } sharedViewModel.layoutChangedEvent.observe( - viewLifecycleOwner, - { - it.consume { - sharedViewModel.isSlidingPaneSlideable.value = binding.slidingPane.isSlideable - if (binding.slidingPane.isSlideable) { - val navHostFragment = childFragmentManager.findFragmentById(R.id.contacts_nav_container) as NavHostFragment - if (navHostFragment.navController.currentDestination?.id == R.id.emptyContactFragment) { - Log.i("[Contacts] Foldable device has been folded, closing side pane with empty fragment") - binding.slidingPane.closePane() - } + viewLifecycleOwner + ) { + it.consume { + sharedViewModel.isSlidingPaneSlideable.value = binding.slidingPane.isSlideable + if (binding.slidingPane.isSlideable) { + val navHostFragment = + childFragmentManager.findFragmentById(R.id.contacts_nav_container) as NavHostFragment + if (navHostFragment.navController.currentDestination?.id == R.id.emptyContactFragment) { + Log.i("[Contacts] Foldable device has been folded, closing side pane with empty fragment") + binding.slidingPane.closePane() } } } - ) + } binding.slidingPane.lockMode = SlidingPaneLayout.LOCK_MODE_LOCKED - /*binding.slidingPane.addPanelSlideListener(object : SlidingPaneLayout.PanelSlideListener { - override fun onPanelSlide(panel: View, slideOffset: Float) { } - - override fun onPanelOpened(panel: View) { - if (binding.slidingPane.isSlideable) { - (requireActivity() as MainActivity).hideTabsFragment() - } - } - - override fun onPanelClosed(panel: View) { - if (binding.slidingPane.isSlideable) { - (requireActivity() as MainActivity).showTabsFragment() - } - } - })*/ /* End of shared view model & sliding pane related */ @@ -190,13 +174,13 @@ class MasterContactsFragment : MasterFragment - Log.i("[Contacts] Selected item in list changed: $contact") - sharedViewModel.selectedContact.value = contact + viewLifecycleOwner + ) { + it.consume { contact -> + Log.i("[Contacts] Selected item in list changed: $contact") + sharedViewModel.selectedContact.value = contact - if (editOnClick) { - navigateToContactEditor(sipUriToAdd, binding.slidingPane) - editOnClick = false - sipUriToAdd = null - } else { - navigateToContact() - } + if (editOnClick) { + navigateToContactEditor(sipUriToAdd, binding.slidingPane) + editOnClick = false + sipUriToAdd = null + } else { + navigateToContact() } } - ) + } listViewModel.contactsList.observe( - viewLifecycleOwner, - { - val id = contactIdToDisplay - if (id != null) { - val contact = coreContext.contactsManager.findContactById(id) - if (contact != null) { - contactIdToDisplay = null - Log.i("[Contacts] Found matching contact $contact after callback") - adapter.selectedContactEvent.value = Event(contact) - } + viewLifecycleOwner + ) { + val id = contactIdToDisplay + if (id != null) { + val contact = coreContext.contactsManager.findContactById(id) + if (contact != null) { + contactIdToDisplay = null + Log.i("[Contacts] Found matching contact $contact after callback") + adapter.selectedContactEvent.value = Event(contact) } - adapter.submitList(it) } - ) + adapter.submitList(it) + } binding.setAllContactsToggleClickListener { listViewModel.sipContactsSelected.value = false @@ -264,18 +246,16 @@ class MasterContactsFragment : MasterFragment() { useMaterialSharedAxisXForwardAnimation = false sharedViewModel.updateDialerAnimationsBasedOnDestination.observe( - viewLifecycleOwner, - { - it.consume { id -> - val forward = when (id) { - R.id.masterChatRoomsFragment -> false - else -> true - } - if (corePreferences.enableAnimations) { - val portraitOrientation = resources.configuration.orientation != Configuration.ORIENTATION_LANDSCAPE - val axis = if (portraitOrientation) MaterialSharedAxis.X else MaterialSharedAxis.Y - enterTransition = MaterialSharedAxis(axis, forward) - reenterTransition = MaterialSharedAxis(axis, forward) - returnTransition = MaterialSharedAxis(axis, !forward) - exitTransition = MaterialSharedAxis(axis, !forward) - } + viewLifecycleOwner + ) { + it.consume { id -> + val forward = when (id) { + R.id.masterChatRoomsFragment -> false + else -> true + } + if (corePreferences.enableAnimations) { + val portraitOrientation = + resources.configuration.orientation != Configuration.ORIENTATION_LANDSCAPE + val axis = + if (portraitOrientation) MaterialSharedAxis.X else MaterialSharedAxis.Y + enterTransition = MaterialSharedAxis(axis, forward) + reenterTransition = MaterialSharedAxis(axis, forward) + returnTransition = MaterialSharedAxis(axis, !forward) + exitTransition = MaterialSharedAxis(axis, !forward) } } - ) + } binding.setNewContactClickListener { sharedViewModel.updateDialerAnimationsBasedOnDestination.value = Event(R.id.masterContactsFragment) @@ -108,6 +111,47 @@ class DialerFragment : SecureFragment() { } } + viewModel.enteredUri.observe( + viewLifecycleOwner + ) { + if (it == corePreferences.debugPopupCode) { + displayDebugPopup() + viewModel.enteredUri.value = "" + } + } + + viewModel.uploadFinishedEvent.observe( + viewLifecycleOwner + ) { + it.consume { url -> + // To prevent being trigger when using the Send Logs button in About page + if (uploadLogsInitiatedByUs) { + val clipboard = + requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("Logs url", url) + clipboard.setPrimaryClip(clip) + + val activity = requireActivity() as MainActivity + activity.showSnackBar(R.string.logs_url_copied_to_clipboard) + + AppUtils.shareUploadedLogsUrl(activity, url) + } + } + } + + viewModel.updateAvailableEvent.observe( + viewLifecycleOwner + ) { + it.consume { url -> + displayNewVersionAvailableDialog(url) + } + } + + if (corePreferences.firstStart) { + Log.w("[Dialer] First start detected, wait for assistant to be finished to check for update & request permissions") + return + } + if (arguments?.containsKey("Transfer") == true) { sharedViewModel.pendingCallTransfer = arguments?.getBoolean("Transfer") ?: false Log.i("[Dialer] Is pending call transfer: ${sharedViewModel.pendingCallTransfer}") @@ -127,45 +171,6 @@ class DialerFragment : SecureFragment() { } arguments?.clear() - viewModel.enteredUri.observe( - viewLifecycleOwner, - { - if (it == corePreferences.debugPopupCode) { - displayDebugPopup() - viewModel.enteredUri.value = "" - } - } - ) - - viewModel.uploadFinishedEvent.observe( - viewLifecycleOwner, - { - it.consume { url -> - // To prevent being trigger when using the Send Logs button in About page - if (uploadLogsInitiatedByUs) { - val clipboard = - requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText("Logs url", url) - clipboard.setPrimaryClip(clip) - - val activity = requireActivity() as MainActivity - activity.showSnackBar(R.string.logs_url_copied_to_clipboard) - - AppUtils.shareUploadedLogsUrl(activity, url) - } - } - } - ) - - viewModel.updateAvailableEvent.observe( - viewLifecycleOwner, - { - it.consume { url -> - displayNewVersionAvailableDialog(url) - } - } - ) - Log.i("[Dialer] Pending call transfer mode = ${sharedViewModel.pendingCallTransfer}") viewModel.transferVisibility.value = sharedViewModel.pendingCallTransfer @@ -205,18 +210,72 @@ class DialerFragment : SecureFragment() { Log.i("[Dialer] READ_PHONE_STATE permission has been granted") coreContext.initPhoneStateListener() } + checkTelecomManagerPermissions() + } else if (requestCode == 1) { + var allGranted = true + for (result in grantResults) { + if (result != PackageManager.PERMISSION_GRANTED) { + allGranted = false + } + } + if (allGranted) { + Log.i("[Dialer] Telecom Manager permission have been granted") + enableTelecomManager() + } else { + Log.w("[Dialer] Telecom Manager permission have been denied (at least one of them)") + } } super.onRequestPermissionsResult(requestCode, permissions, grantResults) } @TargetApi(Version.API23_MARSHMALLOW_60) private fun checkPermissions() { + checkReadPhoneStatePermission() + if (Version.sdkAboveOrEqual(Version.API26_O_80) && PermissionHelper.get().hasReadPhoneStatePermission()) { + // Don't check the following the previous permission is being asked + checkTelecomManagerPermissions() + } + } + + @TargetApi(Version.API23_MARSHMALLOW_60) + private fun checkReadPhoneStatePermission() { if (!PermissionHelper.get().hasReadPhoneStatePermission()) { Log.i("[Dialer] Asking for READ_PHONE_STATE permission") requestPermissions(arrayOf(Manifest.permission.READ_PHONE_STATE), 0) } } + @TargetApi(Version.API26_O_80) + private fun checkTelecomManagerPermissions() { + if (!corePreferences.useTelecomManager) { + Log.i("[Dialer] Telecom Manager feature is disabled") + if (corePreferences.manuallyDisabledTelecomManager) { + Log.w("[Dialer] User has manually disabled Telecom Manager feature") + } else { + if (Compatibility.hasTelecomManagerPermissions(requireContext())) { + enableTelecomManager() + } else { + Log.i("[Dialer] Asking for Telecom Manager permissions") + Compatibility.requestTelecomManagerPermissions(requireActivity(), 1) + } + } + } else { + Log.i("[Dialer] Telecom Manager feature is already enabled") + } + } + + @TargetApi(Version.API26_O_80) + private fun enableTelecomManager() { + Log.i("[Dialer] Telecom Manager permissions granted") + if (!TelecomHelper.exists()) { + Log.i("[Dialer] Creating Telecom Helper") + TelecomHelper.create(requireContext()) + } else { + Log.e("[Dialer] Telecom Manager was already created ?!") + } + corePreferences.useTelecomManager = true + } + private fun displayDebugPopup() { val alertDialog = MaterialAlertDialogBuilder(requireContext()) alertDialog.setTitle(getString(R.string.debug_popup_title)) diff --git a/app/src/main/java/org/linphone/activities/main/dialer/viewmodels/DialerViewModel.kt b/app/src/main/java/org/linphone/activities/main/dialer/viewmodels/DialerViewModel.kt index 04f9bdebe..964399d6b 100644 --- a/app/src/main/java/org/linphone/activities/main/dialer/viewmodels/DialerViewModel.kt +++ b/app/src/main/java/org/linphone/activities/main/dialer/viewmodels/DialerViewModel.kt @@ -21,7 +21,6 @@ package org.linphone.activities.main.dialer.viewmodels import android.content.Context import android.os.Vibrator -import android.provider.Settings import android.text.Editable import android.widget.EditText import androidx.lifecycle.MutableLiveData @@ -69,23 +68,8 @@ class DialerViewModel : LogsUploadViewModel() { } enteredUri.value = sb.toString() - if (coreContext.core.callsNb == 0) { - val contentResolver = coreContext.context.contentResolver - try { - if (Settings.System.getInt( - contentResolver, - Settings.System.DTMF_TONE_WHEN_DIALING - ) == 1 - ) { - coreContext.core.playDtmf(key, 1) - - if (vibrator.hasVibrator() && corePreferences.dtmfKeypadVibration) { - Compatibility.eventVibration(vibrator) - } - } - } catch (snfe: Settings.SettingNotFoundException) { - Log.e("[Dialer] Can't play DTMF: $snfe") - } + if (vibrator.hasVibrator() && corePreferences.dtmfKeypadVibration) { + Compatibility.eventVibration(vibrator) } } @@ -176,7 +160,7 @@ class DialerViewModel : LogsUploadViewModel() { fun updateShowVideoPreview() { val videoPreview = corePreferences.videoPreview showPreview.value = videoPreview - coreContext.core.enableVideoPreview(videoPreview) + coreContext.core.isVideoPreviewEnabled = videoPreview } fun eraseLastChar() { diff --git a/app/src/main/java/org/linphone/activities/main/files/fragments/GenericViewerFragment.kt b/app/src/main/java/org/linphone/activities/main/files/fragments/GenericViewerFragment.kt index 661dd21cc..7367813fa 100644 --- a/app/src/main/java/org/linphone/activities/main/files/fragments/GenericViewerFragment.kt +++ b/app/src/main/java/org/linphone/activities/main/files/fragments/GenericViewerFragment.kt @@ -56,4 +56,8 @@ abstract class GenericViewerFragment : SecureFragment() (childFragmentManager.findFragmentById(R.id.top_bar_fragment) as? TopBarFragment) ?.setContent(content) } + + override fun goBack() { + findNavController().popBackStack() + } } diff --git a/app/src/main/java/org/linphone/activities/main/files/fragments/TopBarFragment.kt b/app/src/main/java/org/linphone/activities/main/files/fragments/TopBarFragment.kt index 0699df0a1..f8987756d 100644 --- a/app/src/main/java/org/linphone/activities/main/files/fragments/TopBarFragment.kt +++ b/app/src/main/java/org/linphone/activities/main/files/fragments/TopBarFragment.kt @@ -21,13 +21,21 @@ package org.linphone.activities.main.files.fragments import android.os.Bundle import android.view.View +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.linphone.R import org.linphone.activities.GenericFragment import org.linphone.activities.SnackBarActivity +import org.linphone.compatibility.Compatibility import org.linphone.core.Content import org.linphone.core.tools.Log import org.linphone.databinding.FileViewerTopBarFragmentBinding +import org.linphone.mediastream.Version import org.linphone.utils.FileUtils +import org.linphone.utils.PermissionHelper class TopBarFragment : GenericFragment() { private var content: Content? = null @@ -46,20 +54,9 @@ class TopBarFragment : GenericFragment() { } binding.setExportClickListener { - if (content != null) { - val filePath = content?.plainFilePath.orEmpty() - plainFilePath = if (filePath.isEmpty()) content?.filePath.orEmpty() else filePath - Log.i("[File Viewer] Plain file path is: $plainFilePath") - if (plainFilePath.isNotEmpty()) { - if (!FileUtils.openFileInThirdPartyApp(requireActivity(), plainFilePath)) { - (requireActivity() as SnackBarActivity).showSnackBar(R.string.chat_message_no_app_found_to_handle_file_mime_type) - if (plainFilePath != content?.filePath.orEmpty()) { - Log.i("[File Viewer] No app to open plain file path: $plainFilePath, destroying it") - FileUtils.deleteFile(plainFilePath) - } - plainFilePath = "" - } - } + val contentToExport = content + if (contentToExport != null) { + exportContent(contentToExport) } else { Log.e("[File Viewer] No Content set!") } @@ -89,4 +86,77 @@ class TopBarFragment : GenericFragment() { content = c binding.fileName.text = c.name } + + private fun exportContent(content: Content) { + lifecycleScope.launch { + var mediaStoreFilePath = "" + if (Version.sdkAboveOrEqual(Version.API29_ANDROID_10) || PermissionHelper.get().hasWriteExternalStoragePermission()) { + Log.i("[File Viewer] Exporting image through Media Store API") + when (content.type) { + "image" -> { + val export = lifecycleScope.async { + Compatibility.addImageToMediaStore(requireContext(), content) + } + if (export.await()) { + Log.i("[File Viewer] Adding image ${content.name} to Media Store terminated: ${content.userData}") + mediaStoreFilePath = content.userData.toString() + } else { + Log.e("[File Viewer] Something went wrong while copying file to Media Store...") + } + } + "video" -> { + val export = lifecycleScope.async { + Compatibility.addVideoToMediaStore(requireContext(), content) + } + if (export.await()) { + Log.i("[File Viewer] Adding video ${content.name} to Media Store terminated: ${content.userData}") + mediaStoreFilePath = content.userData.toString() + } else { + Log.e("[File Viewer] Something went wrong while copying file to Media Store...") + } + } + "audio" -> { + val export = lifecycleScope.async { + Compatibility.addAudioToMediaStore(requireContext(), content) + } + if (export.await()) { + Log.i("[File Viewer] Adding audio ${content.name} to Media Store terminated: ${content.userData}") + mediaStoreFilePath = content.userData.toString() + } else { + Log.e("[File Viewer] Something went wrong while copying file to Media Store...") + } + } + else -> { + Log.w("[File Viewer] File ${content.name} isn't either an image, an audio file or a video, can't add it to the Media Store") + } + } + } else { + Log.w("[File Viewer] Can't export image through Media Store API (requires Android 10 or WRITE_EXTERNAL permission, using fallback method...") + } + + withContext(Dispatchers.Main) { + if (mediaStoreFilePath.isEmpty()) { + Log.w("[File Viewer] Media store file path is empty, media store export failed?") + + val filePath = content.plainFilePath.orEmpty() + plainFilePath = filePath.ifEmpty { content.filePath.orEmpty() } + Log.i("[File Viewer] Plain file path is: $plainFilePath") + if (plainFilePath.isNotEmpty()) { + if (!FileUtils.openFileInThirdPartyApp(requireActivity(), plainFilePath)) { + (requireActivity() as SnackBarActivity).showSnackBar(R.string.chat_message_no_app_found_to_handle_file_mime_type) + if (plainFilePath != content.filePath.orEmpty()) { + Log.i("[File Viewer] No app to open plain file path: $plainFilePath, destroying it") + FileUtils.deleteFile(plainFilePath) + } + plainFilePath = "" + } + } + } else { + plainFilePath = "" + Log.i("[File Viewer] Media store file path is: $mediaStoreFilePath") + FileUtils.openMediaStoreFile(requireActivity(), mediaStoreFilePath) + } + } + } + } } diff --git a/app/src/main/java/org/linphone/activities/main/files/viewmodels/FileViewerViewModel.kt b/app/src/main/java/org/linphone/activities/main/files/viewmodels/FileViewerViewModel.kt index 8de31a6ce..45db338c4 100644 --- a/app/src/main/java/org/linphone/activities/main/files/viewmodels/FileViewerViewModel.kt +++ b/app/src/main/java/org/linphone/activities/main/files/viewmodels/FileViewerViewModel.kt @@ -29,7 +29,12 @@ open class FileViewerViewModel(val content: Content) : ViewModel() { private val deleteAfterUse: Boolean = content.isFileEncrypted init { - filePath = if (deleteAfterUse) content.plainFilePath else content.filePath.orEmpty() + filePath = if (deleteAfterUse) { + Log.i("[File Viewer] Content is encrypted, requesting plain file path") + content.plainFilePath + } else { + content.filePath.orEmpty() + } } override fun onCleared() { diff --git a/app/src/main/java/org/linphone/activities/main/fragments/MasterFragment.kt b/app/src/main/java/org/linphone/activities/main/fragments/MasterFragment.kt index a6f86e180..c342b2a9a 100644 --- a/app/src/main/java/org/linphone/activities/main/fragments/MasterFragment.kt +++ b/app/src/main/java/org/linphone/activities/main/fragments/MasterFragment.kt @@ -56,56 +56,55 @@ abstract class MasterFragment() { } sharedViewModel.accountRemoved.observe( - viewLifecycleOwner, - { - Log.i("[Status Fragment] An account was removed, update default account state") - val defaultAccount = coreContext.core.defaultAccount - if (defaultAccount != null) { - viewModel.updateDefaultAccountRegistrationStatus(defaultAccount.state) - } + viewLifecycleOwner + ) { + Log.i("[Status Fragment] An account was removed, update default account state") + val defaultAccount = coreContext.core.defaultAccount + if (defaultAccount != null) { + viewModel.updateDefaultAccountRegistrationStatus(defaultAccount.state) } - ) + } binding.setMenuClickListener { sharedViewModel.toggleDrawerEvent.value = Event(true) diff --git a/app/src/main/java/org/linphone/activities/main/history/adapters/CallLogsListAdapter.kt b/app/src/main/java/org/linphone/activities/main/history/adapters/CallLogsListAdapter.kt index ae1e80203..ac0194cc1 100644 --- a/app/src/main/java/org/linphone/activities/main/history/adapters/CallLogsListAdapter.kt +++ b/app/src/main/java/org/linphone/activities/main/history/adapters/CallLogsListAdapter.kt @@ -65,7 +65,7 @@ class CallLogsListAdapter( ) : RecyclerView.ViewHolder(binding.root) { fun bind(callLogGroup: GroupedCallLogData) { with(binding) { - val callLogViewModel = callLogGroup.lastCallLogViewModel + val callLogViewModel = callLogGroup.lastCallLogData viewModel = callLogViewModel lifecycleOwner = viewLifecycleOwner @@ -73,15 +73,14 @@ class CallLogsListAdapter( // This is for item selection through ListTopBarFragment selectionListViewModel = selectionViewModel selectionViewModel.isEditionEnabled.observe( - viewLifecycleOwner, - { - position = adapterPosition - } - ) + viewLifecycleOwner + ) { + position = bindingAdapterPosition + } setClickListener { if (selectionViewModel.isEditionEnabled.value == true) { - selectionViewModel.onToggleSelect(adapterPosition) + selectionViewModel.onToggleSelect(bindingAdapterPosition) } else { startCallToEvent.value = Event(callLogGroup) } diff --git a/app/src/main/java/org/linphone/activities/main/history/data/CallLogData.kt b/app/src/main/java/org/linphone/activities/main/history/data/CallLogData.kt new file mode 100644 index 000000000..72483c694 --- /dev/null +++ b/app/src/main/java/org/linphone/activities/main/history/data/CallLogData.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2010-2021 Belledonne Communications SARL. + * + * This file is part of linphone-android + * (see https://www.linphone.org). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.linphone.activities.main.history.data + +import java.text.SimpleDateFormat +import java.util.* +import org.linphone.R +import org.linphone.contact.GenericContactData +import org.linphone.core.Call +import org.linphone.core.CallLog +import org.linphone.utils.TimestampUtils + +class CallLogData(callLog: CallLog) : GenericContactData(callLog.remoteAddress) { + val statusIconResource: Int by lazy { + if (callLog.dir == Call.Dir.Incoming) { + if (callLog.status == Call.Status.Missed) { + R.drawable.call_status_missed + } else { + R.drawable.call_status_incoming + } + } else { + R.drawable.call_status_outgoing + } + } + + val iconContentDescription: Int by lazy { + if (callLog.dir == Call.Dir.Incoming) { + if (callLog.status == Call.Status.Missed) { + R.string.content_description_missed_call + } else { + R.string.content_description_incoming_call + } + } else { + R.string.content_description_outgoing_call + } + } + + val directionIconResource: Int by lazy { + if (callLog.dir == Call.Dir.Incoming) { + if (callLog.status == Call.Status.Missed) { + R.drawable.call_missed + } else { + R.drawable.call_incoming + } + } else { + R.drawable.call_outgoing + } + } + + val duration: String by lazy { + val dateFormat = SimpleDateFormat(if (callLog.duration >= 3600) "HH:mm:ss" else "mm:ss", Locale.getDefault()) + val cal = Calendar.getInstance() + cal[0, 0, 0, 0, 0] = callLog.duration + dateFormat.format(cal.time) + } + + val date: String by lazy { + TimestampUtils.toString(callLog.startDate, shortDate = false, hideYear = false) + } +} diff --git a/app/src/main/java/org/linphone/activities/main/history/data/GroupedCallLogData.kt b/app/src/main/java/org/linphone/activities/main/history/data/GroupedCallLogData.kt index 89b0d1716..ea76e8fe8 100644 --- a/app/src/main/java/org/linphone/activities/main/history/data/GroupedCallLogData.kt +++ b/app/src/main/java/org/linphone/activities/main/history/data/GroupedCallLogData.kt @@ -19,15 +19,14 @@ */ package org.linphone.activities.main.history.data -import org.linphone.activities.main.history.viewmodels.CallLogViewModel import org.linphone.core.CallLog class GroupedCallLogData(callLog: CallLog) { var lastCallLog: CallLog = callLog val callLogs = arrayListOf(callLog) - val lastCallLogViewModel = CallLogViewModel(lastCallLog) + val lastCallLogData = CallLogData(lastCallLog) fun destroy() { - lastCallLogViewModel.destroy() + lastCallLogData.destroy() } } diff --git a/app/src/main/java/org/linphone/activities/main/history/fragments/DetailCallLogFragment.kt b/app/src/main/java/org/linphone/activities/main/history/fragments/DetailCallLogFragment.kt index dc2599280..825921125 100644 --- a/app/src/main/java/org/linphone/activities/main/history/fragments/DetailCallLogFragment.kt +++ b/app/src/main/java/org/linphone/activities/main/history/fragments/DetailCallLogFragment.kt @@ -70,7 +70,7 @@ class DetailCallLogFragment : GenericFragment() { useMaterialSharedAxisXForwardAnimation = sharedViewModel.isSlidingPaneSlideable.value == false - viewModel.relatedCallLogs.value = callLogGroup.callLogs + viewModel.addRelatedCallLogs(callLogGroup.callLogs) binding.setBackClickListener { goBack() @@ -99,50 +99,48 @@ class DetailCallLogFragment : GenericFragment() { } viewModel.startCallEvent.observe( - viewLifecycleOwner, - { - it.consume { callLog -> - val address = callLog.remoteAddress - if (coreContext.core.callsNb > 0) { - Log.i("[History] Starting dialer with pre-filled URI ${address.asStringUriOnly()}, is transfer? ${sharedViewModel.pendingCallTransfer}") - sharedViewModel.updateDialerAnimationsBasedOnDestination.value = Event(R.id.masterCallLogsFragment) + viewLifecycleOwner + ) { + it.consume { callLog -> + val address = callLog.remoteAddress + if (coreContext.core.callsNb > 0) { + Log.i("[History] Starting dialer with pre-filled URI ${address.asStringUriOnly()}, is transfer? ${sharedViewModel.pendingCallTransfer}") + sharedViewModel.updateDialerAnimationsBasedOnDestination.value = + Event(R.id.masterCallLogsFragment) - val args = Bundle() - args.putString("URI", address.asStringUriOnly()) - args.putBoolean("Transfer", sharedViewModel.pendingCallTransfer) - args.putBoolean( - "SkipAutoCallStart", - true - ) // If auto start call setting is enabled, ignore it - navigateToDialer(args) - } else { - val localAddress = callLog.localAddress - coreContext.startCall(address, localAddress = localAddress) - } + val args = Bundle() + args.putString("URI", address.asStringUriOnly()) + args.putBoolean("Transfer", sharedViewModel.pendingCallTransfer) + args.putBoolean( + "SkipAutoCallStart", + true + ) // If auto start call setting is enabled, ignore it + navigateToDialer(args) + } else { + val localAddress = callLog.localAddress + coreContext.startCall(address, localAddress = localAddress) } } - ) + } viewModel.chatRoomCreatedEvent.observe( - viewLifecycleOwner, - { - it.consume { chatRoom -> - val args = Bundle() - args.putString("LocalSipUri", chatRoom.localAddress.asStringUriOnly()) - args.putString("RemoteSipUri", chatRoom.peerAddress.asStringUriOnly()) - navigateToChatRoom(args) - } + viewLifecycleOwner + ) { + it.consume { chatRoom -> + val args = Bundle() + args.putString("LocalSipUri", chatRoom.localAddress.asStringUriOnly()) + args.putString("RemoteSipUri", chatRoom.peerAddress.asStringUriOnly()) + navigateToChatRoom(args) } - ) + } viewModel.onErrorEvent.observe( - viewLifecycleOwner, - { - it.consume { messageResourceId -> - (activity as MainActivity).showSnackBar(messageResourceId) - } + viewLifecycleOwner + ) { + it.consume { messageResourceId -> + (activity as MainActivity).showSnackBar(messageResourceId) } - ) + } } override fun goBack() { diff --git a/app/src/main/java/org/linphone/activities/main/history/fragments/MasterCallLogsFragment.kt b/app/src/main/java/org/linphone/activities/main/history/fragments/MasterCallLogsFragment.kt index b597d21e4..bbd2e0cfa 100644 --- a/app/src/main/java/org/linphone/activities/main/history/fragments/MasterCallLogsFragment.kt +++ b/app/src/main/java/org/linphone/activities/main/history/fragments/MasterCallLogsFragment.kt @@ -101,46 +101,30 @@ class MasterCallLogsFragment : MasterFragment - if (listViewModel.missedCallLogsSelected.value == false) { - adapter.submitList(callLogs) - } + viewLifecycleOwner + ) { callLogs -> + if (listViewModel.missedCallLogsSelected.value == false) { + adapter.submitList(callLogs) } - ) + } listViewModel.missedCallLogs.observe( - viewLifecycleOwner, - { callLogs -> - if (listViewModel.missedCallLogsSelected.value == true) { - adapter.submitList(callLogs) - } + viewLifecycleOwner + ) { callLogs -> + if (listViewModel.missedCallLogsSelected.value == true) { + adapter.submitList(callLogs) } - ) + } listViewModel.missedCallLogsSelected.observe( - viewLifecycleOwner, - { - if (it) { - adapter.submitList(listViewModel.missedCallLogs.value) - } else { - adapter.submitList(listViewModel.callLogs.value) - } + viewLifecycleOwner + ) { + if (it) { + adapter.submitList(listViewModel.missedCallLogs.value) + } else { + adapter.submitList(listViewModel.callLogs.value) } - ) + } listViewModel.contactsUpdatedEvent.observe( - viewLifecycleOwner, - { - it.consume { - adapter.notifyDataSetChanged() - } + viewLifecycleOwner + ) { + it.consume { + adapter.notifyDataSetChanged() } - ) + } adapter.selectedCallLogEvent.observe( - viewLifecycleOwner, - { - it.consume { callLog -> - sharedViewModel.selectedCallLogGroup.value = callLog - navigateToCallHistory(binding.slidingPane) - } + viewLifecycleOwner + ) { + it.consume { callLog -> + sharedViewModel.selectedCallLogGroup.value = callLog + navigateToCallHistory(binding.slidingPane) } - ) + } adapter.startCallToEvent.observe( - viewLifecycleOwner, - { - it.consume { callLogGroup -> - val remoteAddress = callLogGroup.lastCallLog.remoteAddress - if (coreContext.core.callsNb > 0) { - Log.i("[History] Starting dialer with pre-filled URI ${remoteAddress.asStringUriOnly()}, is transfer? ${sharedViewModel.pendingCallTransfer}") - sharedViewModel.updateDialerAnimationsBasedOnDestination.value = Event(R.id.masterCallLogsFragment) - val args = Bundle() - args.putString("URI", remoteAddress.asStringUriOnly()) - args.putBoolean("Transfer", sharedViewModel.pendingCallTransfer) - args.putBoolean("SkipAutoCallStart", true) // If auto start call setting is enabled, ignore it - navigateToDialer(args) - } else { - val localAddress = callLogGroup.lastCallLog.localAddress - coreContext.startCall(remoteAddress, localAddress = localAddress) - } + viewLifecycleOwner + ) { + it.consume { callLogGroup -> + val remoteAddress = callLogGroup.lastCallLog.remoteAddress + if (coreContext.core.callsNb > 0) { + Log.i("[History] Starting dialer with pre-filled URI ${remoteAddress.asStringUriOnly()}, is transfer? ${sharedViewModel.pendingCallTransfer}") + sharedViewModel.updateDialerAnimationsBasedOnDestination.value = + Event(R.id.masterCallLogsFragment) + val args = Bundle() + args.putString("URI", remoteAddress.asStringUriOnly()) + args.putBoolean("Transfer", sharedViewModel.pendingCallTransfer) + // If auto start call setting is enabled, ignore it + args.putBoolean("SkipAutoCallStart", true) + navigateToDialer(args) + } else { + val localAddress = callLogGroup.lastCallLog.localAddress + coreContext.startCall(remoteAddress, localAddress = localAddress) } } - ) + } binding.setAllCallLogsToggleClickListener { listViewModel.missedCallLogsSelected.value = false diff --git a/app/src/main/java/org/linphone/activities/main/history/viewmodels/CallLogViewModel.kt b/app/src/main/java/org/linphone/activities/main/history/viewmodels/CallLogViewModel.kt index f5529312a..a5cabce8b 100644 --- a/app/src/main/java/org/linphone/activities/main/history/viewmodels/CallLogViewModel.kt +++ b/app/src/main/java/org/linphone/activities/main/history/viewmodels/CallLogViewModel.kt @@ -22,17 +22,16 @@ package org.linphone.activities.main.history.viewmodels import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider -import java.text.SimpleDateFormat -import java.util.* import kotlin.collections.ArrayList +import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.LinphoneApplication.Companion.corePreferences import org.linphone.R +import org.linphone.activities.main.history.data.CallLogData import org.linphone.contact.GenericContactViewModel import org.linphone.core.* import org.linphone.core.tools.Log import org.linphone.utils.Event import org.linphone.utils.LinphoneUtils -import org.linphone.utils.TimestampUtils class CallLogViewModelFactory(private val callLog: CallLog) : ViewModelProvider.NewInstanceFactory() { @@ -48,53 +47,6 @@ class CallLogViewModel(val callLog: CallLog) : GenericContactViewModel(callLog.r LinphoneUtils.getDisplayableAddress(callLog.remoteAddress) } - val statusIconResource: Int by lazy { - if (callLog.dir == Call.Dir.Incoming) { - if (callLog.status == Call.Status.Missed) { - R.drawable.call_status_missed - } else { - R.drawable.call_status_incoming - } - } else { - R.drawable.call_status_outgoing - } - } - - val iconContentDescription: Int by lazy { - if (callLog.dir == Call.Dir.Incoming) { - if (callLog.status == Call.Status.Missed) { - R.string.content_description_missed_call - } else { - R.string.content_description_incoming_call - } - } else { - R.string.content_description_outgoing_call - } - } - - val directionIconResource: Int by lazy { - if (callLog.dir == Call.Dir.Incoming) { - if (callLog.status == Call.Status.Missed) { - R.drawable.call_missed - } else { - R.drawable.call_incoming - } - } else { - R.drawable.call_outgoing - } - } - - val duration: String by lazy { - val dateFormat = SimpleDateFormat(if (callLog.duration >= 3600) "HH:mm:ss" else "mm:ss", Locale.getDefault()) - val cal = Calendar.getInstance() - cal[0, 0, 0, 0, 0] = callLog.duration - dateFormat.format(cal.time) - } - - val date: String by lazy { - TimestampUtils.toString(callLog.startDate, shortDate = false, hideYear = false) - } - val startCallEvent: MutableLiveData> by lazy { MutableLiveData>() } @@ -109,7 +61,16 @@ class CallLogViewModel(val callLog: CallLog) : GenericContactViewModel(callLog.r val secureChatAllowed = contact.value?.friend?.getPresenceModelForUriOrTel(peerSipUri)?.hasCapability(FriendCapability.LimeX3Dh) ?: false - val relatedCallLogs = MutableLiveData>() + val relatedCallLogs = MutableLiveData>() + + private val listener = object : CoreListenerStub() { + override fun onCallLogUpdated(core: Core, log: CallLog) { + if (callLog.remoteAddress.weakEqual(log.remoteAddress) && callLog.localAddress.weakEqual(log.localAddress)) { + Log.i("[History Detail] New call log for ${callLog.remoteAddress.asStringUriOnly()} with local address ${callLog.localAddress.asStringUriOnly()}") + addRelatedCallLogs(arrayListOf(log)) + } + } + } private val chatRoomListener = object : ChatRoomListenerStub() { override fun onStateChanged(chatRoom: ChatRoom, state: ChatRoom.State) { @@ -126,14 +87,19 @@ class CallLogViewModel(val callLog: CallLog) : GenericContactViewModel(callLog.r init { waitForChatRoomCreation.value = false + + coreContext.core.addListener(listener) } override fun onCleared() { + coreContext.core.removeListener(listener) destroy() + super.onCleared() } fun destroy() { + relatedCallLogs.value.orEmpty().forEach(CallLogData::destroy) } fun startCall() { @@ -157,11 +123,15 @@ class CallLogViewModel(val callLog: CallLog) : GenericContactViewModel(callLog.r } } - fun getCallsHistory(): ArrayList { - val callsHistory = ArrayList() - for (callLog in relatedCallLogs.value.orEmpty()) { - callsHistory.add(CallLogViewModel(callLog)) + fun addRelatedCallLogs(logs: ArrayList) { + val callsHistory = ArrayList() + + // We assume new logs are more recent than the ones we already have, so we add them first + for (log in logs) { + callsHistory.add(CallLogData(log)) } - return callsHistory + callsHistory.addAll(relatedCallLogs.value.orEmpty()) + + relatedCallLogs.value = callsHistory } } diff --git a/app/src/main/java/org/linphone/activities/main/recordings/adapters/RecordingsListAdapter.kt b/app/src/main/java/org/linphone/activities/main/recordings/adapters/RecordingsListAdapter.kt index 43b2c48ad..839271c38 100644 --- a/app/src/main/java/org/linphone/activities/main/recordings/adapters/RecordingsListAdapter.kt +++ b/app/src/main/java/org/linphone/activities/main/recordings/adapters/RecordingsListAdapter.kt @@ -69,12 +69,12 @@ class RecordingsListAdapter( lifecycleOwner = viewLifecycleOwner // This is for item selection through ListTopBarFragment - position = adapterPosition + position = bindingAdapterPosition selectionListViewModel = selectionViewModel setClickListener { if (selectionViewModel.isEditionEnabled.value == true) { - selectionViewModel.onToggleSelect(adapterPosition) + selectionViewModel.onToggleSelect(bindingAdapterPosition) } } diff --git a/app/src/main/java/org/linphone/activities/main/recordings/data/RecordingData.kt b/app/src/main/java/org/linphone/activities/main/recordings/data/RecordingData.kt index d9093af29..fa3e9ced2 100644 --- a/app/src/main/java/org/linphone/activities/main/recordings/data/RecordingData.kt +++ b/app/src/main/java/org/linphone/activities/main/recordings/data/RecordingData.kt @@ -164,8 +164,9 @@ class RecordingData(val path: String, private val recordingListener: RecordingLi } private fun initPlayer() { - // Use speaker sound card to play recordings, otherwise use earpiece + // In case no headphones/headset is connected, use speaker sound card to play recordings, otherwise use earpiece // If none are available, default one will be used + var headphonesCard: String? = null var speakerCard: String? = null var earpieceCard: String? = null for (device in coreContext.core.audioDevices) { @@ -174,11 +175,14 @@ class RecordingData(val path: String, private val recordingListener: RecordingLi speakerCard = device.id } else if (device.type == AudioDevice.Type.Earpiece) { earpieceCard = device.id + } else if (device.type == AudioDevice.Type.Headphones || device.type == AudioDevice.Type.Headset) { + headphonesCard = device.id } } } + Log.i("[Recording VM] Found headset/headphones sound card [$headphonesCard], speaker sound card [$speakerCard] and earpiece sound card [$earpieceCard]") - val localPlayer = coreContext.core.createLocalPlayer(speakerCard ?: earpieceCard, null, null) + val localPlayer = coreContext.core.createLocalPlayer(headphonesCard ?: speakerCard ?: earpieceCard, null, null) if (localPlayer != null) player = localPlayer else Log.e("[Recording VM] Couldn't create local player!") player.addListener(listener) diff --git a/app/src/main/java/org/linphone/activities/main/recordings/fragments/RecordingsFragment.kt b/app/src/main/java/org/linphone/activities/main/recordings/fragments/RecordingsFragment.kt index 582debc2d..d82ede726 100644 --- a/app/src/main/java/org/linphone/activities/main/recordings/fragments/RecordingsFragment.kt +++ b/app/src/main/java/org/linphone/activities/main/recordings/fragments/RecordingsFragment.kt @@ -29,6 +29,7 @@ import org.linphone.activities.main.fragments.MasterFragment import org.linphone.activities.main.recordings.adapters.RecordingsListAdapter import org.linphone.activities.main.recordings.data.RecordingData import org.linphone.activities.main.recordings.viewmodels.RecordingsViewModel +import org.linphone.core.tools.Log import org.linphone.databinding.RecordingsFragmentBinding import org.linphone.utils.AppUtils import org.linphone.utils.RecyclerViewHeaderDecoration @@ -69,11 +70,10 @@ class RecordingsFragment : MasterFragment - adapter.submitList(recordings) - } - ) + viewLifecycleOwner + ) { recordings -> + adapter.submitList(recordings) + } binding.setBackClickListener { goBack() } @@ -111,4 +111,13 @@ class RecordingsFragment : MasterFragment() diff --git a/app/src/main/java/org/linphone/activities/main/settings/fragments/AccountSettingsFragment.kt b/app/src/main/java/org/linphone/activities/main/settings/fragments/AccountSettingsFragment.kt index df8623f9c..cb18d3a35 100644 --- a/app/src/main/java/org/linphone/activities/main/settings/fragments/AccountSettingsFragment.kt +++ b/app/src/main/java/org/linphone/activities/main/settings/fragments/AccountSettingsFragment.kt @@ -63,32 +63,30 @@ class AccountSettingsFragment : GenericSettingFragment - val clipboard = - requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText("Logs url", url) - clipboard.setPrimaryClip(clip) + viewLifecycleOwner + ) { + it.consume { url -> + val clipboard = + requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("Logs url", url) + clipboard.setPrimaryClip(clip) - val activity = requireActivity() as MainActivity - activity.showSnackBar(R.string.logs_url_copied_to_clipboard) + val activity = requireActivity() as MainActivity + activity.showSnackBar(R.string.logs_url_copied_to_clipboard) - AppUtils.shareUploadedLogsUrl(activity, url) - } + AppUtils.shareUploadedLogsUrl(activity, url) } - ) + } viewModel.uploadErrorEvent.observe( - viewLifecycleOwner, - { - it.consume { - val activity = requireActivity() as MainActivity - activity.showSnackBar(R.string.logs_upload_failure) - } + viewLifecycleOwner + ) { + it.consume { + val activity = requireActivity() as MainActivity + activity.showSnackBar(R.string.logs_upload_failure) } - ) + } viewModel.resetCompleteEvent.observe( - viewLifecycleOwner, - { - it.consume { - val activity = requireActivity() as MainActivity - activity.showSnackBar(R.string.logs_reset_complete) - } + viewLifecycleOwner + ) { + it.consume { + val activity = requireActivity() as MainActivity + activity.showSnackBar(R.string.logs_reset_complete) } - ) + } viewModel.setNightModeEvent.observe( - viewLifecycleOwner, - { - it.consume { value -> - AppCompatDelegate.setDefaultNightMode( - when (value) { - 0 -> AppCompatDelegate.MODE_NIGHT_NO - 1 -> AppCompatDelegate.MODE_NIGHT_YES - else -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM - } - ) - } + viewLifecycleOwner + ) { + it.consume { value -> + AppCompatDelegate.setDefaultNightMode( + when (value) { + 0 -> AppCompatDelegate.MODE_NIGHT_NO + 1 -> AppCompatDelegate.MODE_NIGHT_YES + else -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + } + ) } - ) + } viewModel.backgroundModeEnabled.value = !DeviceUtils.isAppUserRestricted(requireContext()) viewModel.goToBatterySettingsEvent.observe( - viewLifecycleOwner, - { - it.consume { - try { - val intent = Intent("android.settings.IGNORE_BATTERY_OPTIMIZATION_SETTINGS") - startActivity(intent) - } catch (anfe: ActivityNotFoundException) { - Log.e("[Advanced Settings] ActivityNotFound exception: ", anfe) - } + viewLifecycleOwner + ) { + it.consume { + try { + val intent = Intent("android.settings.IGNORE_BATTERY_OPTIMIZATION_SETTINGS") + startActivity(intent) + } catch (anfe: ActivityNotFoundException) { + Log.e("[Advanced Settings] ActivityNotFound exception: ", anfe) } } - ) + } viewModel.powerManagerSettingsVisibility.value = PowerManagerUtils.getDevicePowerManagerIntent(requireContext()) != null viewModel.goToPowerManagerSettingsEvent.observe( - viewLifecycleOwner, - { - it.consume { - val intent = PowerManagerUtils.getDevicePowerManagerIntent(requireActivity()) - if (intent != null) { - try { - startActivity(intent) - } catch (se: SecurityException) { - Log.e("[Advanced Settings] Security exception: ", se) - } + viewLifecycleOwner + ) { + it.consume { + val intent = PowerManagerUtils.getDevicePowerManagerIntent(requireActivity()) + if (intent != null) { + try { + startActivity(intent) + } catch (se: SecurityException) { + Log.e("[Advanced Settings] Security exception: ", se) } } } - ) + } viewModel.goToAndroidSettingsEvent.observe( - viewLifecycleOwner, - { - it.consume { - val intent = Intent() - intent.action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS - intent.addCategory(Intent.CATEGORY_DEFAULT) - intent.data = Uri.parse("package:${requireContext().packageName}") - intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY) - ContextCompat.startActivity(requireContext(), intent, null) - } + viewLifecycleOwner + ) { + it.consume { + val intent = Intent() + intent.action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS + intent.addCategory(Intent.CATEGORY_DEFAULT) + intent.data = Uri.parse("package:${requireContext().packageName}") + intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY) + ContextCompat.startActivity(requireContext(), intent, null) } - ) + } } override fun goBack() { diff --git a/app/src/main/java/org/linphone/activities/main/settings/fragments/AudioSettingsFragment.kt b/app/src/main/java/org/linphone/activities/main/settings/fragments/AudioSettingsFragment.kt index 1f85d7fdd..2ed0343eb 100644 --- a/app/src/main/java/org/linphone/activities/main/settings/fragments/AudioSettingsFragment.kt +++ b/app/src/main/java/org/linphone/activities/main/settings/fragments/AudioSettingsFragment.kt @@ -54,24 +54,22 @@ class AudioSettingsFragment : GenericSettingFragment() { private lateinit var viewModel: CallSettingsViewModel @@ -57,80 +55,75 @@ class CallSettingsFragment : GenericSettingFragment binding.setBackClickListener { goBack() } viewModel.systemWideOverlayEnabledEvent.observe( - viewLifecycleOwner, - { - it.consume { - if (!Compatibility.canDrawOverlay(requireContext())) { - val intent = Intent("android.settings.action.MANAGE_OVERLAY_PERMISSION", Uri.parse("package:${requireContext().packageName}")) - startActivityForResult(intent, 0) - } + viewLifecycleOwner + ) { + it.consume { + if (!Compatibility.canDrawOverlay(requireContext())) { + val intent = Intent( + "android.settings.action.MANAGE_OVERLAY_PERMISSION", + Uri.parse("package:${requireContext().packageName}") + ) + startActivityForResult(intent, 0) } } - ) + } viewModel.goToAndroidNotificationSettingsEvent.observe( - viewLifecycleOwner, - { - it.consume { - if (Build.VERSION.SDK_INT >= Version.API26_O_80) { - val i = Intent() - i.action = Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS - i.putExtra(Settings.EXTRA_APP_PACKAGE, requireContext().packageName) - i.putExtra( - Settings.EXTRA_CHANNEL_ID, - getString(R.string.notification_channel_service_id) - ) - i.addCategory(Intent.CATEGORY_DEFAULT) - i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - i.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY) - i.addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS) - startActivity(i) - } + viewLifecycleOwner + ) { + it.consume { + if (Build.VERSION.SDK_INT >= Version.API26_O_80) { + val i = Intent() + i.action = Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS + i.putExtra(Settings.EXTRA_APP_PACKAGE, requireContext().packageName) + i.putExtra( + Settings.EXTRA_CHANNEL_ID, + getString(R.string.notification_channel_service_id) + ) + i.addCategory(Intent.CATEGORY_DEFAULT) + i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + i.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY) + i.addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS) + startActivity(i) } } - ) + } viewModel.enableTelecomManagerEvent.observe( - viewLifecycleOwner, - { - it.consume { - if (!PermissionHelper.get().hasTelecomManagerPermissions()) { - val permissions = arrayOf( - Manifest.permission.READ_PHONE_NUMBERS, - Manifest.permission.MANAGE_OWN_CALLS - ) - requestPermissions(permissions, 1) - } else if (!TelecomHelper.exists()) { - corePreferences.useTelecomManager = true - Log.w("[Telecom Helper] Doesn't exists yet, creating it") - TelecomHelper.create(requireContext()) - updateTelecomManagerAccount() - } + viewLifecycleOwner + ) { + it.consume { + if (!Compatibility.hasTelecomManagerPermissions(requireContext())) { + Compatibility.requestTelecomManagerPermissions(requireActivity(), 1) + } else if (!TelecomHelper.exists()) { + corePreferences.useTelecomManager = true + Log.w("[Telecom Helper] Doesn't exists yet, creating it") + TelecomHelper.create(requireContext()) + updateTelecomManagerAccount() } } - ) + } viewModel.goToAndroidNotificationSettingsEvent.observe( - viewLifecycleOwner, - { - it.consume { - if (Build.VERSION.SDK_INT >= Version.API26_O_80) { - val i = Intent() - i.action = Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS - i.putExtra(Settings.EXTRA_APP_PACKAGE, requireContext().packageName) - i.putExtra( - Settings.EXTRA_CHANNEL_ID, - getString(R.string.notification_channel_service_id) - ) - i.addCategory(Intent.CATEGORY_DEFAULT) - i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - i.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY) - i.addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS) - startActivity(i) - } + viewLifecycleOwner + ) { + it.consume { + if (Build.VERSION.SDK_INT >= Version.API26_O_80) { + val i = Intent() + i.action = Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS + i.putExtra(Settings.EXTRA_APP_PACKAGE, requireContext().packageName) + i.putExtra( + Settings.EXTRA_CHANNEL_ID, + getString(R.string.notification_channel_service_id) + ) + i.addCategory(Intent.CATEGORY_DEFAULT) + i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + i.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY) + i.addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS) + startActivity(i) } } - ) + } } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { diff --git a/app/src/main/java/org/linphone/activities/main/settings/fragments/ChatSettingsFragment.kt b/app/src/main/java/org/linphone/activities/main/settings/fragments/ChatSettingsFragment.kt index e04d7f2a3..2a89ac47c 100644 --- a/app/src/main/java/org/linphone/activities/main/settings/fragments/ChatSettingsFragment.kt +++ b/app/src/main/java/org/linphone/activities/main/settings/fragments/ChatSettingsFragment.kt @@ -50,39 +50,37 @@ class ChatSettingsFragment : GenericSettingFragment binding.setBackClickListener { goBack() } viewModel.launcherShortcutsEvent.observe( - viewLifecycleOwner, - { - it.consume { newValue -> - if (newValue) { - Compatibility.createShortcutsToChatRooms(requireContext()) - } else { - Compatibility.removeShortcuts(requireContext()) - } + viewLifecycleOwner + ) { + it.consume { newValue -> + if (newValue) { + Compatibility.createShortcutsToChatRooms(requireContext()) + } else { + Compatibility.removeShortcuts(requireContext()) } } - ) + } viewModel.goToAndroidNotificationSettingsEvent.observe( - viewLifecycleOwner, - { - it.consume { - if (Build.VERSION.SDK_INT >= Version.API26_O_80) { - val i = Intent() - i.action = Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS - i.putExtra(Settings.EXTRA_APP_PACKAGE, requireContext().packageName) - i.putExtra( - Settings.EXTRA_CHANNEL_ID, - getString(R.string.notification_channel_chat_id) - ) - i.addCategory(Intent.CATEGORY_DEFAULT) - i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - i.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY) - i.addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS) - startActivity(i) - } + viewLifecycleOwner + ) { + it.consume { + if (Build.VERSION.SDK_INT >= Version.API26_O_80) { + val i = Intent() + i.action = Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS + i.putExtra(Settings.EXTRA_APP_PACKAGE, requireContext().packageName) + i.putExtra( + Settings.EXTRA_CHANNEL_ID, + getString(R.string.notification_channel_chat_id) + ) + i.addCategory(Intent.CATEGORY_DEFAULT) + i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + i.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY) + i.addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS) + startActivity(i) } } - ) + } } override fun goBack() { diff --git a/app/src/main/java/org/linphone/activities/main/settings/fragments/ContactsSettingsFragment.kt b/app/src/main/java/org/linphone/activities/main/settings/fragments/ContactsSettingsFragment.kt index acb9a6556..4d89df1a4 100644 --- a/app/src/main/java/org/linphone/activities/main/settings/fragments/ContactsSettingsFragment.kt +++ b/app/src/main/java/org/linphone/activities/main/settings/fragments/ContactsSettingsFragment.kt @@ -51,30 +51,28 @@ class ContactsSettingsFragment : GenericSettingFragment - if (newValue) { - Compatibility.createShortcutsToContacts(requireContext()) - } else { - Compatibility.removeShortcuts(requireContext()) - if (corePreferences.chatRoomShortcuts) { - Compatibility.createShortcutsToChatRooms(requireContext()) - } + viewLifecycleOwner + ) { + it.consume { newValue -> + if (newValue) { + Compatibility.createShortcutsToContacts(requireContext()) + } else { + Compatibility.removeShortcuts(requireContext()) + if (corePreferences.chatRoomShortcuts) { + Compatibility.createShortcutsToChatRooms(requireContext()) } } } - ) + } viewModel.askWriteContactsPermissionForPresenceStorageEvent.observe( - viewLifecycleOwner, - { - it.consume { - Log.i("[Contacts Settings] Asking for WRITE_CONTACTS permission to be able to store presence") - requestPermissions(arrayOf(android.Manifest.permission.WRITE_CONTACTS), 1) - } + viewLifecycleOwner + ) { + it.consume { + Log.i("[Contacts Settings] Asking for WRITE_CONTACTS permission to be able to store presence") + requestPermissions(arrayOf(android.Manifest.permission.WRITE_CONTACTS), 1) } - ) + } if (!PermissionHelper.required(requireContext()).hasReadContactsPermission()) { Log.i("[Contacts Settings] Asking for READ_CONTACTS permission") diff --git a/app/src/main/java/org/linphone/activities/main/settings/fragments/SettingsFragment.kt b/app/src/main/java/org/linphone/activities/main/settings/fragments/SettingsFragment.kt index 800e9af57..6395507b9 100644 --- a/app/src/main/java/org/linphone/activities/main/settings/fragments/SettingsFragment.kt +++ b/app/src/main/java/org/linphone/activities/main/settings/fragments/SettingsFragment.kt @@ -69,39 +69,37 @@ class SettingsFragment : SecureFragment() { // Account settings loading can take some time, so wait until it is ready before opening the pane sharedViewModel.accountSettingsFragmentOpenedEvent.observe( - viewLifecycleOwner, - { - it.consume { - binding.slidingPane.openPane() - } + viewLifecycleOwner + ) { + it.consume { + binding.slidingPane.openPane() } - ) + } sharedViewModel.closeSlidingPaneEvent.observe( - viewLifecycleOwner, - { - it.consume { - if (!binding.slidingPane.closePane()) { - goBack() - } + viewLifecycleOwner + ) { + it.consume { + if (!binding.slidingPane.closePane()) { + goBack() } } - ) + } sharedViewModel.layoutChangedEvent.observe( - viewLifecycleOwner, - { - it.consume { - sharedViewModel.isSlidingPaneSlideable.value = binding.slidingPane.isSlideable - if (binding.slidingPane.isSlideable) { - val navHostFragment = childFragmentManager.findFragmentById(R.id.settings_nav_container) as NavHostFragment - if (navHostFragment.navController.currentDestination?.id == R.id.emptySettingsFragment) { - Log.i("[Settings] Foldable device has been folded, closing side pane with empty fragment") - binding.slidingPane.closePane() - } + viewLifecycleOwner + ) { + it.consume { + sharedViewModel.isSlidingPaneSlideable.value = binding.slidingPane.isSlideable + if (binding.slidingPane.isSlideable) { + val navHostFragment = + childFragmentManager.findFragmentById(R.id.settings_nav_container) as NavHostFragment + if (navHostFragment.navController.currentDestination?.id == R.id.emptySettingsFragment) { + Log.i("[Settings] Foldable device has been folded, closing side pane with empty fragment") + binding.slidingPane.closePane() } } } - ) + } binding.slidingPane.lockMode = SlidingPaneLayout.LOCK_MODE_LOCKED /* End of shared view model & sliding pane related */ @@ -112,12 +110,11 @@ class SettingsFragment : SecureFragment() { binding.setBackClickListener { goBack() } sharedViewModel.accountRemoved.observe( - viewLifecycleOwner, - { - Log.i("[Settings] Account removed, update accounts list") - viewModel.updateAccountsList() - } - ) + viewLifecycleOwner + ) { + Log.i("[Settings] Account removed, update accounts list") + viewModel.updateAccountsList() + } val identity = arguments?.getString("Identity") if (identity != null) { diff --git a/app/src/main/java/org/linphone/activities/main/settings/viewmodels/AccountSettingsViewModel.kt b/app/src/main/java/org/linphone/activities/main/settings/viewmodels/AccountSettingsViewModel.kt index 6841ff5a3..81760768b 100644 --- a/app/src/main/java/org/linphone/activities/main/settings/viewmodels/AccountSettingsViewModel.kt +++ b/app/src/main/java/org/linphone/activities/main/settings/viewmodels/AccountSettingsViewModel.kt @@ -192,7 +192,7 @@ class AccountSettingsViewModel(val account: Account) : GenericSettingsViewModel( val disableListener = object : SettingListenerStub() { override fun onBoolValueChanged(newValue: Boolean) { val params = account.params.clone() - params.registerEnabled = !newValue + params.isRegisterEnabled = !newValue account.params = params } } @@ -239,7 +239,7 @@ class AccountSettingsViewModel(val account: Account) : GenericSettingsViewModel( } val params = account.params.clone() - params.registerEnabled = false + params.isRegisterEnabled = false account.params = params if (!registered) { @@ -288,7 +288,7 @@ class AccountSettingsViewModel(val account: Account) : GenericSettingsViewModel( val outboundProxyListener = object : SettingListenerStub() { override fun onBoolValueChanged(newValue: Boolean) { val params = account.params.clone() - params.outboundProxyEnabled = newValue + params.isOutboundProxyEnabled = newValue account.params = params } } @@ -297,8 +297,15 @@ class AccountSettingsViewModel(val account: Account) : GenericSettingsViewModel( val stunServerListener = object : SettingListenerStub() { override fun onTextValueChanged(newValue: String) { val params = account.params.clone() - params.natPolicy?.stunServer = newValue - if (newValue.isEmpty()) ice.value = false + if (params.natPolicy == null) { + Log.w("[Account Settings] No NAT Policy object in account params yet") + val natPolicy = core.createNatPolicy() + natPolicy.stunServer = newValue + params.natPolicy = natPolicy + } else { + params.natPolicy?.stunServer = newValue + if (newValue.isEmpty()) ice.value = false + } stunServer.value = newValue account.params = params } @@ -308,7 +315,7 @@ class AccountSettingsViewModel(val account: Account) : GenericSettingsViewModel( val iceListener = object : SettingListenerStub() { override fun onBoolValueChanged(newValue: Boolean) { val params = account.params.clone() - params.natPolicy?.enableIce(newValue) + params.natPolicy?.isIceEnabled = newValue account.params = params } } @@ -370,7 +377,7 @@ class AccountSettingsViewModel(val account: Account) : GenericSettingsViewModel( val escapePlusListener = object : SettingListenerStub() { override fun onBoolValueChanged(newValue: Boolean) { val params = account.params.clone() - params.dialEscapePlusEnabled = newValue + params.isDialEscapePlusEnabled = newValue account.params = params } } @@ -424,19 +431,19 @@ class AccountSettingsViewModel(val account: Account) : GenericSettingsViewModel( userName.value = params.identityAddress?.username userId.value = account.findAuthInfo()?.userid domain.value = params.identityAddress?.domain - disable.value = !params.registerEnabled + disable.value = !params.isRegisterEnabled pushNotification.value = params.pushNotificationAllowed pushNotificationsAvailable.value = core.isPushNotificationAvailable proxy.value = params.serverAddress?.asStringUriOnly() - outboundProxy.value = params.outboundProxyEnabled + outboundProxy.value = params.isOutboundProxyEnabled stunServer.value = params.natPolicy?.stunServer - ice.value = params.natPolicy?.iceEnabled() + ice.value = params.natPolicy?.isIceEnabled avpf.value = params.avpfMode == AVPFMode.Enabled avpfRrInterval.value = params.avpfRrInterval expires.value = params.expires prefix.value = params.internationalPrefix dialPrefix.value = params.useInternationalPrefixForCallsAndChats - escapePlus.value = params.dialEscapePlusEnabled + escapePlus.value = params.isDialEscapePlusEnabled } private fun initTransportList() { diff --git a/app/src/main/java/org/linphone/activities/main/settings/viewmodels/AdvancedSettingsViewModel.kt b/app/src/main/java/org/linphone/activities/main/settings/viewmodels/AdvancedSettingsViewModel.kt index ba78aaa23..c177e7386 100644 --- a/app/src/main/java/org/linphone/activities/main/settings/viewmodels/AdvancedSettingsViewModel.kt +++ b/app/src/main/java/org/linphone/activities/main/settings/viewmodels/AdvancedSettingsViewModel.kt @@ -32,8 +32,8 @@ import org.linphone.mediastream.Version import org.linphone.utils.Event class AdvancedSettingsViewModel : LogsUploadViewModel() { - protected val prefs = corePreferences - protected val core = coreContext.core + private val prefs = corePreferences + private val core = coreContext.core val debugModeListener = object : SettingListenerStub() { override fun onBoolValueChanged(newValue: Boolean) { diff --git a/app/src/main/java/org/linphone/activities/main/settings/viewmodels/AudioSettingsViewModel.kt b/app/src/main/java/org/linphone/activities/main/settings/viewmodels/AudioSettingsViewModel.kt index 16551c4cb..8c684ef7d 100644 --- a/app/src/main/java/org/linphone/activities/main/settings/viewmodels/AudioSettingsViewModel.kt +++ b/app/src/main/java/org/linphone/activities/main/settings/viewmodels/AudioSettingsViewModel.kt @@ -41,7 +41,7 @@ class AudioSettingsViewModel : GenericSettingsViewModel() { val echoCancellationListener = object : SettingListenerStub() { override fun onBoolValueChanged(newValue: Boolean) { - core.enableEchoCancellation(newValue) + core.isEchoCancellationEnabled = newValue } } val echoCancellation = MutableLiveData() @@ -81,7 +81,7 @@ class AudioSettingsViewModel : GenericSettingsViewModel() { val adaptiveRateControlListener = object : SettingListenerStub() { override fun onBoolValueChanged(newValue: Boolean) { - core.enableAdaptiveRateControl(newValue) + core.isAdaptiveRateControlEnabled = newValue } } val adaptiveRateControl = MutableLiveData() @@ -110,6 +110,13 @@ class AudioSettingsViewModel : GenericSettingsViewModel() { val outputAudioDeviceLabels = MutableLiveData>() private val outputAudioDeviceValues = MutableLiveData>() + val preferBluetoothDevicesListener = object : SettingListenerStub() { + override fun onBoolValueChanged(newValue: Boolean) { + prefs.routeAudioToBluetoothIfAvailable = newValue + } + } + val preferBluetoothDevices = MutableLiveData() + val codecBitrateListener = object : SettingListenerStub() { override fun onListValueChanged(position: Int) { for (payloadType in core.audioPayloadTypes) { @@ -146,14 +153,15 @@ class AudioSettingsViewModel : GenericSettingsViewModel() { val audioCodecs = MutableLiveData>() init { - echoCancellation.value = core.echoCancellationEnabled() - adaptiveRateControl.value = core.adaptiveRateControlEnabled() - echoCalibration.value = if (core.echoCancellationEnabled()) { + echoCancellation.value = core.isEchoCancellationEnabled + adaptiveRateControl.value = core.isAdaptiveRateControlEnabled + echoCalibration.value = if (core.isEchoCancellationEnabled) { prefs.getString(R.string.audio_settings_echo_cancellation_calibration_value).format(prefs.echoCancellerCalibration) } else { prefs.getString(R.string.audio_settings_echo_canceller_calibration_summary) } echoTesterStatus.value = prefs.getString(R.string.audio_settings_echo_tester_summary) + preferBluetoothDevices.value = prefs.routeAudioToBluetoothIfAvailable initInputAudioDevicesList() initOutputAudioDevicesList() initCodecBitrateList() diff --git a/app/src/main/java/org/linphone/activities/main/settings/viewmodels/CallSettingsViewModel.kt b/app/src/main/java/org/linphone/activities/main/settings/viewmodels/CallSettingsViewModel.kt index 47f3ef501..f95ab222e 100644 --- a/app/src/main/java/org/linphone/activities/main/settings/viewmodels/CallSettingsViewModel.kt +++ b/app/src/main/java/org/linphone/activities/main/settings/viewmodels/CallSettingsViewModel.kt @@ -20,22 +20,40 @@ package org.linphone.activities.main.settings.viewmodels import androidx.lifecycle.MutableLiveData +import java.io.File +import java.util.* +import kotlin.collections.ArrayList import org.linphone.R import org.linphone.activities.main.settings.SettingListenerStub import org.linphone.core.MediaEncryption import org.linphone.core.tools.Log import org.linphone.mediastream.Version import org.linphone.telecom.TelecomHelper +import org.linphone.utils.AppUtils import org.linphone.utils.Event class CallSettingsViewModel : GenericSettingsViewModel() { val deviceRingtoneListener = object : SettingListenerStub() { override fun onBoolValueChanged(newValue: Boolean) { - core.ring = if (newValue) null else prefs.ringtonePath + core.ring = if (newValue) null else prefs.defaultRingtonePath } } val deviceRingtone = MutableLiveData() + val ringtoneListener = object : SettingListenerStub() { + override fun onListValueChanged(position: Int) { + if (position == 0) { + core.ring = null + } else { + core.ring = ringtoneValues[position] + } + } + } + val ringtoneIndex = MutableLiveData() + val ringtoneLabels = MutableLiveData>() + private val ringtoneValues = arrayListOf() + val showRingtonesList = MutableLiveData() + val vibrateOnIncomingCallListener = object : SettingListenerStub() { override fun onBoolValueChanged(newValue: Boolean) { core.isVibrationOnIncomingCallEnabled = newValue @@ -73,6 +91,9 @@ class CallSettingsViewModel : GenericSettingsViewModel() { TelecomHelper.get().removeAccount() TelecomHelper.get().destroy() TelecomHelper.destroy() + + Log.w("[Call Settings] Disabling Telecom Manager auto-enable") + prefs.manuallyDisabledTelecomManager = true } prefs.useTelecomManager = newValue } @@ -209,7 +230,10 @@ class CallSettingsViewModel : GenericSettingsViewModel() { val goToAndroidNotificationSettingsEvent = MutableLiveData>() init { + initRingtonesList() deviceRingtone.value = core.ring == null + showRingtonesList.value = prefs.showAllRingtones + vibrateOnIncomingCall.value = core.isVibrationOnIncomingCallEnabled initEncryptionList() @@ -235,6 +259,28 @@ class CallSettingsViewModel : GenericSettingsViewModel() { pauseCallsWhenAudioFocusIsLost.value = prefs.pauseCallsWhenAudioFocusIsLost } + private fun initRingtonesList() { + val labels = arrayListOf() + labels.add(AppUtils.getString(R.string.call_settings_device_ringtone_title)) + ringtoneValues.add("") + + val directory = File(prefs.ringtonesPath) + val files = directory.listFiles() + for (ringtone in files.orEmpty()) { + if (ringtone.absolutePath.endsWith(".mkv")) { + val name = ringtone.name + .substringBefore(".") + .replace("_", " ") + .capitalize(Locale.getDefault()) + labels.add(name) + ringtoneValues.add(ringtone.absolutePath) + } + } + + ringtoneLabels.value = labels + ringtoneIndex.value = if (core.ring == null) 0 else ringtoneValues.indexOf(core.ring) + } + private fun initEncryptionList() { val labels = arrayListOf() diff --git a/app/src/main/java/org/linphone/activities/main/settings/viewmodels/ChatSettingsViewModel.kt b/app/src/main/java/org/linphone/activities/main/settings/viewmodels/ChatSettingsViewModel.kt index 01f78ccf3..4b8de0353 100644 --- a/app/src/main/java/org/linphone/activities/main/settings/viewmodels/ChatSettingsViewModel.kt +++ b/app/src/main/java/org/linphone/activities/main/settings/viewmodels/ChatSettingsViewModel.kt @@ -35,7 +35,7 @@ class ChatSettingsViewModel : GenericSettingsViewModel() { val fileSharingUrlListener = object : SettingListenerStub() { override fun onTextValueChanged(newValue: String) { - core.logCollectionUploadServerUrl = newValue + core.fileTransferServer = newValue } } val fileSharingUrl = MutableLiveData() diff --git a/app/src/main/java/org/linphone/activities/main/settings/viewmodels/ContactsSettingsViewModel.kt b/app/src/main/java/org/linphone/activities/main/settings/viewmodels/ContactsSettingsViewModel.kt index 805279621..41aac8d87 100644 --- a/app/src/main/java/org/linphone/activities/main/settings/viewmodels/ContactsSettingsViewModel.kt +++ b/app/src/main/java/org/linphone/activities/main/settings/viewmodels/ContactsSettingsViewModel.kt @@ -33,7 +33,7 @@ class ContactsSettingsViewModel : GenericSettingsViewModel() { val friendListSubscribeListener = object : SettingListenerStub() { override fun onBoolValueChanged(newValue: Boolean) { - core.enableFriendListSubscription(newValue) + core.isFriendListSubscriptionEnabled = newValue } } val friendListSubscribe = MutableLiveData() diff --git a/app/src/main/java/org/linphone/activities/main/settings/viewmodels/NetworkSettingsViewModel.kt b/app/src/main/java/org/linphone/activities/main/settings/viewmodels/NetworkSettingsViewModel.kt index 1a07e8736..c282b006e 100644 --- a/app/src/main/java/org/linphone/activities/main/settings/viewmodels/NetworkSettingsViewModel.kt +++ b/app/src/main/java/org/linphone/activities/main/settings/viewmodels/NetworkSettingsViewModel.kt @@ -26,14 +26,14 @@ import org.linphone.activities.main.settings.SettingListenerStub class NetworkSettingsViewModel : GenericSettingsViewModel() { val wifiOnlyListener = object : SettingListenerStub() { override fun onBoolValueChanged(newValue: Boolean) { - core.enableWifiOnly(newValue) + core.isWifiOnlyEnabled = newValue } } val wifiOnly = MutableLiveData() val allowIpv6Listener = object : SettingListenerStub() { override fun onBoolValueChanged(newValue: Boolean) { - core.enableIpv6(newValue) + core.isIpv6Enabled = newValue } } val allowIpv6 = MutableLiveData() @@ -59,8 +59,8 @@ class NetworkSettingsViewModel : GenericSettingsViewModel() { val sipPort = MutableLiveData() init { - wifiOnly.value = core.wifiOnlyEnabled() - allowIpv6.value = core.ipv6Enabled() + wifiOnly.value = core.isWifiOnlyEnabled + allowIpv6.value = core.isIpv6Enabled randomPorts.value = getTransportPort() == -1 sipPort.value = getTransportPort() } diff --git a/app/src/main/java/org/linphone/activities/main/settings/viewmodels/TunnelSettingsViewModel.kt b/app/src/main/java/org/linphone/activities/main/settings/viewmodels/TunnelSettingsViewModel.kt index f07177204..3822fe5fa 100644 --- a/app/src/main/java/org/linphone/activities/main/settings/viewmodels/TunnelSettingsViewModel.kt +++ b/app/src/main/java/org/linphone/activities/main/settings/viewmodels/TunnelSettingsViewModel.kt @@ -52,7 +52,7 @@ class TunnelSettingsViewModel : GenericSettingsViewModel() { val useDualModeListener = object : SettingListenerStub() { override fun onBoolValueChanged(newValue: Boolean) { val tunnel = core.tunnel - tunnel?.enableDualMode(newValue) + tunnel?.isDualModeEnabled = newValue } } val useDualMode = MutableLiveData() @@ -96,7 +96,7 @@ class TunnelSettingsViewModel : GenericSettingsViewModel() { hostnameUrl.value = config.host port.value = config.port - useDualMode.value = tunnel?.dualModeEnabled() + useDualMode.value = tunnel?.isDualModeEnabled hostnameUrl2.value = config.host2 port2.value = config.port2 diff --git a/app/src/main/java/org/linphone/activities/main/settings/viewmodels/VideoSettingsViewModel.kt b/app/src/main/java/org/linphone/activities/main/settings/viewmodels/VideoSettingsViewModel.kt index f8ed1bfc1..d871e341a 100644 --- a/app/src/main/java/org/linphone/activities/main/settings/viewmodels/VideoSettingsViewModel.kt +++ b/app/src/main/java/org/linphone/activities/main/settings/viewmodels/VideoSettingsViewModel.kt @@ -31,8 +31,8 @@ import org.linphone.core.tools.Log class VideoSettingsViewModel : GenericSettingsViewModel() { val enableVideoListener = object : SettingListenerStub() { override fun onBoolValueChanged(newValue: Boolean) { - core.enableVideoCapture(newValue) - core.enableVideoDisplay(newValue) + core.isVideoCaptureEnabled = newValue + core.isVideoDisplayEnabled = newValue if (!newValue) { tabletPreview.value = false initiateCall.value = false @@ -115,7 +115,7 @@ class VideoSettingsViewModel : GenericSettingsViewModel() { val videoCodecs = MutableLiveData>() init { - enableVideo.value = core.videoEnabled() && core.videoSupported() + enableVideo.value = core.isVideoEnabled && core.videoSupported() tabletPreview.value = prefs.videoPreview isTablet.value = coreContext.context.resources.getBoolean(R.bool.isTablet) initiateCall.value = core.videoActivationPolicy.automaticallyInitiate diff --git a/app/src/main/java/org/linphone/activities/main/sidemenu/fragments/SideMenuFragment.kt b/app/src/main/java/org/linphone/activities/main/sidemenu/fragments/SideMenuFragment.kt index 84183526d..3251193a1 100644 --- a/app/src/main/java/org/linphone/activities/main/sidemenu/fragments/SideMenuFragment.kt +++ b/app/src/main/java/org/linphone/activities/main/sidemenu/fragments/SideMenuFragment.kt @@ -31,6 +31,7 @@ import androidx.lifecycle.lifecycleScope import java.io.File import kotlinx.coroutines.launch import org.linphone.LinphoneApplication.Companion.coreContext +import org.linphone.LinphoneApplication.Companion.corePreferences import org.linphone.R import org.linphone.activities.GenericFragment import org.linphone.activities.assistant.AssistantActivity @@ -67,12 +68,11 @@ class SideMenuFragment : GenericFragment() { } sharedViewModel.accountRemoved.observe( - viewLifecycleOwner, - { - Log.i("[Side Menu] Account removed, update accounts list") - viewModel.updateAccountsList() - } - ) + viewLifecycleOwner + ) { + Log.i("[Side Menu] Account removed, update accounts list") + viewModel.updateAccountsList() + } viewModel.accountsSettingsListener = object : SettingListenerStub() { override fun onAccountClicked(identity: String) { @@ -110,8 +110,15 @@ class SideMenuFragment : GenericFragment() { } binding.setQuitClickListener { + Log.i("[Side Menu] Quitting app") requireActivity().finishAndRemoveTask() - coreContext.stop() + + if (!corePreferences.keepServiceAlive) { + Log.i("[Side Menu] Stopping Core") + coreContext.stop() + } else { + Log.w("[Side Menu] Keep Service alive setting enabled, don't destroy the Core") + } } onBackPressedCallback.isEnabled = false diff --git a/app/src/main/java/org/linphone/activities/main/viewmodels/StatusViewModel.kt b/app/src/main/java/org/linphone/activities/main/viewmodels/StatusViewModel.kt index a6d0404a7..27f0afb14 100644 --- a/app/src/main/java/org/linphone/activities/main/viewmodels/StatusViewModel.kt +++ b/app/src/main/java/org/linphone/activities/main/viewmodels/StatusViewModel.kt @@ -57,7 +57,7 @@ open class StatusViewModel : ViewModel() { body: Content ) { if (body.type == "application" && body.subtype == "simple-message-summary" && body.size > 0) { - val data = body.utf8Text?.toLowerCase(Locale.getDefault()) + val data = body.utf8Text?.lowercase(Locale.getDefault()) val voiceMail = data?.split("voice-message: ") if (voiceMail?.size ?: 0 >= 2) { val toParse = voiceMail!![1].split("/", limit = 0) diff --git a/app/src/main/java/org/linphone/compatibility/Api21Compatibility.kt b/app/src/main/java/org/linphone/compatibility/Api21Compatibility.kt index cd59c2076..db1a1b764 100644 --- a/app/src/main/java/org/linphone/compatibility/Api21Compatibility.kt +++ b/app/src/main/java/org/linphone/compatibility/Api21Compatibility.kt @@ -22,9 +22,11 @@ package org.linphone.compatibility import android.annotation.SuppressLint import android.annotation.TargetApi import android.app.Activity +import android.app.PendingIntent import android.bluetooth.BluetoothAdapter import android.content.ContentValues import android.content.Context +import android.content.Intent import android.graphics.Bitmap import android.net.Uri import android.os.Build @@ -33,6 +35,7 @@ import android.os.Vibrator import android.provider.MediaStore import android.provider.Settings import android.view.WindowManager +import android.view.inputmethod.EditorInfo import org.linphone.R import org.linphone.core.Content import org.linphone.core.tools.Log @@ -44,8 +47,10 @@ import org.linphone.utils.PermissionHelper @TargetApi(21) class Api21Compatibility { companion object { + @SuppressLint("MissingPermission") fun getDeviceName(context: Context): String { - var name = BluetoothAdapter.getDefaultAdapter().name + val adapter = BluetoothAdapter.getDefaultAdapter() + var name = adapter?.name if (name == null) { name = Settings.Secure.getString( context.contentResolver, @@ -74,7 +79,10 @@ class Api21Compatibility { return false } - val filePath = content.filePath + val plainFilePath = content.plainFilePath.orEmpty() + val isVfsEncrypted = plainFilePath.isNotEmpty() + Log.w("[Media Store] Content is encrypted, requesting plain file path") + val filePath = if (isVfsEncrypted) plainFilePath else content.filePath if (filePath == null) { Log.e("[Media Store] Content doesn't have a file path!") return false @@ -92,6 +100,10 @@ class Api21Compatibility { } val collection = MediaStore.Images.Media.getContentUri("external") val mediaStoreFilePath = addContentValuesToCollection(context, filePath, collection, values) + if (isVfsEncrypted) { + Log.w("[Media Store] Content was encrypted, delete plain version: $plainFilePath") + FileUtils.deleteFile(plainFilePath) + } if (mediaStoreFilePath.isNotEmpty()) { content.userData = mediaStoreFilePath return true @@ -105,7 +117,10 @@ class Api21Compatibility { return false } - val filePath = content.filePath + val plainFilePath = content.plainFilePath.orEmpty() + val isVfsEncrypted = plainFilePath.isNotEmpty() + Log.w("[Media Store] Content is encrypted, requesting plain file path") + val filePath = if (isVfsEncrypted) plainFilePath else content.filePath if (filePath == null) { Log.e("[Media Store] Content doesn't have a file path!") return false @@ -124,6 +139,10 @@ class Api21Compatibility { } val collection = MediaStore.Video.Media.getContentUri("external") val mediaStoreFilePath = addContentValuesToCollection(context, filePath, collection, values) + if (isVfsEncrypted) { + Log.w("[Media Store] Content was encrypted, delete plain version: $plainFilePath") + FileUtils.deleteFile(plainFilePath) + } if (mediaStoreFilePath.isNotEmpty()) { content.userData = mediaStoreFilePath return true @@ -137,7 +156,10 @@ class Api21Compatibility { return false } - val filePath = content.filePath + val plainFilePath = content.plainFilePath.orEmpty() + val isVfsEncrypted = plainFilePath.isNotEmpty() + Log.w("[Media Store] Content is encrypted, requesting plain file path") + val filePath = if (isVfsEncrypted) plainFilePath else content.filePath if (filePath == null) { Log.e("[Media Store] Content doesn't have a file path!") return false @@ -157,6 +179,10 @@ class Api21Compatibility { val collection = MediaStore.Audio.Media.getContentUri("external") val mediaStoreFilePath = addContentValuesToCollection(context, filePath, collection, values) + if (isVfsEncrypted) { + Log.w("[Media Store] Content was encrypted, delete plain version: $plainFilePath") + FileUtils.deleteFile(plainFilePath) + } if (mediaStoreFilePath.isNotEmpty()) { content.userData = mediaStoreFilePath return true @@ -207,5 +233,17 @@ class Api21Compatibility { fun requestDismissKeyguard(activity: Activity) { activity.window.addFlags(WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD) } + + fun getUpdateCurrentPendingIntentFlag(): Int { + return PendingIntent.FLAG_UPDATE_CURRENT + } + + fun getImeFlagsForSecureChatRoom(): Int { + return EditorInfo.IME_FLAG_NO_EXTRACT_UI + } + + fun startForegroundService(context: Context, intent: Intent) { + context.startService(intent) + } } } diff --git a/app/src/main/java/org/linphone/compatibility/Api23Compatibility.kt b/app/src/main/java/org/linphone/compatibility/Api23Compatibility.kt index 080d8f739..4a7505564 100644 --- a/app/src/main/java/org/linphone/compatibility/Api23Compatibility.kt +++ b/app/src/main/java/org/linphone/compatibility/Api23Compatibility.kt @@ -19,10 +19,12 @@ */ package org.linphone.compatibility +import android.Manifest import android.annotation.TargetApi import android.content.Context import android.content.pm.PackageManager import android.provider.Settings +import androidx.fragment.app.Fragment @TargetApi(23) class Api23Compatibility { @@ -31,6 +33,10 @@ class Api23Compatibility { return context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED } + fun requestReadPhoneStatePermission(fragment: Fragment, code: Int) { + fragment.requestPermissions(arrayOf(Manifest.permission.READ_PHONE_STATE), code) + } + fun canDrawOverlay(context: Context): Boolean { return Settings.canDrawOverlays(context) } diff --git a/app/src/main/java/org/linphone/compatibility/Api25Compatibility.kt b/app/src/main/java/org/linphone/compatibility/Api25Compatibility.kt index 084ee9d71..a813f8a81 100644 --- a/app/src/main/java/org/linphone/compatibility/Api25Compatibility.kt +++ b/app/src/main/java/org/linphone/compatibility/Api25Compatibility.kt @@ -34,7 +34,8 @@ class Api25Compatibility { context.contentResolver, Settings.Global.DEVICE_NAME ) if (name == null) { - name = BluetoothAdapter.getDefaultAdapter().name + val adapter = BluetoothAdapter.getDefaultAdapter() + name = adapter?.name } if (name == null) { name = Settings.Secure.getString( diff --git a/app/src/main/java/org/linphone/compatibility/Api26Compatibility.kt b/app/src/main/java/org/linphone/compatibility/Api26Compatibility.kt index 8f66015e0..f49e1bdd3 100644 --- a/app/src/main/java/org/linphone/compatibility/Api26Compatibility.kt +++ b/app/src/main/java/org/linphone/compatibility/Api26Compatibility.kt @@ -19,22 +19,33 @@ */ package org.linphone.compatibility +import android.Manifest import android.annotation.SuppressLint import android.annotation.TargetApi -import android.app.Activity -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PictureInPictureParams +import android.app.* import android.content.Context +import android.content.Intent import android.content.pm.PackageManager import android.media.AudioAttributes import android.os.VibrationEffect import android.os.Vibrator import android.view.WindowManager +import android.view.inputmethod.EditorInfo +import android.widget.RemoteViews +import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import org.linphone.LinphoneApplication.Companion.coreContext +import org.linphone.LinphoneApplication.Companion.corePreferences import org.linphone.R +import org.linphone.contact.Contact +import org.linphone.core.Call import org.linphone.core.tools.Log +import org.linphone.notifications.Notifiable +import org.linphone.notifications.NotificationsManager import org.linphone.telecom.NativeCallWrapper +import org.linphone.utils.ImageUtils +import org.linphone.utils.LinphoneUtils @TargetApi(26) class Api26Compatibility { @@ -126,6 +137,117 @@ class Api26Compatibility { return WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY } + fun createIncomingCallNotification( + context: Context, + call: Call, + notifiable: Notifiable, + pendingIntent: PendingIntent, + notificationsManager: NotificationsManager + ): Notification { + val contact: Contact? = coreContext.contactsManager.findContactByAddress(call.remoteAddress) + val pictureUri = contact?.getContactThumbnailPictureUri() + val roundPicture = ImageUtils.getRoundBitmapFromUri(context, pictureUri) + val displayName = contact?.fullName ?: LinphoneUtils.getDisplayName(call.remoteAddress) + val address = LinphoneUtils.getDisplayableAddress(call.remoteAddress) + + val notificationLayoutHeadsUp = RemoteViews(context.packageName, R.layout.call_incoming_notification_heads_up) + notificationLayoutHeadsUp.setTextViewText(R.id.caller, displayName) + notificationLayoutHeadsUp.setTextViewText(R.id.sip_uri, address) + notificationLayoutHeadsUp.setTextViewText(R.id.incoming_call_info, context.getString(R.string.incoming_call_notification_title)) + + if (roundPicture != null) { + notificationLayoutHeadsUp.setImageViewBitmap(R.id.caller_picture, roundPicture) + } + + val builder = NotificationCompat.Builder(context, context.getString(R.string.notification_channel_incoming_call_id)) + .setStyle(NotificationCompat.DecoratedCustomViewStyle()) + .addPerson(notificationsManager.getPerson(contact, displayName, roundPicture)) + .setSmallIcon(R.drawable.topbar_call_notification) + .setContentTitle(displayName) + .setContentText(context.getString(R.string.incoming_call_notification_title)) + .setCategory(NotificationCompat.CATEGORY_CALL) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setWhen(System.currentTimeMillis()) + .setAutoCancel(false) + .setShowWhen(true) + .setOngoing(true) + .setColor(ContextCompat.getColor(context, R.color.primary_color)) + .setFullScreenIntent(pendingIntent, true) + .addAction(notificationsManager.getCallDeclineAction(notifiable)) + .addAction(notificationsManager.getCallAnswerAction(notifiable)) + .setCustomHeadsUpContentView(notificationLayoutHeadsUp) + + if (!corePreferences.preventInterfaceFromShowingUp) { + builder.setContentIntent(pendingIntent) + } + + return builder.build() + } + + fun createCallNotification( + context: Context, + call: Call, + notifiable: Notifiable, + pendingIntent: PendingIntent, + channel: String, + notificationsManager: NotificationsManager + ): Notification { + val contact: Contact? = coreContext.contactsManager.findContactByAddress(call.remoteAddress) + val pictureUri = contact?.getContactThumbnailPictureUri() + val roundPicture = ImageUtils.getRoundBitmapFromUri(context, pictureUri) + val displayName = contact?.fullName ?: LinphoneUtils.getDisplayName(call.remoteAddress) + + val stringResourceId: Int + val iconResourceId: Int + when (call.state) { + Call.State.Paused, Call.State.Pausing, Call.State.PausedByRemote -> { + stringResourceId = R.string.call_notification_paused + iconResourceId = R.drawable.topbar_call_paused_notification + } + Call.State.OutgoingRinging, Call.State.OutgoingProgress, Call.State.OutgoingInit, Call.State.OutgoingEarlyMedia -> { + stringResourceId = R.string.call_notification_outgoing + iconResourceId = if (call.params.isVideoEnabled) { + R.drawable.topbar_videocall_notification + } else { + R.drawable.topbar_call_notification + } + } + else -> { + stringResourceId = R.string.call_notification_active + iconResourceId = if (call.currentParams.isVideoEnabled) { + R.drawable.topbar_videocall_notification + } else { + R.drawable.topbar_call_notification + } + } + } + + val builder = NotificationCompat.Builder( + context, channel + ) + .setContentTitle(contact?.fullName ?: displayName) + .setContentText(context.getString(stringResourceId)) + .setSmallIcon(iconResourceId) + .setLargeIcon(roundPicture) + .addPerson(notificationsManager.getPerson(contact, displayName, roundPicture)) + .setAutoCancel(false) + .setCategory(NotificationCompat.CATEGORY_CALL) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setWhen(System.currentTimeMillis()) + .setShowWhen(true) + .setOngoing(true) + .setColor(ContextCompat.getColor(context, R.color.notification_led_color)) + .addAction(notificationsManager.getCallDeclineAction(notifiable)) + + if (!corePreferences.preventInterfaceFromShowingUp) { + builder.setContentIntent(pendingIntent) + } + + return builder.build() + } + @SuppressLint("MissingPermission") fun eventVibration(vibrator: Vibrator) { val effect = VibrationEffect.createWaveform(longArrayOf(0L, 100L, 100L), intArrayOf(0, VibrationEffect.DEFAULT_AMPLITUDE, 0), -1) @@ -135,8 +257,37 @@ class Api26Compatibility { vibrator.vibrate(effect, audioAttrs) } - fun changeAudioRouteForTelecomManager(connection: NativeCallWrapper, route: Int) { + fun changeAudioRouteForTelecomManager(connection: NativeCallWrapper, route: Int): Boolean { + Log.i("[Telecom Helper] Changing audio route [$route] on connection ${connection.callId}") + if (connection.callAudioState.route == route) { + Log.w("[Telecom Helper] Connection is already using this route") + return false + } connection.setAudioRoute(route) + return true + } + + fun requestTelecomManagerPermission(activity: Activity, code: Int) { + activity.requestPermissions( + arrayOf( + Manifest.permission.READ_PHONE_STATE, + Manifest.permission.MANAGE_OWN_CALLS + ), + code + ) + } + + fun hasTelecomManagerPermission(context: Context): Boolean { + return Compatibility.hasPermission(context, Manifest.permission.READ_PHONE_STATE) && + Compatibility.hasPermission(context, Manifest.permission.MANAGE_OWN_CALLS) + } + + fun getImeFlagsForSecureChatRoom(): Int { + return EditorInfo.IME_FLAG_NO_EXTRACT_UI or EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING + } + + fun startForegroundService(context: Context, intent: Intent) { + context.startForegroundService(intent) } } } diff --git a/app/src/main/java/org/linphone/compatibility/Api29Compatibility.kt b/app/src/main/java/org/linphone/compatibility/Api29Compatibility.kt index 9ffd1e3ea..9bb62eedc 100644 --- a/app/src/main/java/org/linphone/compatibility/Api29Compatibility.kt +++ b/app/src/main/java/org/linphone/compatibility/Api29Compatibility.kt @@ -21,7 +21,6 @@ package org.linphone.compatibility import android.Manifest import android.annotation.TargetApi -import android.app.Activity import android.app.NotificationChannel import android.app.NotificationManager import android.content.ContentValues @@ -57,10 +56,6 @@ class Api29Compatibility { return granted } - fun requestReadPhoneStatePermission(activity: Activity, code: Int) { - activity.requestPermissions(arrayOf(Manifest.permission.READ_PHONE_STATE), code) - } - fun createMessageChannel( context: Context, notificationManager: NotificationManagerCompat @@ -104,7 +99,10 @@ class Api29Compatibility { } suspend fun addImageToMediaStore(context: Context, content: Content): Boolean { - val filePath = content.filePath + val plainFilePath = content.plainFilePath.orEmpty() + val isVfsEncrypted = plainFilePath.isNotEmpty() + Log.w("[Media Store] Content is encrypted, requesting plain file path") + val filePath = if (isVfsEncrypted) plainFilePath else content.filePath if (filePath == null) { Log.e("[Media Store] Content doesn't have a file path!") return false @@ -124,6 +122,11 @@ class Api29Compatibility { } val collection = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) val mediaStoreFilePath = addContentValuesToCollection(context, filePath, collection, values, MediaStore.Images.Media.IS_PENDING) + + if (isVfsEncrypted) { + Log.w("[Media Store] Content was encrypted, delete plain version: $plainFilePath") + FileUtils.deleteFile(plainFilePath) + } if (mediaStoreFilePath.isNotEmpty()) { content.userData = mediaStoreFilePath return true @@ -132,7 +135,10 @@ class Api29Compatibility { } suspend fun addVideoToMediaStore(context: Context, content: Content): Boolean { - val filePath = content.filePath + val plainFilePath = content.plainFilePath.orEmpty() + val isVfsEncrypted = plainFilePath.isNotEmpty() + Log.w("[Media Store] Content is encrypted, requesting plain file path") + val filePath = if (isVfsEncrypted) plainFilePath else content.filePath if (filePath == null) { Log.e("[Media Store] Content doesn't have a file path!") return false @@ -153,6 +159,11 @@ class Api29Compatibility { } val collection = MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) val mediaStoreFilePath = addContentValuesToCollection(context, filePath, collection, values, MediaStore.Video.Media.IS_PENDING) + + if (isVfsEncrypted) { + Log.w("[Media Store] Content was encrypted, delete plain version: $plainFilePath") + FileUtils.deleteFile(plainFilePath) + } if (mediaStoreFilePath.isNotEmpty()) { content.userData = mediaStoreFilePath return true @@ -161,7 +172,10 @@ class Api29Compatibility { } suspend fun addAudioToMediaStore(context: Context, content: Content): Boolean { - val filePath = content.filePath + val plainFilePath = content.plainFilePath.orEmpty() + val isVfsEncrypted = plainFilePath.isNotEmpty() + Log.w("[Media Store] Content is encrypted, requesting plain file path") + val filePath = if (isVfsEncrypted) plainFilePath else content.filePath if (filePath == null) { Log.e("[Media Store] Content doesn't have a file path!") return false @@ -183,6 +197,11 @@ class Api29Compatibility { val collection = MediaStore.Audio.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) val mediaStoreFilePath = addContentValuesToCollection(context, filePath, collection, values, MediaStore.Audio.Media.IS_PENDING) + + if (isVfsEncrypted) { + Log.w("[Media Store] Content was encrypted, delete plain version: $plainFilePath") + FileUtils.deleteFile(plainFilePath) + } if (mediaStoreFilePath.isNotEmpty()) { content.userData = mediaStoreFilePath return true diff --git a/app/src/main/java/org/linphone/compatibility/Api30Compatibility.kt b/app/src/main/java/org/linphone/compatibility/Api30Compatibility.kt index dcf7d050b..70637bbd2 100644 --- a/app/src/main/java/org/linphone/compatibility/Api30Compatibility.kt +++ b/app/src/main/java/org/linphone/compatibility/Api30Compatibility.kt @@ -24,6 +24,7 @@ import android.annotation.TargetApi import android.app.Activity import android.content.Context import android.content.pm.ShortcutManager +import androidx.fragment.app.Fragment import org.linphone.core.ChatRoom import org.linphone.core.tools.Log import org.linphone.utils.LinphoneUtils @@ -41,8 +42,25 @@ class Api30Compatibility { return granted } - fun requestReadPhoneNumbersPermission(activity: Activity, code: Int) { - activity.requestPermissions(arrayOf(Manifest.permission.READ_PHONE_NUMBERS), code) + fun requestReadPhoneNumbersPermission(fragment: Fragment, code: Int) { + fragment.requestPermissions(arrayOf(Manifest.permission.READ_PHONE_NUMBERS), code) + } + + fun requestTelecomManagerPermission(activity: Activity, code: Int) { + activity.requestPermissions( + arrayOf( + Manifest.permission.READ_PHONE_NUMBERS, + Manifest.permission.READ_PHONE_STATE, + Manifest.permission.MANAGE_OWN_CALLS + ), + code + ) + } + + fun hasTelecomManagerPermission(context: Context): Boolean { + return Compatibility.hasPermission(context, Manifest.permission.READ_PHONE_NUMBERS) && + Compatibility.hasPermission(context, Manifest.permission.READ_PHONE_STATE) && + Compatibility.hasPermission(context, Manifest.permission.MANAGE_OWN_CALLS) } fun removeChatRoomShortcut(context: Context, chatRoom: ChatRoom) { diff --git a/app/src/main/java/org/linphone/compatibility/Api31Compatibility.kt b/app/src/main/java/org/linphone/compatibility/Api31Compatibility.kt new file mode 100644 index 000000000..82262693e --- /dev/null +++ b/app/src/main/java/org/linphone/compatibility/Api31Compatibility.kt @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2010-2021 Belledonne Communications SARL. + * + * This file is part of linphone-android + * (see https://www.linphone.org). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.linphone.compatibility + +import android.annotation.TargetApi +import android.app.Notification +import android.app.PendingIntent +import android.app.Person +import android.content.Context +import androidx.core.content.ContextCompat +import org.linphone.LinphoneApplication.Companion.coreContext +import org.linphone.LinphoneApplication.Companion.corePreferences +import org.linphone.R +import org.linphone.contact.Contact +import org.linphone.core.Call +import org.linphone.notifications.Notifiable +import org.linphone.notifications.NotificationsManager +import org.linphone.utils.ImageUtils +import org.linphone.utils.LinphoneUtils + +@TargetApi(31) +class Api31Compatibility { + companion object { + fun getUpdateCurrentPendingIntentFlag(): Int { + return PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE + } + + fun createIncomingCallNotification( + context: Context, + call: Call, + notifiable: Notifiable, + pendingIntent: PendingIntent, + notificationsManager: NotificationsManager + ): Notification { + val contact: Contact? = coreContext.contactsManager.findContactByAddress(call.remoteAddress) + val pictureUri = contact?.getContactThumbnailPictureUri() + val roundPicture = ImageUtils.getRoundBitmapFromUri(context, pictureUri) + val displayName = contact?.fullName ?: LinphoneUtils.getDisplayName(call.remoteAddress) + + val person = notificationsManager.getPerson(contact, displayName, roundPicture) + val caller = Person.Builder() + .setName(person.name) + .setIcon(person.icon?.toIcon(context)) + .setUri(person.uri) + .setKey(person.key) + .setImportant(person.isImportant) + .build() + val declineIntent = notificationsManager.getCallDeclinePendingIntent(notifiable) + val answerIntent = notificationsManager.getCallAnswerPendingIntent(notifiable) + val builder = Notification.Builder(context, context.getString(R.string.notification_channel_incoming_call_id)) + .setStyle(Notification.CallStyle.forIncomingCall(caller, declineIntent, answerIntent)) + .setSmallIcon(R.drawable.topbar_call_notification) + .setCategory(Notification.CATEGORY_CALL) + .setVisibility(Notification.VISIBILITY_PUBLIC) + .setWhen(System.currentTimeMillis()) + .setAutoCancel(false) + .setShowWhen(true) + .setOngoing(true) + .setColor(ContextCompat.getColor(context, R.color.primary_color)) + .setFullScreenIntent(pendingIntent, true) + + if (!corePreferences.preventInterfaceFromShowingUp) { + builder.setContentIntent(pendingIntent) + } + + return builder.build() + } + + fun createCallNotification( + context: Context, + call: Call, + notifiable: Notifiable, + pendingIntent: PendingIntent, + channel: String, + notificationsManager: NotificationsManager + ): Notification { + val contact: Contact? = coreContext.contactsManager.findContactByAddress(call.remoteAddress) + val pictureUri = contact?.getContactThumbnailPictureUri() + val roundPicture = ImageUtils.getRoundBitmapFromUri(context, pictureUri) + val displayName = contact?.fullName ?: LinphoneUtils.getDisplayName(call.remoteAddress) + + val isVideo = call.currentParams.isVideoEnabled + val iconResourceId: Int = when (call.state) { + Call.State.Paused, Call.State.Pausing, Call.State.PausedByRemote -> { + R.drawable.topbar_call_paused_notification + } + else -> { + if (isVideo) { + R.drawable.topbar_videocall_notification + } else { + R.drawable.topbar_call_notification + } + } + } + + val person = notificationsManager.getPerson(contact, displayName, roundPicture) + val caller = Person.Builder() + .setName(person.name) + .setIcon(person.icon?.toIcon(context)) + .setUri(person.uri) + .setKey(person.key) + .setImportant(person.isImportant) + .build() + val declineIntent = notificationsManager.getCallDeclinePendingIntent(notifiable) + val builder = Notification.Builder( + context, channel + ) + .setStyle(Notification.CallStyle.forOngoingCall(caller, declineIntent).setIsVideo(isVideo)) + .setSmallIcon(iconResourceId) + .setAutoCancel(false) + .setCategory(Notification.CATEGORY_CALL) + .setVisibility(Notification.VISIBILITY_PUBLIC) + .setWhen(System.currentTimeMillis()) + .setShowWhen(true) + .setOngoing(true) + .setColor(ContextCompat.getColor(context, R.color.notification_led_color)) + .setFullScreenIntent(pendingIntent, true) // This is required for CallStyle notification + + if (!corePreferences.preventInterfaceFromShowingUp) { + builder.setContentIntent(pendingIntent) + } + + return builder.build() + } + } +} diff --git a/app/src/main/java/org/linphone/compatibility/Compatibility.kt b/app/src/main/java/org/linphone/compatibility/Compatibility.kt index c1c23718a..e01d09c95 100644 --- a/app/src/main/java/org/linphone/compatibility/Compatibility.kt +++ b/app/src/main/java/org/linphone/compatibility/Compatibility.kt @@ -20,19 +20,27 @@ package org.linphone.compatibility import android.app.Activity +import android.app.Notification +import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.graphics.Bitmap import android.net.Uri +import android.os.Build import android.os.Vibrator import android.telephony.TelephonyManager import android.view.View import android.view.WindowManager import androidx.core.app.NotificationManagerCompat +import androidx.fragment.app.Fragment +import java.util.* +import org.linphone.core.Call import org.linphone.core.ChatRoom import org.linphone.core.Content import org.linphone.mediastream.Version +import org.linphone.notifications.Notifiable +import org.linphone.notifications.NotificationsManager import org.linphone.telecom.NativeCallWrapper @Suppress("DEPRECATION") @@ -55,11 +63,28 @@ class Compatibility { } // See https://developer.android.com/about/versions/11/privacy/permissions#phone-numbers - fun requestReadPhoneStateOrNumbersPermission(activity: Activity, code: Int) { + fun requestReadPhoneStateOrNumbersPermission(fragment: Fragment, code: Int) { if (Version.sdkAboveOrEqual(Version.API30_ANDROID_11)) { - Api30Compatibility.requestReadPhoneNumbersPermission(activity, code) + Api30Compatibility.requestReadPhoneNumbersPermission(fragment, code) } else { - Api29Compatibility.requestReadPhoneStatePermission(activity, code) + Api23Compatibility.requestReadPhoneStatePermission(fragment, code) + } + } + + // See https://developer.android.com/about/versions/11/privacy/permissions#phone-numbers + fun hasTelecomManagerPermissions(context: Context): Boolean { + return if (Version.sdkAboveOrEqual(Version.API30_ANDROID_11)) { + Api30Compatibility.hasTelecomManagerPermission(context) + } else { + Api26Compatibility.hasTelecomManagerPermission(context) + } + } + + fun requestTelecomManagerPermissions(activity: Activity, code: Int) { + if (Version.sdkAboveOrEqual(Version.API30_ANDROID_11)) { + Api30Compatibility.requestTelecomManagerPermission(activity, code) + } else { + Api26Compatibility.requestTelecomManagerPermission(activity, code) } } @@ -147,6 +172,43 @@ class Compatibility { return WindowManager.LayoutParams.TYPE_PHONE } + fun createIncomingCallNotification( + context: Context, + call: Call, + notifiable: Notifiable, + pendingIntent: PendingIntent, + notificationsManager: NotificationsManager + ): Notification { + if (Version.sdkAboveOrEqual(Version.API31_ANDROID_12)) { + return Api31Compatibility.createIncomingCallNotification(context, call, notifiable, pendingIntent, notificationsManager) + } else if (Build.MANUFACTURER.lowercase(Locale.getDefault()) == "xiaomi") { + return XiaomiCompatibility.createIncomingCallNotification(context, call, notifiable, pendingIntent, notificationsManager) + } + return Api26Compatibility.createIncomingCallNotification(context, call, notifiable, pendingIntent, notificationsManager) + } + + fun createCallNotification( + context: Context, + call: Call, + notifiable: Notifiable, + pendingIntent: PendingIntent, + channel: String, + notificationsManager: NotificationsManager + ): Notification { + if (Version.sdkAboveOrEqual(Version.API31_ANDROID_12)) { + return Api31Compatibility.createCallNotification(context, call, notifiable, pendingIntent, channel, notificationsManager) + } + return Api26Compatibility.createCallNotification(context, call, notifiable, pendingIntent, channel, notificationsManager) + } + + fun startForegroundService(context: Context, intent: Intent) { + if (Version.sdkAboveOrEqual(Version.API26_O_80)) { + Api26Compatibility.startForegroundService(context, intent) + } else { + Api21Compatibility.startForegroundService(context, intent) + } + } + /* Call */ fun canDrawOverlay(context: Context): Boolean { @@ -170,10 +232,11 @@ class Compatibility { } } - fun changeAudioRouteForTelecomManager(connection: NativeCallWrapper, route: Int) { + fun changeAudioRouteForTelecomManager(connection: NativeCallWrapper, route: Int): Boolean { if (Version.sdkAboveOrEqual(Version.API26_O_80)) { - Api26Compatibility.changeAudioRouteForTelecomManager(connection, route) + return Api26Compatibility.changeAudioRouteForTelecomManager(connection, route) } + return false } /* Contacts */ @@ -244,5 +307,19 @@ class Compatibility { } return Api21Compatibility.addAudioToMediaStore(context, content) } + + fun getUpdateCurrentPendingIntentFlag(): Int { + if (Version.sdkAboveOrEqual(Version.API31_ANDROID_12)) { + return Api31Compatibility.getUpdateCurrentPendingIntentFlag() + } + return Api21Compatibility.getUpdateCurrentPendingIntentFlag() + } + + fun getImeFlagsForSecureChatRoom(): Int { + if (Version.sdkAboveOrEqual(Version.API26_O_80)) { + return Api26Compatibility.getImeFlagsForSecureChatRoom() + } + return Api21Compatibility.getImeFlagsForSecureChatRoom() + } } } diff --git a/app/src/main/java/org/linphone/compatibility/TelephonyListener.kt b/app/src/main/java/org/linphone/compatibility/TelephonyListener.kt index b946cc94e..df96c50f6 100644 --- a/app/src/main/java/org/linphone/compatibility/TelephonyListener.kt +++ b/app/src/main/java/org/linphone/compatibility/TelephonyListener.kt @@ -33,7 +33,7 @@ class TelephonyListener(private val telephonyManager: TelephonyManager) : PhoneS private fun runOnUiThreadExecutor(): Executor { val handler = Handler(Looper.getMainLooper()) - return Executor() { + return Executor { handler.post(it) } } diff --git a/app/src/main/java/org/linphone/compatibility/XiaomiCompatibility.kt b/app/src/main/java/org/linphone/compatibility/XiaomiCompatibility.kt new file mode 100644 index 000000000..05b6a482c --- /dev/null +++ b/app/src/main/java/org/linphone/compatibility/XiaomiCompatibility.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2010-2021 Belledonne Communications SARL. + * + * This file is part of linphone-android + * (see https://www.linphone.org). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.linphone.compatibility + +import android.annotation.TargetApi +import android.app.* +import android.content.Context +import android.graphics.BitmapFactory +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import org.linphone.LinphoneApplication.Companion.coreContext +import org.linphone.LinphoneApplication.Companion.corePreferences +import org.linphone.R +import org.linphone.contact.Contact +import org.linphone.core.Call +import org.linphone.notifications.Notifiable +import org.linphone.notifications.NotificationsManager +import org.linphone.utils.ImageUtils +import org.linphone.utils.LinphoneUtils + +@TargetApi(26) +class XiaomiCompatibility { + companion object { + fun createIncomingCallNotification( + context: Context, + call: Call, + notifiable: Notifiable, + pendingIntent: PendingIntent, + notificationsManager: NotificationsManager + ): Notification { + val contact: Contact? = coreContext.contactsManager.findContactByAddress(call.remoteAddress) + val pictureUri = contact?.getContactThumbnailPictureUri() + val roundPicture = ImageUtils.getRoundBitmapFromUri(context, pictureUri) + val displayName = contact?.fullName ?: LinphoneUtils.getDisplayName(call.remoteAddress) + val address = LinphoneUtils.getDisplayableAddress(call.remoteAddress) + + val builder = NotificationCompat.Builder(context, context.getString(R.string.notification_channel_incoming_call_id)) + .addPerson(notificationsManager.getPerson(contact, displayName, roundPicture)) + .setSmallIcon(R.drawable.topbar_call_notification) + .setLargeIcon(roundPicture ?: BitmapFactory.decodeResource(context.resources, R.drawable.avatar)) + .setContentTitle(displayName) + .setContentText(address) + .setSubText(context.getString(R.string.incoming_call_notification_title)) + .setCategory(NotificationCompat.CATEGORY_CALL) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setWhen(System.currentTimeMillis()) + .setAutoCancel(false) + .setShowWhen(true) + .setOngoing(true) + .setColor(ContextCompat.getColor(context, R.color.primary_color)) + .setFullScreenIntent(pendingIntent, true) + .addAction(notificationsManager.getCallDeclineAction(notifiable)) + .addAction(notificationsManager.getCallAnswerAction(notifiable)) + + if (!corePreferences.preventInterfaceFromShowingUp) { + builder.setContentIntent(pendingIntent) + } + + return builder.build() + } + } +} diff --git a/app/src/main/java/org/linphone/contact/AsyncContactsLoader.kt b/app/src/main/java/org/linphone/contact/AsyncContactsLoader.kt index 3433f893b..b554d0a40 100644 --- a/app/src/main/java/org/linphone/contact/AsyncContactsLoader.kt +++ b/app/src/main/java/org/linphone/contact/AsyncContactsLoader.kt @@ -129,10 +129,10 @@ class AsyncContactsLoader(private val context: Context) : try { val id: String = - cursor.getString(cursor.getColumnIndex(ContactsContract.Data.CONTACT_ID)) + cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.Data.CONTACT_ID)) val starred = - cursor.getInt(cursor.getColumnIndex(ContactsContract.Contacts.STARRED)) == 1 - val lookupKey = cursor.getString(cursor.getColumnIndex(ContactsContract.Contacts.LOOKUP_KEY)) + cursor.getInt(cursor.getColumnIndexOrThrow(ContactsContract.Contacts.STARRED)) == 1 + val lookupKey = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.Contacts.LOOKUP_KEY)) var contact: Contact? = androidContactsCache[id] if (contact == null) { Log.d( @@ -148,6 +148,10 @@ class AsyncContactsLoader(private val context: Context) : Log.e( "[Contacts Loader] Couldn't get values from cursor, exception: $ise" ) + } catch (iae: IllegalArgumentException) { + Log.e( + "[Contacts Loader] Couldn't get values from cursor, exception: $iae" + ) } } cursor.close() diff --git a/app/src/main/java/org/linphone/contact/Contact.kt b/app/src/main/java/org/linphone/contact/Contact.kt index 2d370a1eb..8cba22e3a 100644 --- a/app/src/main/java/org/linphone/contact/Contact.kt +++ b/app/src/main/java/org/linphone/contact/Contact.kt @@ -51,10 +51,10 @@ open class Contact : Comparable { // Raw SIP addresses are only used for contact edition var rawSipAddresses = arrayListOf() - var thumbnailUri: Uri? = null - var friend: Friend? = null + private var thumbnailUri: Uri? = null + override fun compareTo(other: Contact): Int { val fn = fullName ?: "" val otherFn = other.fullName ?: "" @@ -151,7 +151,7 @@ open class Contact : Comparable { if (bm == null) IconCompat.createWithResource( coreContext.context, R.drawable.avatar - ) else IconCompat.createWithBitmap(bm) + ) else IconCompat.createWithAdaptiveBitmap(bm) if (icon != null) { personBuilder.setIcon(icon) } diff --git a/app/src/main/java/org/linphone/contact/ContactsManager.kt b/app/src/main/java/org/linphone/contact/ContactsManager.kt index e5c28ef6e..fdc2af0d8 100644 --- a/app/src/main/java/org/linphone/contact/ContactsManager.kt +++ b/app/src/main/java/org/linphone/contact/ContactsManager.kt @@ -79,12 +79,6 @@ class ContactsManager(private val context: Context) { @Synchronized private set - var localAccountsContacts = ArrayList() - @Synchronized - get - @Synchronized - private set - val magicSearch: MagicSearch by lazy { val magicSearch = coreContext.core.createMagicSearch() magicSearch.limitedSearch = false @@ -93,6 +87,12 @@ class ContactsManager(private val context: Context) { var latestContactFetch: String = "" + private var localAccountsContacts = ArrayList() + @Synchronized + get + @Synchronized + private set + private val friendsMap: HashMap = HashMap() private val contactsUpdatedListeners = ArrayList() diff --git a/app/src/main/java/org/linphone/contact/DummyAuthenticationService.kt b/app/src/main/java/org/linphone/contact/DummyAuthenticationService.kt index 3cc3cf4b0..001568cab 100644 --- a/app/src/main/java/org/linphone/contact/DummyAuthenticationService.kt +++ b/app/src/main/java/org/linphone/contact/DummyAuthenticationService.kt @@ -28,6 +28,7 @@ import android.content.Context import android.content.Intent import android.os.Bundle import android.os.IBinder +import org.linphone.core.tools.Log // Below classes are required to be able to create our DummySyncService... internal class DummyAuthenticator(context: Context) : AbstractAccountAuthenticator(context) { @@ -88,6 +89,7 @@ class DummyAuthenticationService : Service() { override fun onCreate() { authenticator = DummyAuthenticator(this) + Log.i("[Dummy Auth Service] Authenticator created") } override fun onBind(intent: Intent): IBinder { diff --git a/app/src/main/java/org/linphone/contact/DummySyncService.kt b/app/src/main/java/org/linphone/contact/DummySyncService.kt index 60cec9642..a040dafca 100644 --- a/app/src/main/java/org/linphone/contact/DummySyncService.kt +++ b/app/src/main/java/org/linphone/contact/DummySyncService.kt @@ -24,6 +24,7 @@ import android.app.Service import android.content.* import android.os.Bundle import android.os.IBinder +import org.linphone.core.tools.Log // Below classes are required to be able to use our own contact MIME type entry... class DummySyncAdapter(context: Context, autoInit: Boolean) : AbstractThreadedSyncAdapter(context, autoInit) { @@ -44,8 +45,10 @@ class DummySyncService : Service() { override fun onCreate() { synchronized(syncAdapterLock) { + Log.i("[Dummy Sync Adapter] Sync Service created") if (syncAdapter == null) { syncAdapter = DummySyncAdapter(applicationContext, true) + Log.i("[Dummy Sync Adapter] Sync Adapter created") } } } diff --git a/app/src/main/java/org/linphone/contact/NativeContact.kt b/app/src/main/java/org/linphone/contact/NativeContact.kt index b2a109227..c28f6fd6b 100644 --- a/app/src/main/java/org/linphone/contact/NativeContact.kt +++ b/app/src/main/java/org/linphone/contact/NativeContact.kt @@ -71,7 +71,7 @@ class NativeContact(val nativeId: String, private val lookupKey: String? = null) if (bm == null) IconCompat.createWithResource( coreContext.context, R.drawable.avatar - ) else IconCompat.createWithBitmap(bm) + ) else IconCompat.createWithAdaptiveBitmap(bm) if (icon != null) { personBuilder.setIcon(icon) } @@ -86,91 +86,104 @@ class NativeContact(val nativeId: String, private val lookupKey: String? = null) @Synchronized override fun syncValuesFromAndroidCursor(cursor: Cursor) { - val displayName: String? = - cursor.getString(cursor.getColumnIndex(ContactsContract.Data.DISPLAY_NAME_PRIMARY)) + try { + val displayName: String? = + cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.Data.DISPLAY_NAME_PRIMARY)) - val mime: String? = cursor.getString(cursor.getColumnIndex(ContactsContract.Data.MIMETYPE)) - val data1: String? = cursor.getString(cursor.getColumnIndex("data1")) - val data2: String? = cursor.getString(cursor.getColumnIndex("data2")) - val data3: String? = cursor.getString(cursor.getColumnIndex("data3")) - val data4: String? = cursor.getString(cursor.getColumnIndex("data4")) + val mime: String? = + cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.Data.MIMETYPE)) + val data1: String? = cursor.getString(cursor.getColumnIndexOrThrow("data1")) + val data2: String? = cursor.getString(cursor.getColumnIndexOrThrow("data2")) + val data3: String? = cursor.getString(cursor.getColumnIndexOrThrow("data3")) + val data4: String? = cursor.getString(cursor.getColumnIndexOrThrow("data4")) - if (fullName == null || fullName != displayName) { - Log.d("[Native Contact] Setting display name $displayName") - fullName = displayName - } + if (fullName == null || fullName != displayName) { + Log.d("[Native Contact] Setting display name $displayName") + fullName = displayName + } - val linphoneMime = AppUtils.getString(R.string.linphone_address_mime_type) - when (mime) { - ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE -> { - if (data1 == null && data4 == null) { - Log.d("[Native Contact] Phone number data is empty") - return - } + val linphoneMime = AppUtils.getString(R.string.linphone_address_mime_type) + when (mime) { + ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE -> { + if (data1 == null && data4 == null) { + Log.d("[Native Contact] Phone number data is empty") + return + } - val labelColumnIndex = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.LABEL) - val label: String? = cursor.getString(labelColumnIndex) - val typeColumnIndex = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.TYPE) - val type: Int = cursor.getInt(typeColumnIndex) - val typeLabel = ContactsContract.CommonDataKinds.Phone.getTypeLabel( - coreContext.context.resources, - type, - label - ).toString() + val labelColumnIndex = + cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.LABEL) + val label: String? = cursor.getString(labelColumnIndex) + val typeColumnIndex = + cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.TYPE) + val type: Int = cursor.getInt(typeColumnIndex) + val typeLabel = ContactsContract.CommonDataKinds.Phone.getTypeLabel( + coreContext.context.resources, + type, + label + ).toString() - val number = data4 ?: data1 - if (number != null && number.isNotEmpty()) { - Log.d("[Native Contact] Found phone number $data1 ($data4), type label is $typeLabel") - if (!rawPhoneNumbers.contains(number)) { - phoneNumbers.add(PhoneNumber(number, typeLabel)) - rawPhoneNumbers.add(number) + // data4 = ContactsContract.CommonDataKinds.Phone.NORMALIZED_NUMBER + // data1 = ContactsContract.CommonDataKinds.Phone.NUMBER + val number = if (corePreferences.preferNormalizedPhoneNumbersFromAddressBook) { + data4 ?: data1 + } else { + data1 ?: data4 + } + if (number != null && number.isNotEmpty()) { + Log.d("[Native Contact] Found phone number $data1 ($data4), type label is $typeLabel") + if (!rawPhoneNumbers.contains(number)) { + phoneNumbers.add(PhoneNumber(number, typeLabel)) + rawPhoneNumbers.add(number) + } } } - } - linphoneMime, ContactsContract.CommonDataKinds.SipAddress.CONTENT_ITEM_TYPE -> { - if (data1 == null) { - Log.d("[Native Contact] SIP address is null") - return - } + linphoneMime, ContactsContract.CommonDataKinds.SipAddress.CONTENT_ITEM_TYPE -> { + if (data1 == null) { + Log.d("[Native Contact] SIP address is null") + return + } - Log.d("[Native Contact] Found SIP address $data1") - if (rawPhoneNumbers.contains(data1)) { - Log.d("[Native Contact] SIP address value already exists in phone numbers list, skipping") - return - } + Log.d("[Native Contact] Found SIP address $data1") + if (rawPhoneNumbers.contains(data1)) { + Log.d("[Native Contact] SIP address value already exists in phone numbers list, skipping") + return + } - val address: Address? = coreContext.core.interpretUrl(data1) - if (address == null) { - Log.e("[Native Contact] Couldn't parse address $data1 !") - return - } + val address: Address? = coreContext.core.interpretUrl(data1) + if (address == null) { + Log.e("[Native Contact] Couldn't parse address $data1 !") + return + } - val stringAddress = address.asStringUriOnly() - Log.d("[Native Contact] Found SIP address $stringAddress") - if (!rawSipAddresses.contains(data1)) { - sipAddresses.add(address) - rawSipAddresses.add(data1) + val stringAddress = address.asStringUriOnly() + Log.d("[Native Contact] Found SIP address $stringAddress") + if (!rawSipAddresses.contains(data1)) { + sipAddresses.add(address) + rawSipAddresses.add(data1) + } + } + ContactsContract.CommonDataKinds.Organization.CONTENT_ITEM_TYPE -> { + if (data1 == null) { + Log.d("[Native Contact] Organization is null") + return + } + + Log.d("[Native Contact] Found organization $data1") + organization = data1 + } + ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE -> { + if (data2 == null && data3 == null) { + Log.d("[Native Contact] First name and last name are both null") + return + } + + Log.d("[Native Contact] Found first name $data2 and last name $data3") + firstName = data2 + lastName = data3 } } - ContactsContract.CommonDataKinds.Organization.CONTENT_ITEM_TYPE -> { - if (data1 == null) { - Log.d("[Native Contact] Organization is null") - return - } - - Log.d("[Native Contact] Found organization $data1") - organization = data1 - } - ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE -> { - if (data2 == null && data3 == null) { - Log.d("[Native Contact] First name and last name are both null") - return - } - - Log.d("[Native Contact] Found first name $data2 and last name $data3") - firstName = data2 - lastName = data3 - } + } catch (iae: IllegalArgumentException) { + Log.e("[Native Contact] Exception: $iae") } } @@ -179,7 +192,7 @@ class NativeContact(val nativeId: String, private val lookupKey: String? = null) var created = false if (friend == null) { val friend = coreContext.core.createFriend() - friend.enableSubscribes(false) + friend.isSubscribesEnabled = false friend.incSubscribePolicy = SubscribePolicy.SPDeny friend.refKey = nativeId friend.userData = this diff --git a/app/src/main/java/org/linphone/contact/NativeContactEditor.kt b/app/src/main/java/org/linphone/contact/NativeContactEditor.kt index 84f900e74..630cb9703 100644 --- a/app/src/main/java/org/linphone/contact/NativeContactEditor.kt +++ b/app/src/main/java/org/linphone/contact/NativeContactEditor.kt @@ -96,8 +96,12 @@ class NativeContactEditor(val contact: NativeContact) { if (cursor?.moveToFirst() == true) { do { if (rawId == null) { - rawId = cursor.getString(cursor.getColumnIndex(RawContacts._ID)) - Log.i("[Native Contact Editor] Found raw id $rawId for native contact with id ${contact.nativeId}") + try { + rawId = cursor.getString(cursor.getColumnIndexOrThrow(RawContacts._ID)) + Log.i("[Native Contact Editor] Found raw id $rawId for native contact with id ${contact.nativeId}") + } catch (iae: IllegalArgumentException) { + Log.e("[Native Contact Editor] Exception: $iae") + } } } while (cursor.moveToNext() && rawId == null) } @@ -258,11 +262,16 @@ class NativeContactEditor(val contact: NativeContact) { ) if (cursor?.moveToFirst() == true) { do { - val accountType = - cursor.getString(cursor.getColumnIndex(RawContacts.ACCOUNT_TYPE)) - if (accountType == AppUtils.getString(R.string.sync_account_type) && syncAccountRawId == null) { - syncAccountRawId = cursor.getString(cursor.getColumnIndex(RawContacts._ID)) - Log.d("[Native Contact Editor] Found linphone raw id $syncAccountRawId for native contact with id ${contact.nativeId}") + try { + val accountType = + cursor.getString(cursor.getColumnIndexOrThrow(RawContacts.ACCOUNT_TYPE)) + if (accountType == AppUtils.getString(R.string.sync_account_type) && syncAccountRawId == null) { + syncAccountRawId = + cursor.getString(cursor.getColumnIndexOrThrow(RawContacts._ID)) + Log.d("[Native Contact Editor] Found linphone raw id $syncAccountRawId for native contact with id ${contact.nativeId}") + } + } catch (iae: IllegalArgumentException) { + Log.e("[Native Contact Editor] Exception: $iae") } } while (cursor.moveToNext() && syncAccountRawId == null) } @@ -461,7 +470,11 @@ class NativeContactEditor(val contact: NativeContact) { val count = cursor?.count ?: 0 val data1 = if (count > 0) { if (cursor?.moveToFirst() == true) { - cursor.getString(cursor.getColumnIndex("data1")) + try { + cursor.getString(cursor.getColumnIndexOrThrow("data1")) + } catch (iae: IllegalArgumentException) { + Log.e("[Native Contact Editor] Exception: $iae") + } } else null } else null cursor?.close() diff --git a/app/src/main/java/org/linphone/core/CoreContext.kt b/app/src/main/java/org/linphone/core/CoreContext.kt index bb2197f9a..d098d076d 100644 --- a/app/src/main/java/org/linphone/core/CoreContext.kt +++ b/app/src/main/java/org/linphone/core/CoreContext.kt @@ -87,7 +87,7 @@ class CoreContext(val context: Context, coreConfig: Config) { "$sdkVersion ($sdkBranch, $sdkBuildType)" } - val collator = Collator.getInstance() + val collator: Collator = Collator.getInstance() val contactsManager: ContactsManager by lazy { ContactsManager(context) } @@ -136,29 +136,9 @@ class CoreContext(val context: Context, coreConfig: Config) { ) { Log.i("[Context] Call state changed [$state]") if (state == Call.State.IncomingReceived || state == Call.State.IncomingEarlyMedia) { - if (!corePreferences.useTelecomManager) { // Can't use the following call with Telecom Manager API as it will "fake" GSM calls - var gsmCallActive = false - if (::phoneStateListener.isInitialized) { - gsmCallActive = phoneStateListener.isInCall() - } - - if (gsmCallActive) { - Log.w("[Context] Refusing the call with reason busy because a GSM call is active") - call.decline(Reason.Busy) - return - } - } else { - if (TelecomHelper.exists()) { - if (!TelecomHelper.get().isIncomingCallPermitted() || - TelecomHelper.get().isInManagedCall() - ) { - Log.w("[Context] Refusing the call with reason busy because Telecom Manager will reject the call") - call.decline(Reason.Busy) - return - } - } else { - Log.e("[Context] Telecom Manager singleton wasn't created!") - } + if (declineCallDueToGsmActiveCall()) { + call.decline(Reason.Busy) + return } // Starting SDK 24 (Android 7.0) we rely on the fullscreen intent of the call incoming notification @@ -209,7 +189,7 @@ class CoreContext(val context: Context, coreConfig: Config) { } } - if (corePreferences.routeAudioToSpeakerWhenVideoIsEnabled && call.currentParams.videoEnabled()) { + if (corePreferences.routeAudioToSpeakerWhenVideoIsEnabled && call.currentParams.isVideoEnabled) { // Do not turn speaker on when video is enabled if headset or bluetooth is used if (!AudioRouteUtils.isHeadsetAudioRouteAvailable() && !AudioRouteUtils.isBluetoothAudioRouteCurrentlyUsed( call @@ -306,15 +286,19 @@ class CoreContext(val context: Context, coreConfig: Config) { fun start(isPush: Boolean = false) { Log.i("[Context] Starting") - notificationsManager.onCoreReady() - core.addListener(listener) // CoreContext listener must be added first! if (Version.sdkAboveOrEqual(Version.API26_O_80) && corePreferences.useTelecomManager) { - Log.i("[Context] Creating TelecomHelper, disabling audio focus requests in AudioHelper") - core.config.setBool("audio", "android_disable_audio_focus_requests", true) - TelecomHelper.create(context) + if (Compatibility.hasTelecomManagerPermissions(context)) { + Log.i("[Context] Creating Telecom Helper, disabling audio focus requests in AudioHelper") + core.config.setBool("audio", "android_disable_audio_focus_requests", true) + val telecomHelper = TelecomHelper.required(context) + Log.i("[Context] Telecom Helper created, account is ${if (telecomHelper.isAccountEnabled()) "enabled" else "disabled"}") + } else { + Log.w("[Context] Can't create Telecom Helper, permissions have been revoked") + corePreferences.useTelecomManager = false + } } if (isPush) { @@ -322,14 +306,27 @@ class CoreContext(val context: Context, coreConfig: Config) { core.enterBackground() } - core.start() - configureCore() + core.start() + initPhoneStateListener() + notificationsManager.onCoreReady() + EmojiCompat.init(BundledEmojiCompatConfig(context)) collator.strength = Collator.NO_DECOMPOSITION + + if (corePreferences.vfsEnabled) { + FileUtils.clearExistingPlainFiles() + } + + if (corePreferences.keepServiceAlive) { + Log.i("[Context] Background mode setting is enabled, starting Service") + notificationsManager.startForeground() + } + + Log.i("[Context] Started") } fun stop() { @@ -344,6 +341,7 @@ class CoreContext(val context: Context, coreConfig: Config) { if (TelecomHelper.exists()) { Log.i("[Context] Destroying telecom helper") TelecomHelper.get().destroy() + TelecomHelper.destroy() } core.stop() @@ -387,6 +385,12 @@ class CoreContext(val context: Context, coreConfig: Config) { core.limeX3DhServerUrl = url } } + + // Ensure we allow CPIM messages in basic chat rooms + val newParams = account.params.clone() + newParams.isCpimInBasicChatRoomEnabled = true + account.params = newParams + Log.i("[Context] CPIM allowed in basic chat rooms for account ${newParams.identityAddress?.asStringUriOnly()}") } } @@ -432,15 +436,41 @@ class CoreContext(val context: Context, coreConfig: Config) { } } + fun declineCallDueToGsmActiveCall(): Boolean { + if (!corePreferences.useTelecomManager) { // Can't use the following call with Telecom Manager API as it will "fake" GSM calls + var gsmCallActive = false + if (::phoneStateListener.isInitialized) { + gsmCallActive = phoneStateListener.isInCall() + } + + if (gsmCallActive) { + Log.w("[Context] Refusing the call with reason busy because a GSM call is active") + return true + } + } else { + if (TelecomHelper.exists()) { + if (!TelecomHelper.get().isIncomingCallPermitted() || + TelecomHelper.get().isInManagedCall() + ) { + Log.w("[Context] Refusing the call with reason busy because Telecom Manager will reject the call") + return true + } + } else { + Log.e("[Context] Telecom Manager singleton wasn't created!") + } + } + return false + } + fun answerCallVideoUpdateRequest(call: Call, accept: Boolean) { val params = core.createCallParams(call) if (accept) { - params?.enableVideo(true) - core.enableVideoCapture(true) - core.enableVideoDisplay(true) + params?.isVideoEnabled = true + core.isVideoCaptureEnabled = true + core.isVideoDisplayEnabled = true } else { - params?.enableVideo(false) + params?.isVideoEnabled = false } call.acceptUpdate(params) @@ -452,7 +482,7 @@ class CoreContext(val context: Context, coreConfig: Config) { params?.recordFile = LinphoneUtils.getRecordingFilePathForAddress(call.remoteAddress) if (LinphoneUtils.checkIfNetworkHasLowBandwidth(context)) { Log.w("[Context] Enabling low bandwidth mode!") - params?.enableLowBandwidth(true) + params?.isLowBandwidthEnabled = true } call.acceptWithParams(params) } @@ -529,7 +559,7 @@ class CoreContext(val context: Context, coreConfig: Config) { } if (LinphoneUtils.checkIfNetworkHasLowBandwidth(context)) { Log.w("[Context] Enabling low bandwidth mode!") - params.enableLowBandwidth(true) + params.isLowBandwidthEnabled = true } params.recordFile = LinphoneUtils.getRecordingFilePathForAddress(address) @@ -543,7 +573,7 @@ class CoreContext(val context: Context, coreConfig: Config) { } if (corePreferences.sendEarlyMedia) { - params.enableEarlyMediaSending(true) + params.isEarlyMediaSendingEnabled = true } val call = core.inviteAddressWithParams(address, params) @@ -582,7 +612,7 @@ class CoreContext(val context: Context, coreConfig: Config) { return if (conference != null && conference.isIn) { conference.currentParams.isVideoEnabled } else { - core.currentCall?.currentParams?.videoEnabled() ?: false + core.currentCall?.currentParams?.isVideoEnabled ?: false } } diff --git a/app/src/main/java/org/linphone/core/CorePreferences.kt b/app/src/main/java/org/linphone/core/CorePreferences.kt index f66c22aa3..e69d23f9b 100644 --- a/app/src/main/java/org/linphone/core/CorePreferences.kt +++ b/app/src/main/java/org/linphone/core/CorePreferences.kt @@ -317,8 +317,15 @@ class CorePreferences constructor(private val context: Context) { config.setBool("audio", "android_disable_audio_focus_requests", value) } + // We will try to auto enable Telecom Manager feature, but in case user disables it don't try again + var manuallyDisabledTelecomManager: Boolean + get() = config.getBool("app", "user_disabled_self_managed_telecom_manager", false) + set(value) { + config.setBool("app", "user_disabled_self_managed_telecom_manager", value) + } + var fullScreenCallUI: Boolean - get() = config.getBool("app", "full_screen_call", true) + get() = config.getBool("app", "full_screen_call", false) set(value) { config.setBool("app", "full_screen_call", value) } @@ -424,6 +431,11 @@ class CorePreferences constructor(private val context: Context) { val fetchContactsFromDefaultDirectory: Boolean get() = config.getBool("app", "fetch_contacts_from_default_directory", true) + // From Android Contact APIs we can also retrieve the internationalized phone number + // By default we display the same value as the native address book app + val preferNormalizedPhoneNumbersFromAddressBook: Boolean + get() = config.getBool("app", "prefer_normalized_phone_numbers_from_address_book", false) + val hideStaticImageCamera: Boolean get() = config.getBool("app", "hide_static_image_camera", true) @@ -448,6 +460,11 @@ class CorePreferences constructor(private val context: Context) { val useEphemeralPerDeviceMode: Boolean get() = config.getBool("app", "ephemeral_chat_messages_settings_per_device", true) + // If enabled user will see all ringtones bundled in our SDK + // and will be able to choose which one to use if not using it's device's default + val showAllRingtones: Boolean + get() = config.getBool("app", "show_all_available_ringtones", false) + /* Default values related */ val echoCancellerCalibration: Int @@ -566,8 +583,11 @@ class CorePreferences constructor(private val context: Context) { val defaultValuesPath: String get() = context.filesDir.absolutePath + "/assistant_default_values" - val ringtonePath: String - get() = context.filesDir.absolutePath + "/share/sounds/linphone/rings/notes_of_the_optimistic.mkv" + val ringtonesPath: String + get() = context.filesDir.absolutePath + "/share/sounds/linphone/rings/" + + val defaultRingtonePath: String + get() = ringtonesPath + "notes_of_the_optimistic.mkv" val userCertificatesPath: String get() = context.filesDir.absolutePath + "/user-certs" diff --git a/app/src/main/java/org/linphone/core/CoreService.kt b/app/src/main/java/org/linphone/core/CoreService.kt index 1eeac37da..56411a98c 100644 --- a/app/src/main/java/org/linphone/core/CoreService.kt +++ b/app/src/main/java/org/linphone/core/CoreService.kt @@ -67,6 +67,8 @@ class CoreService : CoreService() { } else { Log.w("[Service] Task removed but Core in not in background, skipping") } + } else { + Log.i("[Service] Task removed but we were asked to keep the service alive, so doing nothing") } super.onTaskRemoved(rootIntent) diff --git a/app/src/main/java/org/linphone/notifications/NotificationBroadcastReceiver.kt b/app/src/main/java/org/linphone/notifications/NotificationBroadcastReceiver.kt index f0161a4c3..5215bdf2d 100644 --- a/app/src/main/java/org/linphone/notifications/NotificationBroadcastReceiver.kt +++ b/app/src/main/java/org/linphone/notifications/NotificationBroadcastReceiver.kt @@ -19,6 +19,7 @@ */ package org.linphone.notifications +import android.app.NotificationManager import android.app.RemoteInput import android.content.BroadcastReceiver import android.content.Context @@ -33,13 +34,13 @@ class NotificationBroadcastReceiver : BroadcastReceiver() { val notificationId = intent.getIntExtra(NotificationsManager.INTENT_NOTIF_ID, 0) if (intent.action == NotificationsManager.INTENT_REPLY_NOTIF_ACTION || intent.action == NotificationsManager.INTENT_MARK_AS_READ_ACTION) { - handleChatIntent(intent, notificationId) + handleChatIntent(context, intent, notificationId) } else if (intent.action == NotificationsManager.INTENT_ANSWER_CALL_NOTIF_ACTION || intent.action == NotificationsManager.INTENT_HANGUP_CALL_NOTIF_ACTION) { handleCallIntent(intent) } } - private fun handleChatIntent(intent: Intent, notificationId: Int) { + private fun handleChatIntent(context: Context, intent: Intent, notificationId: Int) { val remoteSipAddress = intent.getStringExtra(NotificationsManager.INTENT_REMOTE_ADDRESS) if (remoteSipAddress == null) { Log.e("[Notification Broadcast Receiver] Remote SIP address is null for notification id $notificationId") @@ -84,7 +85,11 @@ class NotificationBroadcastReceiver : BroadcastReceiver() { msg.send() Log.i("[Notification Broadcast Receiver] Reply sent for notif id $notificationId") } else { - coreContext.notificationsManager.dismissChatNotification(room) + if (!coreContext.notificationsManager.dismissChatNotification(room)) { + Log.w("[Notification Broadcast Receiver] Notifications Manager failed to cancel notification") + val notificationManager = context.getSystemService(NotificationManager::class.java) + notificationManager.cancel(NotificationsManager.CHAT_TAG, notificationId) + } } } @@ -107,7 +112,13 @@ class NotificationBroadcastReceiver : BroadcastReceiver() { if (intent.action == NotificationsManager.INTENT_ANSWER_CALL_NOTIF_ACTION) { coreContext.answerCall(call) } else { - if (call.state == Call.State.IncomingReceived || call.state == Call.State.IncomingEarlyMedia) coreContext.declineCall(call) else coreContext.terminateCall(call) + if (call.state == Call.State.IncomingReceived || + call.state == Call.State.IncomingEarlyMedia + ) { + coreContext.declineCall(call) + } else { + coreContext.terminateCall(call) + } } } diff --git a/app/src/main/java/org/linphone/notifications/NotificationsManager.kt b/app/src/main/java/org/linphone/notifications/NotificationsManager.kt index 28c26b141..5d462323d 100644 --- a/app/src/main/java/org/linphone/notifications/NotificationsManager.kt +++ b/app/src/main/java/org/linphone/notifications/NotificationsManager.kt @@ -20,6 +20,7 @@ package org.linphone.notifications import android.app.Notification +import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent @@ -27,7 +28,6 @@ import android.graphics.Bitmap import android.net.Uri import android.os.Bundle import android.webkit.MimeTypeMap -import android.widget.RemoteViews import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.Person @@ -55,7 +55,7 @@ import org.linphone.utils.FileUtils import org.linphone.utils.ImageUtils import org.linphone.utils.LinphoneUtils -private class Notifiable(val notificationId: Int) { +class Notifiable(val notificationId: Int) { val messages: ArrayList = arrayListOf() var isGroup: Boolean = false @@ -65,7 +65,7 @@ private class Notifiable(val notificationId: Int) { var remoteAddress: String? = null } -private class NotifiableMessage( +class NotifiableMessage( var message: String, val contact: Contact?, val sender: String, @@ -90,6 +90,9 @@ class NotificationsManager(private val context: Context) { private const val SERVICE_NOTIF_ID = 1 private const val MISSED_CALLS_NOTIF_ID = 2 + + const val CHAT_TAG = "Chat" + private const val MISSED_CALL_TAG = "Missed call" } private val notificationManager: NotificationManagerCompat by lazy { @@ -97,6 +100,8 @@ class NotificationsManager(private val context: Context) { } private val chatNotificationsMap: HashMap = HashMap() private val callNotificationsMap: HashMap = HashMap() + private val previousChatNotifications: ArrayList = arrayListOf() + private val chatBubbleNotifications: ArrayList = arrayListOf() private var currentForegroundServiceNotificationId: Int = 0 private var serviceNotification: Notification? = null @@ -105,8 +110,6 @@ class NotificationsManager(private val context: Context) { var currentlyDisplayedChatRoomAddress: String? = null - var dismissNotificationUponReadChatRoom: Boolean = true - private val listener: CoreListenerStub = object : CoreListenerStub() { override fun onCallStateChanged( core: Core, @@ -121,7 +124,7 @@ class NotificationsManager(private val context: Context) { return } - when (state) { + when (call.state) { Call.State.IncomingEarlyMedia, Call.State.IncomingReceived -> displayIncomingCallNotification(call) Call.State.End, Call.State.Error -> dismissCallNotification(call) Call.State.Released -> { @@ -168,11 +171,23 @@ class NotificationsManager(private val context: Context) { } override fun onChatRoomRead(core: Core, chatRoom: ChatRoom) { - if (dismissNotificationUponReadChatRoom) { - Log.i("[Notifications Manager] Chat room [$chatRoom] has been marked as read, removing notification if any") - dismissChatNotification(chatRoom) + val address = chatRoom.peerAddress.asStringUriOnly() + val notifiable = chatNotificationsMap[address] + if (notifiable != null) { + if (chatBubbleNotifications.contains(notifiable.notificationId)) { + Log.i("[Notifications Manager] Chat room [$chatRoom] has been marked as read, not removing notification because of a chat bubble") + } else { + Log.i("[Notifications Manager] Chat room [$chatRoom] has been marked as read, removing notification if any") + dismissChatNotification(chatRoom) + } } else { - Log.i("[Notifications Manager] Chat room [$chatRoom] has been marked as read, not removing notification, maybe because of a chat bubble?") + val notificationId = chatRoom.creationTime.toInt() + if (chatBubbleNotifications.contains(notificationId)) { + Log.i("[Notifications Manager] Chat room [$chatRoom] has been marked as read but no notifiable found, not removing notification because of a chat bubble") + } else { + Log.i("[Notifications Manager] Chat room [$chatRoom] has been marked as read but no notifiable found, removing notification if any") + dismissChatNotification(chatRoom) + } } } } @@ -198,17 +213,28 @@ class NotificationsManager(private val context: Context) { displayReplyMessageNotification(message, notifiable) } else { Log.e("[Notifications Manager] Couldn't find notification for chat room $address") - cancel(id) + cancel(id, CHAT_TAG) } } else if (state == ChatMessage.State.NotDelivered) { Log.e("[Notifications Manager] Reply wasn't delivered") - cancel(id) + cancel(id, CHAT_TAG) } } } init { Compatibility.createNotificationChannels(context, notificationManager) + + val manager = context.getSystemService(NotificationManager::class.java) as NotificationManager + for (notification in manager.activeNotifications) { + if (notification.tag.isNullOrEmpty()) { // We use null tag for call notifications otherwise it will create duplicates when used with Service.startForeground()... + Log.w("[Notifications Manager] Found existing call? notification [${notification.id}], cancelling it") + manager.cancel(notification.tag, notification.id) + } else if (notification.tag == CHAT_TAG) { + Log.i("[Notifications Manager] Found existing chat notification [${notification.id}]") + previousChatNotifications.add(notification.id) + } + } } fun onCoreReady() { @@ -222,26 +248,29 @@ class NotificationsManager(private val context: Context) { // causing the notification to be missed by the user... Log.i("[Notifications Manager] Getting destroyed, clearing foreground Service & call notifications") - if (currentForegroundServiceNotificationId > 0) { - notificationManager.cancel(currentForegroundServiceNotificationId) + if (currentForegroundServiceNotificationId > 0 && !corePreferences.keepServiceAlive) { + Log.i("[Notifications Manager] Clearing foreground Service") + stopForegroundNotification() } - for (notifiable in callNotificationsMap.values) { - notificationManager.cancel(notifiable.notificationId) + if (callNotificationsMap.size > 0) { + Log.i("[Notifications Manager] Clearing call notifications") + for (notifiable in callNotificationsMap.values) { + notificationManager.cancel(notifiable.notificationId) + } } - stopForegroundNotification() coreContext.core.removeListener(listener) } - private fun notify(id: Int, notification: Notification) { - Log.i("[Notifications Manager] Notifying $id") - notificationManager.notify(id, notification) + private fun notify(id: Int, notification: Notification, tag: String? = null) { + Log.i("[Notifications Manager] Notifying [$id] with tag [$tag]") + notificationManager.notify(tag, id, notification) } - fun cancel(id: Int) { - Log.i("[Notifications Manager] Canceling $id") - notificationManager.cancel(id) + fun cancel(id: Int, tag: String? = null) { + Log.i("[Notifications Manager] Canceling [$id] with tag [$tag]") + notificationManager.cancel(tag, id) } fun resetChatNotificationCounterForSipUri(sipUri: String) { @@ -259,7 +288,13 @@ class NotificationsManager(private val context: Context) { Log.w("[Notifications Manager] Can't start service as foreground without a service, starting it now") val intent = Intent() intent.setClass(coreContext.context, CoreService::class.java) - coreContext.context.startService(intent) + try { + Compatibility.startForegroundService(coreContext.context, intent) + } catch (ise: IllegalStateException) { + Log.e("[Notifications Manager] Failed to start Service: $ise") + } catch (se: SecurityException) { + Log.e("[Notifications Manager] Failed to start Service: $se") + } } } @@ -267,34 +302,41 @@ class NotificationsManager(private val context: Context) { service = coreService when { currentForegroundServiceNotificationId != 0 -> { - Log.e("[Notifications Manager] There is already a foreground service notification") + if (currentForegroundServiceNotificationId != SERVICE_NOTIF_ID) { + Log.e("[Notifications Manager] There is already a foreground service notification [$currentForegroundServiceNotificationId]") + } else { + Log.i("[Notifications Manager] There is already a foreground service notification, no need to use the call notification to keep Service alive") + } } coreContext.core.callsNb > 0 -> { // When this method will be called, we won't have any notification yet val call = coreContext.core.currentCall ?: coreContext.core.calls[0] when (call.state) { Call.State.IncomingReceived, Call.State.IncomingEarlyMedia -> { - displayIncomingCallNotification(call, true) + Log.i("[Notifications Manager] Waiting for call to be in state Connected before creating service notification") + } + else -> { + Log.i("[Notifications Manager] Creating call notification to be used as foreground service") + displayCallNotification(call, true) } - else -> displayCallNotification(call, true) } } } } fun startForeground(coreService: CoreService, useAutoStartDescription: Boolean = true) { - Log.i("[Notifications Manager] Starting service as foreground") if (serviceNotification == null) { createServiceNotification(useAutoStartDescription) } currentForegroundServiceNotificationId = SERVICE_NOTIF_ID + Log.i("[Notifications Manager] Starting service as foreground [$currentForegroundServiceNotificationId]") coreService.startForeground(currentForegroundServiceNotificationId, serviceNotification) service = coreService } private fun startForeground(notificationId: Int, callNotification: Notification) { if (currentForegroundServiceNotificationId == 0 && service != null) { - Log.i("[Notifications Manager] Starting service as foreground using call notification") + Log.i("[Notifications Manager] Starting service as foreground using call notification [$notificationId]") currentForegroundServiceNotificationId = notificationId service?.startForeground(currentForegroundServiceNotificationId, callNotification) } @@ -302,7 +344,7 @@ class NotificationsManager(private val context: Context) { private fun stopForegroundNotification() { if (service != null) { - Log.i("[Notifications Manager] Stopping service as foreground") + Log.i("[Notifications Manager] Stopping service as foreground [$currentForegroundServiceNotificationId]") service?.stopForeground(true) currentForegroundServiceNotificationId = 0 } @@ -310,14 +352,14 @@ class NotificationsManager(private val context: Context) { fun stopForegroundNotificationIfPossible() { if (service != null && currentForegroundServiceNotificationId == SERVICE_NOTIF_ID && !corePreferences.keepServiceAlive) { - Log.i("[Notifications Manager] Stopping auto-started service notification") + Log.i("[Notifications Manager] Stopping auto-started service notification [$currentForegroundServiceNotificationId]") stopForegroundNotification() } } fun stopCallForeground() { if (service != null && currentForegroundServiceNotificationId != SERVICE_NOTIF_ID && !corePreferences.keepServiceAlive) { - Log.i("[Notifications Manager] Stopping call notification used as foreground service") + Log.i("[Notifications Manager] Stopping call notification [$currentForegroundServiceNotificationId] used as foreground service") stopForegroundNotification() } } @@ -371,14 +413,14 @@ class NotificationsManager(private val context: Context) { return notifiable } - private fun getPerson(contact: Contact?, displayName: String, picture: Bitmap?): Person { + fun getPerson(contact: Contact?, displayName: String, picture: Bitmap?): Person { return if (contact != null) { contact.getPerson() } else { val builder = Person.Builder().setName(displayName) val userIcon = if (picture != null) { - IconCompat.createWithBitmap(picture) + IconCompat.createWithAdaptiveBitmap(picture) } else { IconCompat.createWithResource(context, R.drawable.avatar) } @@ -388,18 +430,17 @@ class NotificationsManager(private val context: Context) { } private fun displayIncomingCallNotification(call: Call, useAsForeground: Boolean = false) { - val address = LinphoneUtils.getDisplayableAddress(call.remoteAddress) - val notifiable = getNotifiableForCall(call) - - if (notifiable.notificationId == currentForegroundServiceNotificationId) { - Log.w("[Notifications Manager] Incoming call notification already displayed by foreground service, skipping") + if (coreContext.declineCallDueToGsmActiveCall()) { + Log.w("[Notifications Manager] Call will be declined, do not show incoming call notification") return } - val contact: Contact? = coreContext.contactsManager.findContactByAddress(call.remoteAddress) - val pictureUri = contact?.getContactThumbnailPictureUri() - val roundPicture = ImageUtils.getRoundBitmapFromUri(context, pictureUri) - val displayName = contact?.fullName ?: LinphoneUtils.getDisplayName(call.remoteAddress) + val notifiable = getNotifiableForCall(call) + if (notifiable.notificationId == currentForegroundServiceNotificationId) { + Log.e("[Notifications Manager] There is already a Service foreground notification for an incoming call, cancelling it") + cancel(notifiable.notificationId) + currentForegroundServiceNotificationId = 0 + } val incomingCallNotificationIntent = Intent(context, IncomingCallActivity::class.java) incomingCallNotificationIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) @@ -410,45 +451,12 @@ class NotificationsManager(private val context: Context) { PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) - val notificationLayoutHeadsUp = RemoteViews(context.packageName, R.layout.call_incoming_notification_heads_up) - notificationLayoutHeadsUp.setTextViewText(R.id.caller, displayName) - notificationLayoutHeadsUp.setTextViewText(R.id.sip_uri, address) - notificationLayoutHeadsUp.setTextViewText(R.id.incoming_call_info, context.getString(R.string.incoming_call_notification_title)) - - if (roundPicture != null) { - notificationLayoutHeadsUp.setImageViewBitmap(R.id.caller_picture, roundPicture) - } - - val builder = NotificationCompat.Builder(context, context.getString(R.string.notification_channel_incoming_call_id)) - .setStyle(NotificationCompat.DecoratedCustomViewStyle()) - .addPerson(getPerson(contact, displayName, roundPicture)) - .setSmallIcon(R.drawable.topbar_call_notification) - .setContentTitle(displayName) - .setContentText(context.getString(R.string.incoming_call_notification_title)) - .setCategory(NotificationCompat.CATEGORY_CALL) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setWhen(System.currentTimeMillis()) - .setAutoCancel(false) - .setShowWhen(true) - .setOngoing(true) - .setColor(ContextCompat.getColor(context, R.color.primary_color)) - .setFullScreenIntent(pendingIntent, true) - .addAction(getCallDeclineAction(notifiable)) - .addAction(getCallAnswerAction(notifiable)) - .setCustomHeadsUpContentView(notificationLayoutHeadsUp) - - if (!corePreferences.preventInterfaceFromShowingUp) { - builder.setContentIntent(pendingIntent) - } - - val notification = builder.build() - - Log.i("[Notifications Manager] Notifying incoming call notification") + val notification = Compatibility.createIncomingCallNotification(context, call, notifiable, pendingIntent, this) + Log.i("[Notifications Manager] Notifying incoming call notification [${notifiable.notificationId}]") notify(notifiable.notificationId, notification) if (useAsForeground) { - Log.i("[Notifications Manager] Notifying incoming call notification for foreground service") + Log.i("[Notifications Manager] Notifying incoming call notification for foreground service [${notifiable.notificationId}]") startForeground(notifiable.notificationId, notification) } } @@ -492,17 +500,28 @@ class NotificationsManager(private val context: Context) { } val notification = builder.build() - - notify(MISSED_CALLS_NOTIF_ID, notification) + notify(MISSED_CALLS_NOTIF_ID, notification, MISSED_CALL_TAG) } fun dismissMissedCallNotification() { - cancel(MISSED_CALLS_NOTIF_ID) + cancel(MISSED_CALLS_NOTIF_ID, MISSED_CALL_TAG) } fun displayCallNotification(call: Call, useAsForeground: Boolean = false) { val notifiable = getNotifiableForCall(call) + val callActivity: Class<*> = when (call.state) { + Call.State.Paused, Call.State.Pausing, Call.State.PausedByRemote -> { + CallActivity::class.java + } + Call.State.OutgoingRinging, Call.State.OutgoingProgress, Call.State.OutgoingInit, Call.State.OutgoingEarlyMedia -> { + OutgoingCallActivity::class.java + } + else -> { + CallActivity::class.java + } + } + val serviceChannel = context.getString(R.string.notification_channel_service_id) val channelToUse = when (val serviceChannelImportance = Compatibility.getChannelImportance(notificationManager, serviceChannel)) { NotificationManagerCompat.IMPORTANCE_NONE -> { @@ -520,40 +539,6 @@ class NotificationsManager(private val context: Context) { } } - val contact: Contact? = coreContext.contactsManager.findContactByAddress(call.remoteAddress) - val pictureUri = contact?.getContactThumbnailPictureUri() - val roundPicture = ImageUtils.getRoundBitmapFromUri(context, pictureUri) - val displayName = contact?.fullName ?: LinphoneUtils.getDisplayName(call.remoteAddress) - - val stringResourceId: Int - val iconResourceId: Int - val callActivity: Class<*> - when (call.state) { - Call.State.Paused, Call.State.Pausing, Call.State.PausedByRemote -> { - callActivity = CallActivity::class.java - stringResourceId = R.string.call_notification_paused - iconResourceId = R.drawable.topbar_call_paused_notification - } - Call.State.OutgoingRinging, Call.State.OutgoingProgress, Call.State.OutgoingInit, Call.State.OutgoingEarlyMedia -> { - callActivity = OutgoingCallActivity::class.java - stringResourceId = R.string.call_notification_outgoing - iconResourceId = if (call.params.videoEnabled()) { - R.drawable.topbar_videocall_notification - } else { - R.drawable.topbar_call_notification - } - } - else -> { - callActivity = CallActivity::class.java - stringResourceId = R.string.call_notification_active - iconResourceId = if (call.currentParams.videoEnabled()) { - R.drawable.topbar_videocall_notification - } else { - R.drawable.topbar_call_notification - } - } - } - val callNotificationIntent = Intent(context, callActivity) callNotificationIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) val pendingIntent = PendingIntent.getActivity( @@ -563,33 +548,12 @@ class NotificationsManager(private val context: Context) { PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) - val builder = NotificationCompat.Builder( - context, channelToUse - ) - .setContentTitle(contact?.fullName ?: displayName) - .setContentText(context.getString(stringResourceId)) - .setSmallIcon(iconResourceId) - .setLargeIcon(roundPicture) - .addPerson(getPerson(contact, displayName, roundPicture)) - .setAutoCancel(false) - .setCategory(NotificationCompat.CATEGORY_CALL) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setPriority(NotificationCompat.PRIORITY_LOW) - .setWhen(System.currentTimeMillis()) - .setShowWhen(true) - .setOngoing(true) - .setColor(ContextCompat.getColor(context, R.color.notification_led_color)) - .addAction(getCallDeclineAction(notifiable)) - - if (!corePreferences.preventInterfaceFromShowingUp) { - builder.setContentIntent(pendingIntent) - } - - val notification = builder.build() - + val notification = Compatibility.createCallNotification(context, call, notifiable, pendingIntent, channelToUse, this) + Log.i("[Notifications Manager] Notifying call notification [${notifiable.notificationId}]") notify(notifiable.notificationId, notification) - if (useAsForeground) { + if (useAsForeground || (service != null && currentForegroundServiceNotificationId == 0)) { + Log.i("[Notifications Manager] Notifying call notification for foreground service [${notifiable.notificationId}]") startForeground(notifiable.notificationId, notification) } } @@ -633,12 +597,12 @@ class NotificationsManager(private val context: Context) { context, notifiable.notificationId, target, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE + Compatibility.getUpdateCurrentPendingIntentFlag() ) val id = LinphoneUtils.getChatRoomId(room.localAddress, room.peerAddress) val notification = createMessageNotification(notifiable, pendingIntent, bubbleIntent, id) - notify(notifiable.notificationId, notification) + notify(notifiable.notificationId, notification, CHAT_TAG) } private fun displayIncomingChatNotification(room: ChatRoom, message: ChatMessage) { @@ -739,13 +703,37 @@ class NotificationsManager(private val context: Context) { displayChatNotifiable(message.chatRoom, notifiable) } - fun dismissChatNotification(room: ChatRoom) { + fun dismissChatNotification(room: ChatRoom): Boolean { val address = room.peerAddress.asStringUriOnly() val notifiable: Notifiable? = chatNotificationsMap[address] if (notifiable != null) { Log.i("[Notifications Manager] Dismissing notification for chat room $room with id ${notifiable.notificationId}") notifiable.messages.clear() - cancel(notifiable.notificationId) + cancel(notifiable.notificationId, CHAT_TAG) + return true + } else { + val previousNotificationId = previousChatNotifications.find { id -> id == room.creationTime.toInt() } + if (previousNotificationId != null) { + if (chatBubbleNotifications.contains(previousNotificationId)) { + Log.i("[Notifications Manager] Found previous notification with same ID [$previousNotificationId] but not cancelling it as it's ID is in chat bubbles list") + } else { + Log.i("[Notifications Manager] Found previous notification with same ID [$previousNotificationId], canceling it") + cancel(previousNotificationId, CHAT_TAG) + } + return true + } + } + return false + } + + fun changeDismissNotificationUponReadForChatRoom(chatRoom: ChatRoom, dismiss: Boolean) { + val notificationId = chatRoom.creationTime.toInt() + if (dismiss) { + Log.i("[Notifications Manager] Allow notification with id [$notificationId] to be dismissed when chat room will be marked as read, used for chat bubble") + chatBubbleNotifications.add(notificationId) + } else { + Log.i("[Notifications Manager] Prevent notification with id [$notificationId] from being dismissed when chat room will be marked as read, used for chat bubble") + chatBubbleNotifications.remove(notificationId) } } @@ -831,43 +819,47 @@ class NotificationsManager(private val context: Context) { /* Notifications actions */ - private fun getCallAnswerAction(notifiable: Notifiable): NotificationCompat.Action { + fun getCallAnswerPendingIntent(notifiable: Notifiable): PendingIntent { val answerIntent = Intent(context, NotificationBroadcastReceiver::class.java) answerIntent.action = INTENT_ANSWER_CALL_NOTIF_ACTION answerIntent.putExtra(INTENT_NOTIF_ID, notifiable.notificationId) answerIntent.putExtra(INTENT_REMOTE_ADDRESS, notifiable.remoteAddress) - val answerPendingIntent = PendingIntent.getBroadcast( + return PendingIntent.getBroadcast( context, notifiable.notificationId, answerIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) + } + fun getCallAnswerAction(notifiable: Notifiable): NotificationCompat.Action { return NotificationCompat.Action.Builder( R.drawable.call_audio_start, context.getString(R.string.incoming_call_notification_answer_action_label), - answerPendingIntent + getCallAnswerPendingIntent(notifiable) ).build() } - private fun getCallDeclineAction(notifiable: Notifiable): NotificationCompat.Action { + fun getCallDeclinePendingIntent(notifiable: Notifiable): PendingIntent { val hangupIntent = Intent(context, NotificationBroadcastReceiver::class.java) hangupIntent.action = INTENT_HANGUP_CALL_NOTIF_ACTION hangupIntent.putExtra(INTENT_NOTIF_ID, notifiable.notificationId) hangupIntent.putExtra(INTENT_REMOTE_ADDRESS, notifiable.remoteAddress) - val hangupPendingIntent = PendingIntent.getBroadcast( + return PendingIntent.getBroadcast( context, notifiable.notificationId, hangupIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) + } + fun getCallDeclineAction(notifiable: Notifiable): NotificationCompat.Action { return NotificationCompat.Action.Builder( R.drawable.call_hangup, context.getString(R.string.incoming_call_notification_hangup_action_label), - hangupPendingIntent + getCallDeclinePendingIntent(notifiable) ).build() } @@ -888,7 +880,7 @@ class NotificationsManager(private val context: Context) { context, notifiable.notificationId, replyIntent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE + Compatibility.getUpdateCurrentPendingIntentFlag() ) return NotificationCompat.Action.Builder( R.drawable.chat_send_over, diff --git a/app/src/main/java/org/linphone/telecom/NativeCallWrapper.kt b/app/src/main/java/org/linphone/telecom/NativeCallWrapper.kt index 05e7fe67f..2173faff3 100644 --- a/app/src/main/java/org/linphone/telecom/NativeCallWrapper.kt +++ b/app/src/main/java/org/linphone/telecom/NativeCallWrapper.kt @@ -102,6 +102,11 @@ class NativeCallWrapper(var callId: String) : Connection() { getCall()?.terminate() ?: selfDestroy() } + override fun onSilence() { + Log.i("[Connection] Call with id: $callId asked to be silenced") + coreContext.core.stopRinging() + } + private fun getCall(): Call? { return coreContext.core.getCallByCallid(callId) } diff --git a/app/src/main/java/org/linphone/telecom/TelecomConnectionService.kt b/app/src/main/java/org/linphone/telecom/TelecomConnectionService.kt index ac8f8da9c..fa3f8c4de 100644 --- a/app/src/main/java/org/linphone/telecom/TelecomConnectionService.kt +++ b/app/src/main/java/org/linphone/telecom/TelecomConnectionService.kt @@ -42,6 +42,7 @@ class TelecomConnectionService : ConnectionService() { Call.State.OutgoingProgress -> { for (connection in TelecomHelper.get().connections) { if (connection.callId.isEmpty()) { + Log.i("[Telecom Connection Service] Updating connection with call ID: ${call.callLog.callId}") connection.callId = core.currentCall?.callLog?.callId ?: "" } } @@ -51,6 +52,18 @@ class TelecomConnectionService : ConnectionService() { Call.State.Connected -> onCallConnected(call) } } + + override fun onLastCallEnded(core: Core) { + val connectionsCount = TelecomHelper.get().connections.size + if (connectionsCount > 0) { + Log.w("[Telecom Connection Service] Last call ended, there is $connectionsCount connections still alive") + for (connection in TelecomHelper.get().connections) { + Log.w("[Telecom Connection Service] Destroying zombie connection ${connection.callId}") + connection.setDisconnected(DisconnectCause(DisconnectCause.OTHER)) + connection.destroy() + } + } + } } override fun onCreate() { @@ -162,8 +175,12 @@ class TelecomConnectionService : ConnectionService() { } private fun onCallError(call: Call) { - val connection = TelecomHelper.get().findConnectionForCallId(call.callLog.callId) - connection ?: return + val callId = call.callLog.callId + val connection = TelecomHelper.get().findConnectionForCallId(callId) + if (connection == null) { + Log.e("[Telecom Connection Service] Failed to find connection for call id: $callId") + return + } TelecomHelper.get().connections.remove(connection) connection.setDisconnected(DisconnectCause(DisconnectCause.ERROR)) @@ -171,19 +188,27 @@ class TelecomConnectionService : ConnectionService() { } private fun onCallEnded(call: Call) { - val connection = TelecomHelper.get().findConnectionForCallId(call.callLog.callId) - connection ?: return + val callId = call.callLog.callId + val connection = TelecomHelper.get().findConnectionForCallId(callId) + if (connection == null) { + Log.e("[Telecom Connection Service] Failed to find connection for call id: $callId") + return + } TelecomHelper.get().connections.remove(connection) val reason = call.reason - Log.i("[Telecom Connection Service] Call ended with reason: $reason") + Log.i("[Telecom Connection Service] Call [$callId] ended with reason: $reason, destroying connection") connection.setDisconnected(DisconnectCause(DisconnectCause.LOCAL)) connection.destroy() } private fun onCallConnected(call: Call) { - val connection = TelecomHelper.get().findConnectionForCallId(call.callLog.callId) - connection ?: return + val callId = call.callLog.callId + val connection = TelecomHelper.get().findConnectionForCallId(callId) + if (connection == null) { + Log.e("[Telecom Connection Service] Failed to find connection for call id: $callId") + return + } if (connection.state != Connection.STATE_HOLDING) { connection.setActive() diff --git a/app/src/main/java/org/linphone/telecom/TelecomHelper.kt b/app/src/main/java/org/linphone/telecom/TelecomHelper.kt index 531cb028b..c2a29d759 100644 --- a/app/src/main/java/org/linphone/telecom/TelecomHelper.kt +++ b/app/src/main/java/org/linphone/telecom/TelecomHelper.kt @@ -30,6 +30,7 @@ import android.telecom.PhoneAccount import android.telecom.PhoneAccountHandle import android.telecom.TelecomManager import android.telecom.TelecomManager.* +import java.lang.Exception import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.R import org.linphone.contact.Contact @@ -159,9 +160,16 @@ class TelecomHelper private constructor(context: Context) { ComponentName(context, TelecomConnectionService::class.java), context.packageName ) - val identity = coreContext.core.defaultAccount?.params?.identityAddress?.asStringUriOnly() ?: "" + // Take care that identity may be parsed, otherwise Android OS may crash during startup + // and user will have to do a factory reset... + val identity = coreContext.core.defaultAccount?.params?.identityAddress?.asStringUriOnly() + ?: coreContext.core.createPrimaryContactParsed()?.asStringUriOnly() + ?: "sip:linphone.android@sip.linphone.org" + + val address = Uri.parse(identity) + ?: throw Exception("[Telecom Helper] Identity address for phone account is null!") val account = PhoneAccount.builder(accountHandle, context.getString(R.string.app_name)) - .setAddress(Uri.parse(identity)) + .setAddress(address) .setIcon(Icon.createWithResource(context, R.drawable.linphone_logo_tinted)) .setCapabilities(PhoneAccount.CAPABILITY_SELF_MANAGED) .setHighlightColor(context.getColor(R.color.primary_color)) @@ -208,7 +216,7 @@ class TelecomHelper private constructor(context: Context) { if (call.dir == Call.Dir.Outgoing) { extras.putString( EXTRA_CALL_BACK_NUMBER, - call.callLog.fromAddress.asStringUriOnly() + call.remoteAddress.asStringUriOnly() ) } else { extras.putParcelable(EXTRA_INCOMING_CALL_ADDRESS, Uri.parse(address.asStringUriOnly())) diff --git a/app/src/main/java/org/linphone/utils/AudioRouteUtils.kt b/app/src/main/java/org/linphone/utils/AudioRouteUtils.kt index d3f06fda9..d23b9759b 100644 --- a/app/src/main/java/org/linphone/utils/AudioRouteUtils.kt +++ b/app/src/main/java/org/linphone/utils/AudioRouteUtils.kt @@ -112,7 +112,11 @@ class AudioRouteUtils { Log.i("[Audio Route Helper] Telecom Helper & matching connection found, dispatching audio route change through it") // We will be called here again by NativeCallWrapper.onCallAudioStateChanged() // but this time with skipTelecom = true - Compatibility.changeAudioRouteForTelecomManager(connection, route) + if (!Compatibility.changeAudioRouteForTelecomManager(connection, route)) { + Log.w("[Audio Route Helper] Connection is already using this route internally, make the change!") + applyAudioRouteChange(callToUse, types) + changeCaptureDeviceToMatchAudioRoute(callToUse, types) + } } else { Log.w("[Audio Route Helper] Telecom Helper found but no matching connection!") applyAudioRouteChange(callToUse, types) diff --git a/app/src/main/java/org/linphone/utils/DataBindingUtils.kt b/app/src/main/java/org/linphone/utils/DataBindingUtils.kt index 3be9be1be..62e2cfaf4 100644 --- a/app/src/main/java/org/linphone/utils/DataBindingUtils.kt +++ b/app/src/main/java/org/linphone/utils/DataBindingUtils.kt @@ -19,6 +19,7 @@ */ package org.linphone.utils +import android.app.Activity import android.content.Context import android.graphics.Bitmap import android.graphics.drawable.Drawable @@ -29,6 +30,8 @@ import android.util.Patterns import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputMethodManager import android.widget.* import android.widget.SeekBar.OnSeekBarChangeListener import androidx.constraintlayout.widget.ConstraintLayout @@ -186,6 +189,23 @@ fun editTextSetting(view: EditText, lambda: () -> Unit) { }) } +@BindingAdapter("onSettingImeDone") +fun editTextImeDone(view: EditText, lambda: () -> Unit) { + view.setOnEditorActionListener { _, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_DONE) { + lambda() + + view.clearFocus() + + val imm = view.context.getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager + imm.hideSoftInputFromWindow(view.windowToken, 0) + + return@setOnEditorActionListener true + } + false + } +} + @BindingAdapter("onFocusChangeVisibilityOf") fun setEditTextOnFocusChangeVisibilityOf(editText: EditText, view: View) { editText.setOnFocusChangeListener { _, hasFocus -> diff --git a/app/src/main/java/org/linphone/utils/FileUtils.kt b/app/src/main/java/org/linphone/utils/FileUtils.kt index e9536c5b7..30adf0ba1 100644 --- a/app/src/main/java/org/linphone/utils/FileUtils.kt +++ b/app/src/main/java/org/linphone/utils/FileUtils.kt @@ -70,35 +70,56 @@ class FileUtils { } fun isPlainTextFile(path: String): Boolean { - val extension = getExtensionFromFileName(path).toLowerCase(Locale.getDefault()) + val extension = getExtensionFromFileName(path).lowercase(Locale.getDefault()) val type = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) return type?.startsWith("text/plain") ?: false } fun isExtensionPdf(path: String): Boolean { - val extension = getExtensionFromFileName(path).toLowerCase(Locale.getDefault()) + val extension = getExtensionFromFileName(path).lowercase(Locale.getDefault()) val type = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) return type?.startsWith("application/pdf") ?: false } fun isExtensionImage(path: String): Boolean { - val extension = getExtensionFromFileName(path).toLowerCase(Locale.getDefault()) + val extension = getExtensionFromFileName(path).lowercase(Locale.getDefault()) val type = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) return type?.startsWith("image/") ?: false } fun isExtensionVideo(path: String): Boolean { - val extension = getExtensionFromFileName(path).toLowerCase(Locale.getDefault()) + val extension = getExtensionFromFileName(path).lowercase(Locale.getDefault()) val type = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) return type?.startsWith("video/") ?: false } fun isExtensionAudio(path: String): Boolean { - val extension = getExtensionFromFileName(path).toLowerCase(Locale.getDefault()) + val extension = getExtensionFromFileName(path).lowercase(Locale.getDefault()) val type = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) return type?.startsWith("audio/") ?: false } + fun clearExistingPlainFiles() { + for (file in coreContext.context.filesDir.listFiles().orEmpty()) { + if (file.path.endsWith(VFS_PLAIN_FILE_EXTENSION)) { + Log.w("[File Utils] Found forgotten plain file: ${file.path}, deleting it") + deleteFile(file.path) + } + } + for (file in coreContext.context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)?.listFiles().orEmpty()) { + if (file.path.endsWith(VFS_PLAIN_FILE_EXTENSION)) { + Log.w("[File Utils] Found forgotten plain file: ${file.path}, deleting it") + deleteFile(file.path) + } + } + for (file in coreContext.context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)?.listFiles().orEmpty()) { + if (file.path.endsWith(VFS_PLAIN_FILE_EXTENSION)) { + Log.w("[File Utils] Found forgotten plain file: ${file.path}, deleting it") + deleteFile(file.path) + } + } + } + fun getFileStorageDir(isPicture: Boolean = false): File { var path: File? = null if (Environment.getExternalStorageState() == Environment.MEDIA_MOUNTED) { @@ -470,5 +491,27 @@ class FileUtils { } return false } + + fun openMediaStoreFile( + activity: Activity, + contentFilePath: String, + newTask: Boolean = false + ): Boolean { + val intent = Intent(Intent.ACTION_VIEW) + val contentUri: Uri = Uri.parse(contentFilePath) + intent.data = contentUri + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + if (newTask) { + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + + try { + activity.startActivity(intent) + return true + } catch (anfe: ActivityNotFoundException) { + Log.e("[File Viewer] Can't open media store export in third party app: $anfe") + } + return false + } } } diff --git a/app/src/main/java/org/linphone/utils/LinphoneUtils.kt b/app/src/main/java/org/linphone/utils/LinphoneUtils.kt index e939a085d..162811c99 100644 --- a/app/src/main/java/org/linphone/utils/LinphoneUtils.kt +++ b/app/src/main/java/org/linphone/utils/LinphoneUtils.kt @@ -66,7 +66,7 @@ class LinphoneUtils { fun isLimeAvailable(): Boolean { val core = coreContext.core - return core.limeX3DhAvailable() && core.limeX3DhEnabled() && + return core.limeX3DhAvailable() && core.isLimeX3DhEnabled && core.limeX3DhServerUrl != null && core.defaultAccount?.params?.conferenceFactoryUri != null } @@ -81,11 +81,11 @@ class LinphoneUtils { val defaultAccount = core.defaultAccount val params = core.createDefaultChatRoomParams() - params.enableGroup(false) + params.isGroupEnabled = false params.backend = ChatRoomBackend.Basic if (isSecured) { params.subject = AppUtils.getString(R.string.chat_room_dummy_subject) - params.enableEncryption(true) + params.isEncryptionEnabled = true params.backend = ChatRoomBackend.FlexisipChat } diff --git a/app/src/main/java/org/linphone/utils/PatternClickableSpan.kt b/app/src/main/java/org/linphone/utils/PatternClickableSpan.kt new file mode 100644 index 000000000..3455d18c5 --- /dev/null +++ b/app/src/main/java/org/linphone/utils/PatternClickableSpan.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2010-2022 Belledonne Communications SARL. + * + * This file is part of linphone-android + * (see https://www.linphone.org). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.linphone.utils + +import android.text.SpannableStringBuilder +import android.text.Spanned +import android.text.style.ClickableSpan +import android.view.View +import android.widget.TextView +import java.util.regex.Pattern + +class PatternClickableSpan { + var patterns: ArrayList = ArrayList() + + inner class SpannablePatternItem( + var pattern: Pattern, + var listener: SpannableClickedListener + ) + + interface SpannableClickedListener { + fun onSpanClicked(text: String) + } + + inner class StyledClickableSpan(var item: SpannablePatternItem) : ClickableSpan() { + override fun onClick(widget: View) { + val tv = widget as TextView + val span = tv.text as Spanned + val start = span.getSpanStart(this) + val end = span.getSpanEnd(this) + val text = span.subSequence(start, end) + item.listener.onSpanClicked(text.toString()) + } + } + + fun add( + pattern: Pattern, + listener: SpannableClickedListener + ): PatternClickableSpan { + patterns.add(SpannablePatternItem(pattern, listener)) + return this + } + + fun build(editable: CharSequence?): SpannableStringBuilder { + val ssb = SpannableStringBuilder(editable) + for (item in patterns) { + val matcher = item.pattern.matcher(ssb) + while (matcher.find()) { + val start = matcher.start() + val end = matcher.end() + val url = StyledClickableSpan(item) + ssb.setSpan(url, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + } + return ssb + } +} diff --git a/app/src/main/java/org/linphone/utils/PermissionHelper.kt b/app/src/main/java/org/linphone/utils/PermissionHelper.kt index 40fad7073..3d28a3b42 100644 --- a/app/src/main/java/org/linphone/utils/PermissionHelper.kt +++ b/app/src/main/java/org/linphone/utils/PermissionHelper.kt @@ -21,8 +21,6 @@ package org.linphone.utils import android.Manifest import android.content.Context -import android.os.Build -import androidx.annotation.RequiresApi import org.linphone.compatibility.Compatibility import org.linphone.core.tools.Log @@ -75,10 +73,4 @@ class PermissionHelper private constructor(private val context: Context) { fun hasRecordAudioPermission(): Boolean { return hasPermission(Manifest.permission.RECORD_AUDIO) } - - @RequiresApi(Build.VERSION_CODES.O) - fun hasTelecomManagerPermissions(): Boolean { - return hasPermission(Manifest.permission.READ_PHONE_NUMBERS) && - hasPermission(Manifest.permission.MANAGE_OWN_CALLS) - } } diff --git a/app/src/main/java/org/linphone/utils/RecyclerViewHeaderDecoration.kt b/app/src/main/java/org/linphone/utils/RecyclerViewHeaderDecoration.kt index a4f682376..031dead06 100644 --- a/app/src/main/java/org/linphone/utils/RecyclerViewHeaderDecoration.kt +++ b/app/src/main/java/org/linphone/utils/RecyclerViewHeaderDecoration.kt @@ -36,7 +36,7 @@ class RecyclerViewHeaderDecoration(private val context: Context, private val ada parent: RecyclerView, state: RecyclerView.State ) { - val position = (view.layoutParams as RecyclerView.LayoutParams).viewAdapterPosition + val position = (view.layoutParams as RecyclerView.LayoutParams).bindingAdapterPosition if (position != RecyclerView.NO_POSITION && adapter.displayHeaderForPosition(position)) { val headerView: View = adapter.getHeaderViewForPosition(view.context, position) diff --git a/app/src/main/java/org/linphone/utils/RecyclerViewSwipeUtils.kt b/app/src/main/java/org/linphone/utils/RecyclerViewSwipeUtils.kt index 41b3f77e4..52808677a 100644 --- a/app/src/main/java/org/linphone/utils/RecyclerViewSwipeUtils.kt +++ b/app/src/main/java/org/linphone/utils/RecyclerViewSwipeUtils.kt @@ -46,7 +46,8 @@ class RecyclerViewSwipeConfiguration { val textColor: Int = Color.WHITE, val backgroundColor: Int = 0, val icon: Int = 0, - val iconTint: Int = 0 + val iconTint: Int = 0, + val preventFor: Class<*>? = null ) val iconMargin = 16f @@ -61,7 +62,7 @@ class RecyclerViewSwipeConfiguration { } private class RecyclerViewSwipeUtilsCallback( - direction: Int, + val direction: Int, val configuration: RecyclerViewSwipeConfiguration, val listener: RecyclerViewSwipeListener ) : ItemTouchHelper.SimpleCallback(0, direction) { @@ -83,26 +84,38 @@ private class RecyclerViewSwipeUtilsCallback( background.draw(canvas) } - val iconHorizontalMargin: Int = TypedValue.applyDimension( + val horizontalMargin: Int = TypedValue.applyDimension( TypedValue.COMPLEX_UNIT_DIP, configuration.iconMargin, recyclerView.context.resources.displayMetrics ).toInt() - var iconSize = 0 + var iconWidth = 0 - if (configuration.leftToRightAction.icon != 0 && dX > iconHorizontalMargin) { + if (configuration.leftToRightAction.icon != 0) { val icon = - ContextCompat.getDrawable(recyclerView.context, configuration.leftToRightAction.icon) - if (icon != null) { - iconSize = icon.intrinsicHeight - val halfIcon = iconSize / 2 + ContextCompat.getDrawable( + recyclerView.context, + configuration.leftToRightAction.icon + ) + iconWidth = icon?.intrinsicWidth ?: 0 + if (icon != null && dX > iconWidth) { + val halfIcon = icon.intrinsicHeight / 2 val top = viewHolder.itemView.top + ((viewHolder.itemView.bottom - viewHolder.itemView.top) / 2 - halfIcon) + // Icon won't move past the swipe threshold, thus indicating to the user + // it has reached the required distance for swipe action to be done + val threshold = getSwipeThreshold(viewHolder) * viewHolder.itemView.right + val left = if (dX < threshold) { + viewHolder.itemView.left + dX.toInt() - iconWidth + } else { + viewHolder.itemView.left + threshold.toInt() - iconWidth + } + icon.setBounds( - viewHolder.itemView.left + iconHorizontalMargin, + left, top, - viewHolder.itemView.left + iconHorizontalMargin + icon.intrinsicWidth, + left + iconWidth, top + icon.intrinsicHeight ) @@ -115,7 +128,7 @@ private class RecyclerViewSwipeUtilsCallback( } } - if (configuration.leftToRightAction.text.isNotEmpty() && dX > iconHorizontalMargin + iconSize) { + if (configuration.leftToRightAction.text.isNotEmpty() && dX > horizontalMargin + iconWidth) { val textPaint = TextPaint() textPaint.isAntiAlias = true textPaint.textSize = TypedValue.applyDimension( @@ -126,9 +139,9 @@ private class RecyclerViewSwipeUtilsCallback( textPaint.color = configuration.leftToRightAction.textColor textPaint.typeface = configuration.actionTextFont - val margin = if (iconSize > 0) iconHorizontalMargin / 2 else 0 + val margin = if (iconWidth > 0) horizontalMargin / 2 else 0 val textX = - (viewHolder.itemView.left + iconHorizontalMargin + iconSize + margin).toFloat() + (viewHolder.itemView.left + horizontalMargin + iconWidth + margin).toFloat() val textY = (viewHolder.itemView.top + (viewHolder.itemView.bottom - viewHolder.itemView.top) / 2.0 + textPaint.textSize / 2).toFloat() canvas.drawText( @@ -157,30 +170,44 @@ private class RecyclerViewSwipeUtilsCallback( background.draw(canvas) } - val iconHorizontalMargin: Int = TypedValue.applyDimension( + val horizontalMargin: Int = TypedValue.applyDimension( TypedValue.COMPLEX_UNIT_DIP, configuration.iconMargin, recyclerView.context.resources.displayMetrics ).toInt() - var iconSize = 0 + var iconWidth = 0 var imageLeftBorder = viewHolder.itemView.right - if (configuration.rightToLeftAction.icon != 0 && dX < -iconHorizontalMargin) { + if (configuration.rightToLeftAction.icon != 0) { val icon = - ContextCompat.getDrawable(recyclerView.context, configuration.rightToLeftAction.icon) - if (icon != null) { - iconSize = icon.intrinsicHeight - val halfIcon = iconSize / 2 + ContextCompat.getDrawable( + recyclerView.context, + configuration.rightToLeftAction.icon + ) + iconWidth = icon?.intrinsicWidth ?: 0 + if (icon != null && dX < viewHolder.itemView.right - iconWidth) { + val halfIcon = icon.intrinsicHeight / 2 val top = viewHolder.itemView.top + ((viewHolder.itemView.bottom - viewHolder.itemView.top) / 2 - halfIcon) - imageLeftBorder = - viewHolder.itemView.right - iconHorizontalMargin - halfIcon * 2 + + // Icon won't move past the swipe threshold, thus indicating to the user + // it has reached the required distance for swipe action to be done + val threshold = -(getSwipeThreshold(viewHolder) * viewHolder.itemView.right) + val right = if (dX > threshold) { + viewHolder.itemView.right + dX.toInt() + } else { + viewHolder.itemView.right + threshold.toInt() + } + imageLeftBorder = right - icon.intrinsicWidth + icon.setBounds( imageLeftBorder, top, - viewHolder.itemView.right - iconHorizontalMargin, + right, top + icon.intrinsicHeight ) + + @Suppress("DEPRECATION") if (configuration.rightToLeftAction.iconTint != 0) icon.setColorFilter( configuration.rightToLeftAction.iconTint, PorterDuff.Mode.SRC_IN @@ -188,7 +215,8 @@ private class RecyclerViewSwipeUtilsCallback( icon.draw(canvas) } } - if (configuration.rightToLeftAction.text.isNotEmpty() && dX < -iconHorizontalMargin - iconSize) { + + if (configuration.rightToLeftAction.text.isNotEmpty() && dX < -horizontalMargin - iconWidth) { val textPaint = TextPaint() textPaint.isAntiAlias = true textPaint.textSize = TypedValue.applyDimension( @@ -200,7 +228,7 @@ private class RecyclerViewSwipeUtilsCallback( textPaint.typeface = configuration.actionTextFont val margin = - if (imageLeftBorder == viewHolder.itemView.right) iconHorizontalMargin else iconHorizontalMargin / 2 + if (imageLeftBorder == viewHolder.itemView.right) horizontalMargin else horizontalMargin / 2 val textX = imageLeftBorder - textPaint.measureText(configuration.rightToLeftAction.text) - margin val textY = @@ -234,6 +262,32 @@ private class RecyclerViewSwipeUtilsCallback( } } + override fun getSwipeDirs( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder + ): Int { + // Prevent swipe actions for a specific ViewHolder class if needed + // Used to allow swipe actions on chat messages but not events + var dirFlags = direction + if (direction and ItemTouchHelper.RIGHT != 0) { + val classToPrevent = configuration.leftToRightAction.preventFor + if (classToPrevent != null) { + if (classToPrevent.isInstance(viewHolder)) { + dirFlags = dirFlags and ItemTouchHelper.RIGHT.inv() + } + } + } + if (direction or ItemTouchHelper.LEFT != 0) { + val classToPrevent = configuration.rightToLeftAction.preventFor + if (classToPrevent != null) { + if (classToPrevent.isInstance(viewHolder)) { + dirFlags = dirFlags and ItemTouchHelper.LEFT.inv() + } + } + } + return dirFlags + } + override fun onMove( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, @@ -253,6 +307,10 @@ private class RecyclerViewSwipeUtilsCallback( } } + override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float { + return .33f // A third of the screen is required to validate swipe move (default is .5f) + } + override fun onChildDraw( canvas: Canvas, recyclerView: RecyclerView, diff --git a/app/src/main/java/org/linphone/views/SettingTextInputEditText.kt b/app/src/main/java/org/linphone/views/SettingTextInputEditText.kt new file mode 100644 index 000000000..6340f2896 --- /dev/null +++ b/app/src/main/java/org/linphone/views/SettingTextInputEditText.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2010-2021 Belledonne Communications SARL. + * + * This file is part of linphone-android + * (see https://www.linphone.org). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.linphone.views + +import android.app.Activity +import android.content.Context +import android.util.AttributeSet +import android.view.inputmethod.InputMethodManager +import com.google.android.material.textfield.TextInputEditText +import org.linphone.activities.main.settings.SettingListener + +class SettingTextInputEditText : TextInputEditText { + constructor(context: Context) : super(context) + + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) + + constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super( + context, + attrs, + defStyleAttr + ) + + fun fakeImeDone(listener: SettingListener) { + listener.onTextValueChanged(text.toString()) + + // Send IME action DONE to trigger onSettingImeDone binding adapter, but that doesn't work... + // val inputConnection = BaseInputConnection(this, true) + // inputConnection.performEditorAction(EditorInfo.IME_ACTION_DONE) + + // Will make check icon to disappear thanks to onFocusChangeVisibilityOf binding adapter + clearFocus() + + // Hide keyboard + val imm = context.getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager + imm.hideSoftInputFromWindow(windowToken, 0) + } +} diff --git a/app/src/main/java/org/linphone/views/VoiceRecordProgressBar.kt b/app/src/main/java/org/linphone/views/VoiceRecordProgressBar.kt index 3d918af4c..900e45e8f 100644 --- a/app/src/main/java/org/linphone/views/VoiceRecordProgressBar.kt +++ b/app/src/main/java/org/linphone/views/VoiceRecordProgressBar.kt @@ -205,7 +205,17 @@ class VoiceRecordProgressBar : View { } } - fun setProgressDrawable(drawable: Drawable) { + fun setSecondaryProgressTint(color: Int) { + val drawable = progressDrawable + if (drawable != null) { + if (drawable is LayerDrawable) { + val secondaryProgressDrawable = drawable.findDrawableByLayerId(android.R.id.secondaryProgress) + secondaryProgressDrawable?.setTint(color) + } + } + } + + private fun setProgressDrawable(drawable: Drawable) { val needUpdate: Boolean = if (progressDrawable != null && drawable !== progressDrawable) { progressDrawable?.callback = null true @@ -233,16 +243,6 @@ class VoiceRecordProgressBar : View { } } - fun setSecondaryProgressTint(color: Int) { - val drawable = progressDrawable - if (drawable != null) { - if (drawable is LayerDrawable) { - val secondaryProgressDrawable = drawable.findDrawableByLayerId(android.R.id.secondaryProgress) - secondaryProgressDrawable?.setTint(color) - } - } - } - private fun refreshProgress(id: Int, progress: Int) { var scale: Float = if (max > 0) (progress.toFloat() / max) else 0f diff --git a/app/src/main/res/drawable-xhdpi/menu_forward_default.png b/app/src/main/res/drawable-xhdpi/menu_forward_default.png index 4212b9df2..9a5b9d85f 100644 Binary files a/app/src/main/res/drawable-xhdpi/menu_forward_default.png and b/app/src/main/res/drawable-xhdpi/menu_forward_default.png differ diff --git a/app/src/main/res/drawable-xhdpi/menu_imdn_info_default.png b/app/src/main/res/drawable-xhdpi/menu_imdn_info_default.png deleted file mode 100644 index 1b83a3b9f..000000000 Binary files a/app/src/main/res/drawable-xhdpi/menu_imdn_info_default.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/menu_reply_default.png b/app/src/main/res/drawable-xhdpi/menu_reply_default.png index 47fc5db73..7f898c47e 100644 Binary files a/app/src/main/res/drawable-xhdpi/menu_reply_default.png and b/app/src/main/res/drawable-xhdpi/menu_reply_default.png differ diff --git a/app/src/main/res/drawable-xhdpi/splashscreen_branding.png b/app/src/main/res/drawable-xhdpi/splashscreen_branding.png new file mode 100644 index 000000000..99ec7945b Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/splashscreen_branding.png differ diff --git a/app/src/main/res/drawable/launch_screen.xml b/app/src/main/res/drawable/launch_screen.xml deleted file mode 100644 index 5a3965dfc..000000000 --- a/app/src/main/res/drawable/launch_screen.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/menu_imdn_info.xml b/app/src/main/res/drawable/menu_imdn_info.xml index dfa734b2d..d15e39140 100644 --- a/app/src/main/res/drawable/menu_imdn_info.xml +++ b/app/src/main/res/drawable/menu_imdn_info.xml @@ -1,7 +1,7 @@ - diff --git a/app/src/main/res/drawable/vector_linphone_logo.xml b/app/src/main/res/drawable/vector_linphone_logo.xml new file mode 100644 index 000000000..9c1d994f9 --- /dev/null +++ b/app/src/main/res/drawable/vector_linphone_logo.xml @@ -0,0 +1,20 @@ + + + + + diff --git a/app/src/main/res/layout-land/about_fragment.xml b/app/src/main/res/layout-land/about_fragment.xml index ca69f55be..b57607329 100644 --- a/app/src/main/res/layout-land/about_fragment.xml +++ b/app/src/main/res/layout-land/about_fragment.xml @@ -1,6 +1,5 @@ - + @@ -13,6 +12,9 @@ + @@ -164,9 +166,21 @@ android:layout_gravity="center" android:gravity="center" android:paddingTop="10dp" - android:paddingBottom="20dp" + android:paddingBottom="10dp" android:text="@string/about_text" /> + + @@ -89,6 +89,7 @@ + android:src="@{viewModel.autoInitiateVideoCalls ? @drawable/call_video_start : @drawable/call_audio_start, default=@drawable/call_audio_start}" /> + android:src="@{viewModel.autoInitiateVideoCalls ? @drawable/call_video_start : @drawable/call_audio_start, default=@drawable/call_audio_start}" /> + android:src="@{viewModel.autoInitiateVideoCalls ? @drawable/call_video_start : @drawable/call_audio_start, default=@drawable/call_audio_start}" /> - + @@ -13,6 +12,9 @@ + @@ -168,9 +170,21 @@ android:layout_gravity="center" android:gravity="center" android:paddingTop="10dp" - android:paddingBottom="20dp" + android:paddingBottom="10dp" android:text="@string/about_text" /> + + diff --git a/app/src/main/res/layout/assistant_echo_canceller_calibration_fragment.xml b/app/src/main/res/layout/assistant_echo_canceller_calibration_fragment.xml index 183386b3e..acf86c157 100644 --- a/app/src/main/res/layout/assistant_echo_canceller_calibration_fragment.xml +++ b/app/src/main/res/layout/assistant_echo_canceller_calibration_fragment.xml @@ -16,7 +16,7 @@ android:id="@+id/top_bar_fragment" android:name="org.linphone.activities.assistant.fragments.TopBarFragment" android:layout_width="match_parent" - android:layout_height="wrap_content" + android:layout_height="@dimen/main_activity_top_bar_size" android:layout_alignParentTop="true" tools:layout="@layout/assistant_top_bar_fragment" /> diff --git a/app/src/main/res/layout/assistant_email_account_creation_fragment.xml b/app/src/main/res/layout/assistant_email_account_creation_fragment.xml index 78906ee61..03d2f83f0 100644 --- a/app/src/main/res/layout/assistant_email_account_creation_fragment.xml +++ b/app/src/main/res/layout/assistant_email_account_creation_fragment.xml @@ -19,7 +19,7 @@ android:id="@+id/top_bar_fragment" android:name="org.linphone.activities.assistant.fragments.TopBarFragment" android:layout_width="match_parent" - android:layout_height="wrap_content" + android:layout_height="@dimen/main_activity_top_bar_size" android:layout_alignParentTop="true" tools:layout="@layout/assistant_top_bar_fragment" /> diff --git a/app/src/main/res/layout/assistant_email_account_validation_fragment.xml b/app/src/main/res/layout/assistant_email_account_validation_fragment.xml index 73639b611..6a487897e 100644 --- a/app/src/main/res/layout/assistant_email_account_validation_fragment.xml +++ b/app/src/main/res/layout/assistant_email_account_validation_fragment.xml @@ -18,7 +18,7 @@ android:id="@+id/top_bar_fragment" android:name="org.linphone.activities.assistant.fragments.TopBarFragment" android:layout_width="match_parent" - android:layout_height="wrap_content" + android:layout_height="@dimen/main_activity_top_bar_size" android:layout_alignParentTop="true" tools:layout="@layout/assistant_top_bar_fragment" /> diff --git a/app/src/main/res/layout/assistant_generic_account_login_fragment.xml b/app/src/main/res/layout/assistant_generic_account_login_fragment.xml index a1790bf5c..42a11a26a 100644 --- a/app/src/main/res/layout/assistant_generic_account_login_fragment.xml +++ b/app/src/main/res/layout/assistant_generic_account_login_fragment.xml @@ -20,7 +20,7 @@ android:id="@+id/top_bar_fragment" android:name="org.linphone.activities.assistant.fragments.TopBarFragment" android:layout_width="match_parent" - android:layout_height="wrap_content" + android:layout_height="@dimen/main_activity_top_bar_size" android:layout_alignParentTop="true" tools:layout="@layout/assistant_top_bar_fragment" /> diff --git a/app/src/main/res/layout/assistant_generic_account_warning_fragment.xml b/app/src/main/res/layout/assistant_generic_account_warning_fragment.xml new file mode 100644 index 000000000..b4ab8d0c1 --- /dev/null +++ b/app/src/main/res/layout/assistant_generic_account_warning_fragment.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + +