diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 85f3b6ff8e..313652c8eb 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -39,7 +39,7 @@ If you are using a SDK that isn't the latest release, please update first as it' 5. **SDK logs** (mandatory) -Enable debug logs in advanced section of the settings, restart the app, reproduce the issue and then go back to advanced settings, click on "Send logs" and copy/paste the link here. +Click on "Share logs" in Help -> Troubleshooting view and copy/paste the link here. It's also explained [in the README](https://github.com/BelledonneCommunications/linphone-android#behavior-issue). diff --git a/.github/workflows/an-mobile-android.yml b/.github/workflows/an-mobile-android.yml index f9298bccb5..b15a5711c7 100644 --- a/.github/workflows/an-mobile-android.yml +++ b/.github/workflows/an-mobile-android.yml @@ -4,6 +4,7 @@ on: branches: - main - testing + - 'release/**' workflow_dispatch: concurrency: group: "${{ github.ref }}" @@ -42,6 +43,7 @@ jobs: export GOOGLE_APPLICATION_CREDENTIALS=./google-creds.json if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then python3 upload-app.py --track beta com.acceleratenetworks.mobile "$(./version.sh)"; fi if [[ "${{ github.ref }}" == "refs/heads/testing" ]]; then python3 upload-app.py --track alpha com.acceleratenetworks.mobile "$(./version.sh)"; fi + if [[ "${{ github.ref }}" == refs/heads/release/* ]]; then python3 upload-app.py --track alpha com.acceleratenetworks.mobile "$(./version.sh)"; fi - uses: actions/upload-artifact@v4.1.0 if: always() with: diff --git a/.gitlab-ci-files/job-android.yml b/.gitlab-ci-files/job-android.yml index 946909ca40..66cffa96c3 100644 --- a/.gitlab-ci-files/job-android.yml +++ b/.gitlab-ci-files/job-android.yml @@ -2,7 +2,7 @@ job-android: stage: build tags: [ "docker-android" ] - image: gitlab.linphone.org:4567/bc/public/linphone-android/bc-dev-android:20230414_bullseye_jdk_17_cleaned + image: gitlab.linphone.org:4567/bc/public/linphone-android/bc-dev-android-36:20260120_trixie_java21_android36_gradle9 before_script: - if ! [ -z ${SCP_PRIVATE_KEY+x} ]; then eval $(ssh-agent -s); fi diff --git a/.tx/config b/.tx/config deleted file mode 100644 index 93225826fd..0000000000 --- a/.tx/config +++ /dev/null @@ -1,11 +0,0 @@ -[main] -host = https://www.transifex.com -lang_map = fr_CA:fr-rCA,pt_BR:pt-rBR,zh_CN:zh-rCN,zh_HK:zh-rHK,zh_TW:zh-rTW,da_DK:da-rDK,sv_SE:sv-rSE,kn_IN:kn-rIN,nl_NL:nl-rNL,en_NL:en-rNL,he:iw -minimum_perc = 1 -type = ANDROID - -[linphone-android.stringsxml] -file_filter = app/src/main/res/values-/strings.xml -source_file = app/src/main/res/values/strings.xml -source_lang = en - diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e22bc7e27..c0e21046a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,108 @@ 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. +## [6.2.3] - 2026-07-07 + +### Added +- Setting to disable proximity sensor turning screen off when device is next to the ear during audio calls + +### Changed +- Show chat message emoji reaction as soon as selected instead of waiting for message to be sent + +### Fixed +- Account registrar & outbound SIP proxy URIs transport that couldn't be changed +- Layout when conference is paused +- Prevent fullscreen mode when conference is paused + +## [6.2.2] - 2026-06-25 + +### Changed +- Improved keep app alive foreground service notification content text +- Only resume media player after seeking if it was playing before + +### Fixed +- Prevent drawer menu to be drawn behind the navigation bar in landscape + +## [6.2.1] - 2026-06-23 + +### Added +- Added proximity sensor listener to be able to log events +- Laotian translation from Weblate + +### Changed +- Abort single sign-on process if server URL is null or empty +- Automatically route audio to wired headphones/headset if such device is available + +### Fixed +- Active call screen showing up after going back from outgoing call screen +- Prevent DTMF to be played indefinitely when swiping the numpad bottom sheet away while pressing a digit + +## [6.2.0] - 2026-06-02 + +### Added +- Added the ability to edit/delete chat messages sent less than 24 hours ago. +- Added keyboard shortcuts on IncomingCallFragment: Ctrl + Shift + A to answer the call, Ctrl + Shift + D to decline it +- Added seeking feature to recordings & media player within app +- Added PDF preview in conversation (message bubble & documents list) +- Added media/documents access from contact page if a 1-1 conversation with any of the contact SIP addresses is found +- Added hover effect when using a mouse (useful for tablets or devices with desktop mode) +- Support right click on some items to open bottom sheet/menu +- Added toggle speaker action in active call notification +- Increased text size for chat messages that only contains emoji(s) +- Use user-input to filter participants list after typing "@" in conversation send area +- Handle read-only CardDAV address books, disable edit/delete menus for contacts in read-only FriendList +- Added swipe/pull to refresh on contacts list of a CardDAV addressbook has been configured to force the synchronization +- Show information to user when filtering contacts doesn't show them all and user may have to refine it's search +- Core will now automatically delete from device files that were sent/received in a chat message when it is deleted (because it's ephemeral or has been manually deleted) +- Show Android notification when an account goes to failed registration state (only when background mode is enabled) +- New settings: + - one for user to choose whether to sort contacts by first name or last name + - one to hide contacts that have neither a SIP address nor a phone number + - one to let app auto-answer call with video sending already enabled + - one to let edit native contacts Linphone copy in-app instead of opening native addressbook third party app + - one to show past meetings (they are now hidden by default) + - one to hide received message content in android notification + - possibility to choose between RFC2833 and SIP INFO for DTMFs +- Added a vu meter for recording & playback volumes (must be enabled in developer settings) +- Added support for HDMI audio devices +- Added video preview during in-call conversation +- Allow text selection in chat bubble (once long press menu is displayed) + +### Changed +- App now targets API level 37 +- No longer follow TelecomManager audio endpoint during calls, using our own routing policy +- Show matching contacts & suggestions when filtering call history list & conversations list, allowing to quickly call someone without opening the start call/conversation fragment +- Join a conference using default layout instead of audio only when clicking on a meeting SIP URI +- Removing an account will also remove all related data in the local database (auth info, call logs, conversations, meetings, etc...) +- Hide SIP address/phone number picker dialog if contact has exactly one SIP address matching both the app default domain & the currently selected account domain +- Hide SIP address associated to phone number through presence mecanism in contact details & editor views. +- Improved UI on tablets with screen sw600dp and higher, will look more like our desktop app +- Improved navigation within app when using a keyboard +- Now loading media/documents contents in conversation by chunks (instead of all of them at once) +- If in-call foreground service doesn't start, show an error notification and clicking on it will fix the issue (by bringing Linphone in foreground and re-starting the foreground service) +- Restart keep-alive service foreground notification if it's dismissed to ensure app will be kept alive in background +- Simplified audio device name in settings +- Reworked some settings (moved calls related ones from advanced settings to advanced calls settings) +- Removed menu to access account profile, button is now directly available from drawer menu +- Increased shared media preview size in chat +- Un-encrypted conversation warning will be more visible for accounts that support end-to-end encrypted conversations +- Made numpad buttons larger by changing their shape +- All LDAP fields are mandatory now +- Improved how Android shortcuts are created +- Permission fragment will only show missing ones +- Added more info into StartupListener logs +- Updated password forgotten procedure, will use online account manager platform +- Added confirmation dialog before deleting/removing something/someone (contact, meeting, conversation, call log, etc...) +- Delivery information menu when long pressing an incoming chat message has been removed since we no longer send IMDNs to all participants of the group conversation + +### Fixed +- Prevent crash & background mode from turning on when doing a remote provisioning if an account is already configured +- Prevent app from sending all phone numbers & SIP addresses to sip.linphone.org presence server for long term presence feature if account is not sip.linphone.org +- Fixed SIP address picker not showing up in call transfer view when selecting a contact with more than 1 SIP address +- Prevent incoming call notification from staying visible and answer button from doing nothing after answering incoming conference call +- Copy raw message content instead of modified one when it contains a participant mention ("@username") +- Do not apply Crashlytics plugin if it's not enabled + ## [6.0.23] - 2026-02-02 ### Fixed @@ -48,14 +150,14 @@ Group changes to describe their impact on the project, as follows: ### Changed - Added shrink resources to release config in gradle -### Fixed +### Fixed - Remove AuthInfo when configuring a CardDAV friend list if synchronization fails - Added missing toast when starting a group call or meeting if there's an issue - Fixed crash in RecordingPlayerFragment due to used lateinit property before it's initialized ## [6.0.19] - 2025-10-16 -### Added +### Added - Spanish and Slovakian translations thanks to Weblate contributors ### Changed diff --git a/README.md b/README.md index 4e2156060c..7ed3d4ff86 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,7 @@ We no longer use transifex for the translation process, instead we have deployed Due to the full app rewrite we can't re-use previous translations, so we'll be very happy if you want to contribute. -Translation status +Translation status # CONTRIBUTIONS diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cd6d037408..d0d103a5dc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,7 +1,8 @@ import com.android.build.gradle.internal.tasks.factory.dependsOn import com.google.firebase.crashlytics.buildtools.gradle.CrashlyticsExtension +import com.google.firebase.crashlytics.buildtools.gradle.CrashlyticsPlugin import com.google.gms.googleservices.GoogleServicesPlugin -import java.io.ByteArrayOutputStream +import java.io.BufferedReader import java.io.FileInputStream import java.util.Properties @@ -11,7 +12,6 @@ plugins { alias(libs.plugins.ktlint) alias(libs.plugins.jetbrainsKotlinAndroid) alias(libs.plugins.navigation) - alias(libs.plugins.crashlytics) } val packageName = "com.acceleratenetworks.mobile" @@ -25,63 +25,69 @@ val firebaseCloudMessagingAvailable = googleServices.exists() val crashlyticsAvailable = googleServices.exists() && linphoneLibs.exists() && linphoneDebugLibs.exists() if (firebaseCloudMessagingAvailable) { - println("google-services.json found, enabling CloudMessaging feature") + println("google-services.json found, enabling Firebase CloudMessaging feature") apply() } else { - println("google-services.json not found, disabling CloudMessaging feature") + println("google-services.json not found, disabling Firebase CloudMessaging feature") +} +if (crashlyticsAvailable) { + println("google-services.json found and Linphone SDK libs-debug folder found, enabling Crashlytics feature") + apply() +} else { + println("Crashlytics has been disabled because either google-services.json file wasn't found or local Linphone SDK build folder isn't configured") } -var gitBranch = ByteArrayOutputStream() -var gitVersion = "6.2.8" // linphone 6.0.23 - -task("getGitVersion") { - val gitVersionStream = ByteArrayOutputStream() - val gitCommitsCount = ByteArrayOutputStream() - val gitCommitHash = ByteArrayOutputStream() - - try { - exec { - commandLine("git", "describe", "--abbrev=0") - standardOutput = gitVersionStream - } - exec { - commandLine( - "git", - "rev-list", - gitVersionStream.toString().trim() + "..HEAD", - "--count", - ) - standardOutput = gitCommitsCount - } - exec { - commandLine("git", "rev-parse", "--short", "HEAD") - standardOutput = gitCommitHash - } - exec { - commandLine("git", "name-rev", "--name-only", "HEAD") - standardOutput = gitBranch +var gitVersion = "6.2.9" // AN app version; linphone base 6.2.3 +var gitBranch = "" +try { + val gitDescribe = ProcessBuilder() + .command("git", "describe", "--abbrev=0") + .directory(project.rootDir) + .start() + .inputStream.bufferedReader().use(BufferedReader::readText) + .trim() + println("Git describe: $gitDescribe") + + val gitCommitsCount = ProcessBuilder() + .command("git", "rev-list", "$gitDescribe..HEAD", "--count") + .directory(project.rootDir) + .start() + .inputStream.bufferedReader().use(BufferedReader::readText) + .trim() + println("Git commits count: $gitCommitsCount") + + val gitCommitHash = ProcessBuilder() + .command("git", "rev-parse", "--short", "HEAD") + .directory(project.rootDir) + .start() + .inputStream.bufferedReader().use(BufferedReader::readText) + .trim() + println("Git commit hash: $gitCommitHash") + + gitBranch = ProcessBuilder() + .command("git", "name-rev", "--name-only", "HEAD") + .directory(project.rootDir) + .start() + .inputStream.bufferedReader().use(BufferedReader::readText) + .trim() + println("Git branch name: $gitBranch") + + gitVersion = + if (gitCommitsCount.toInt() == 0) { + gitDescribe + } else { + "$gitDescribe.$gitCommitsCount+$gitCommitHash" } - - gitVersion = - if (gitCommitsCount.toString().trim().toInt() == 0) { - gitVersionStream.toString().trim() - } else { - gitVersionStream.toString().trim() + "." + - gitCommitsCount.toString() - .trim() + "+" + gitCommitHash.toString().trim() - } - println("Git version: $gitVersion") - } catch (e: Exception) { - println("Git not found [$e], using $gitVersion") - } - project.version = gitVersion +} catch (e: Exception) { + println("Git not found [$e], using $gitVersion") } -project.tasks.preBuild.dependsOn("getGitVersion") +println("Computed git version: $gitVersion") configurations { implementation { isCanBeResolved = true } } -task("linphoneSdkSource") { + +tasks.register("linphoneSdkSource") { doLast { configurations.implementation.get().incoming.resolutionResult.allComponents.forEach { if (it.id.displayName.contains("linphone-sdk-android")) { @@ -94,14 +100,14 @@ project.tasks.preBuild.dependsOn("linphoneSdkSource") android { namespace = "org.linphone" - compileSdk = 35 + compileSdk = 37 defaultConfig { applicationId = packageName minSdk = 28 - targetSdk = 35 - versionCode = 60020008 // 6.002.0008 - versionName = "6.2.8" + targetSdk = 37 + versionCode = 60020010 // 6.002.0010 + versionName = "6.2.10" manifestPlaceholders["appAuthRedirectScheme"] = packageName @@ -116,7 +122,7 @@ android { variant.outputs .map { it as com.android.build.gradle.internal.api.BaseVariantOutputImpl } .forEach { output -> - output.outputFileName = "linphone-android-${variant.buildType.name}-${project.version}.apk" + output.outputFileName = "linphone-android-${variant.buildType.name}-$gitVersion.apk" } } @@ -153,13 +159,16 @@ android { isDebuggable = true isJniDebuggable = true + val appVersion = gitVersion + val appBranch = gitBranch + println("Debug flavor app version is [$appVersion], app branch is [$appBranch]") + resValue("string", "linphone_app_version", appVersion) + resValue("string", "linphone_app_branch", appBranch) if (useDifferentPackageNameForDebugBuild) { resValue("string", "file_provider", "$packageName.debug.fileprovider") } else { resValue("string", "file_provider", "$packageName.fileprovider") } - resValue("string", "linphone_app_version", gitVersion.trim()) - resValue("string", "linphone_app_branch", gitBranch.toString().trim()) resValue("string", "linphone_openid_callback_scheme", packageName) if (crashlyticsAvailable) { @@ -183,9 +192,12 @@ android { signingConfig = signingConfigs.getByName("release") } + val appVersion = gitVersion + val appBranch = gitBranch + println("Release flavor app version is [$appVersion], app branch is [$appBranch]") + resValue("string", "linphone_app_version", appVersion) + resValue("string", "linphone_app_branch", appBranch) resValue("string", "file_provider", "$packageName.fileprovider") - resValue("string", "linphone_app_version", gitVersion.trim()) - resValue("string", "linphone_app_branch", gitBranch.toString().trim()) resValue("string", "linphone_openid_callback_scheme", packageName) if (crashlyticsAvailable) { @@ -200,17 +212,14 @@ android { } compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - kotlinOptions { - jvmTarget = "17" + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 } buildFeatures { dataBinding = true buildConfig = true + resValues = true } lint { @@ -220,7 +229,6 @@ android { dependencies { implementation(libs.androidx.annotations) - implementation(libs.androidx.activity) implementation(libs.androidx.appcompat) implementation(libs.androidx.constraint.layout) implementation(libs.androidx.core.ktx) @@ -228,6 +236,7 @@ dependencies { implementation(libs.androidx.telecom) implementation(libs.androidx.media) implementation(libs.androidx.recyclerview) + implementation(libs.androidx.swiperefreshlayout) implementation(libs.androidx.slidingpanelayout) implementation(libs.androidx.window) implementation(libs.androidx.gridlayout) @@ -246,7 +255,11 @@ dependencies { implementation(platform(libs.google.firebase.bom)) implementation(libs.google.firebase.messaging) - implementation(libs.google.firebase.crashlytics) + if (crashlyticsAvailable) { + implementation(libs.google.firebase.crashlytics) + } else { + compileOnly(libs.google.firebase.crashlytics) + } // https://github.com/coil-kt/coil/blob/main/LICENSE.txt Apache v2.0 implementation(libs.coil) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1984e3dda1..3a5ef37f41 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -136,6 +136,12 @@ android:launchMode="singleTask" android:resizeableActivity="true" /> + + srtp 1 + sips:rls@sip.linphone.org diff --git a/app/src/main/assets/assistant_third_party_default_values b/app/src/main/assets/assistant_third_party_default_values index d19b3f5045..511252548d 100644 --- a/app/src/main/assets/assistant_third_party_default_values +++ b/app/src/main/assets/assistant_third_party_default_values @@ -32,4 +32,7 @@ srtp 0 +
+ 1 +
diff --git a/app/src/main/assets/linphonerc_default b/app/src/main/assets/linphonerc_default index 0b7f694fd3..c373d10ac0 100644 --- a/app/src/main/assets/linphonerc_default +++ b/app/src/main/assets/linphonerc_default @@ -3,7 +3,6 @@ [sip] contact="Linphone Android" -use_info=0 use_ipv6=1 keepalive_period=30000 sip_port=-1 @@ -13,7 +12,6 @@ media_encryption=none update_presence_model_timestamp_before_publish_expires_refresh=1 use_rfc2833=1 use_info=1 -rls_uri=sips:rls@sip.linphone.org [net] #Because dynamic bitrate adaption can increase bitrate, we must allow "no limit" @@ -56,4 +54,7 @@ imdn_to_everybody_threshold=1 [ui] contacts_filter=sip.linphone.org +[account_creator] +url=https://subscribe.linphone.org/api/ + ## End of default rc diff --git a/app/src/main/assets/linphonerc_factory b/app/src/main/assets/linphonerc_factory index d33320b241..cf21b3804c 100644 --- a/app/src/main/assets/linphonerc_factory +++ b/app/src/main/assets/linphonerc_factory @@ -36,6 +36,14 @@ android_monitor_audio_devices=0 displaytype=MSAndroidTextureDisplay auto_resize_preview_to_keep_ratio=1 max_conference_size=vga +# AccelerateNetworks: server infrastructure cannot carry video. Video is off by +# default (capture/display disabled, policy set to never auto-initiate/accept). +# UI hides itself via hideVideo = !core.isVideoEnabled; there is no toggle to turn +# it on and AN provisioning must not set these on. +capture=0 +display=0 +automatically_initiate=0 +automatically_accept=0 [misc] enable_basic_to_client_group_chat_room_migration=0 @@ -47,10 +55,6 @@ store_friends=0 [app] record_aware=1 -[account_creator] -url=https://subscribe.linphone.org/api/ -backend=1 - [lime] lime_update_threshold=86400 diff --git a/app/src/main/java/org/linphone/compatibility/Api28Compatibility.kt b/app/src/main/java/org/linphone/compatibility/Api28Compatibility.kt index e58cc06258..91e0b5cf70 100644 --- a/app/src/main/java/org/linphone/compatibility/Api28Compatibility.kt +++ b/app/src/main/java/org/linphone/compatibility/Api28Compatibility.kt @@ -34,15 +34,17 @@ class Api28Compatibility { companion object { private const val TAG = "[API 28 Compatibility]" - fun startServiceForeground(service: Service, id: Int, notification: Notification) { + fun startServiceForeground(service: Service, id: Int, notification: Notification): Boolean { try { service.startForeground( id, notification ) + return true } catch (e: Exception) { Log.e("$TAG Can't start service as foreground! $e") } + return false } fun enterPipMode(activity: Activity): Boolean { diff --git a/app/src/main/java/org/linphone/compatibility/Api33Compatibility.kt b/app/src/main/java/org/linphone/compatibility/Api33Compatibility.kt index 4f2c91070f..315fd59c6b 100644 --- a/app/src/main/java/org/linphone/compatibility/Api33Compatibility.kt +++ b/app/src/main/java/org/linphone/compatibility/Api33Compatibility.kt @@ -20,6 +20,7 @@ package org.linphone.compatibility import android.Manifest +import android.app.ActivityOptions import android.content.Context import android.content.pm.PackageManager import android.os.Build @@ -46,6 +47,12 @@ class Api33Compatibility { ) == PackageManager.PERMISSION_GRANTED } + fun getPendingIntentActivityOptions(): ActivityOptions { + val options = ActivityOptions.makeBasic() + options.isPendingIntentBackgroundActivityLaunchAllowed = true + return options + } + fun hasTelecomManagerFeature(context: Context): Boolean { val hasFeature = context.packageManager.hasSystemFeature( PackageManager.FEATURE_TELECOM diff --git a/app/src/main/java/org/linphone/compatibility/Api34Compatibility.kt b/app/src/main/java/org/linphone/compatibility/Api34Compatibility.kt index 004d0c8e7a..285340afa7 100644 --- a/app/src/main/java/org/linphone/compatibility/Api34Compatibility.kt +++ b/app/src/main/java/org/linphone/compatibility/Api34Compatibility.kt @@ -19,13 +19,16 @@ */ package org.linphone.compatibility +import android.app.ActivityOptions import android.app.Notification import android.app.NotificationManager +import android.app.PendingIntent import android.app.Service import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent import android.os.Build +import android.os.Bundle import android.provider.Settings import androidx.annotation.RequiresApi import org.linphone.core.tools.Log @@ -41,16 +44,18 @@ class Api34Compatibility { id: Int, notification: Notification, foregroundServiceType: Int - ) { + ): Boolean { try { service.startForeground( id, notification, foregroundServiceType ) + return true } catch (e: Exception) { Log.e("$TAG Can't start service as foreground! $e") } + return false } fun hasFullScreenIntentPermission(context: Context): Boolean { @@ -78,5 +83,21 @@ class Api34Compatibility { Log.e("$TAG Failed to start intent for granting full screen intent permission: $anfe") } } + + fun sendPendingIntent(pendingIntent: PendingIntent, bundle: Bundle) { + pendingIntent.send(bundle) + } + + fun getPendingIntentActivityOptions(creator: Boolean): ActivityOptions { + val options = ActivityOptions.makeBasic() + if (creator) { + options.pendingIntentCreatorBackgroundActivityStartMode = + ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED + } else { + options.pendingIntentBackgroundActivityStartMode = + ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED + } + return options + } } } diff --git a/app/src/main/java/org/linphone/compatibility/Api35Compatibility.kt b/app/src/main/java/org/linphone/compatibility/Api35Compatibility.kt index 13a9208693..d87066df41 100644 --- a/app/src/main/java/org/linphone/compatibility/Api35Compatibility.kt +++ b/app/src/main/java/org/linphone/compatibility/Api35Compatibility.kt @@ -39,18 +39,42 @@ class Api35Compatibility { Executors.newSingleThreadExecutor() ) { info -> Log.i("==== Current startup information dump ====") - Log.i("TYPE = ${startupTypeToString(info.startType)}") - Log.i("STATE = ${startupStateToString(info.startupState)}") - Log.i("REASON = ${startupReasonToString(info.reason)}") - Log.i("FORCE STOPPED = ${if (info.wasForceStopped()) "yes" else "no"}") - Log.i("PROCESS NAME = ${info.processName}") - Log.i("=========================================") + logAppStartupInfo(info) + } + + Log.i("==== Fetching last three startup reasons if available ====") + val lastStartupInfo = activityManager.getHistoricalProcessStartReasons(3) + for (info in lastStartupInfo) { + Log.i("==== Previous startup information dump ====") + logAppStartupInfo(info) } } catch (iae: IllegalArgumentException) { Log.e("$TAG Can't add application start info completion listener: $iae") } } + private fun logAppStartupInfo(info: ApplicationStartInfo) { + Log.i("TYPE = ${startupTypeToString(info.startType)}") + Log.i("STATE = ${startupStateToString(info.startupState)}") + Log.i("REASON = ${startupReasonToString(info.reason)}") + Log.i("START COMPONENT = ${startComponentToString(info.launchMode)}") + Log.i("INTENT = ${info.intent}") + Log.i("FORCE STOPPED = ${if (info.wasForceStopped()) "yes" else "no"}") + Log.i("PROCESS NAME = ${info.processName}") + Log.i("=========================================") + } + + private fun startComponentToString(component: Int): String { + return when (component) { + ApplicationStartInfo.START_COMPONENT_ACTIVITY -> "Activity" + ApplicationStartInfo.START_COMPONENT_BROADCAST -> "Broadcast" + ApplicationStartInfo.START_COMPONENT_CONTENT_PROVIDER -> "Content Provider" + ApplicationStartInfo.START_COMPONENT_SERVICE -> "Service" + ApplicationStartInfo.START_COMPONENT_OTHER -> "Other" + else -> "Unexpected ($component)" + } + } + private fun startupTypeToString(type: Int): String { return when (type) { ApplicationStartInfo.START_TYPE_COLD -> "Cold" diff --git a/app/src/main/java/org/linphone/compatibility/Api36Compatibility.kt b/app/src/main/java/org/linphone/compatibility/Api36Compatibility.kt new file mode 100644 index 0000000000..d091c59645 --- /dev/null +++ b/app/src/main/java/org/linphone/compatibility/Api36Compatibility.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2010-2025 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.app.ActivityOptions +import android.os.Build +import androidx.annotation.RequiresApi + +@RequiresApi(Build.VERSION_CODES.BAKLAVA) +class Api36Compatibility { + companion object { + private const val TAG = "[API 36 Compatibility]" + + fun getPendingIntentActivityOptions(creator: Boolean): ActivityOptions { + val options = ActivityOptions.makeBasic() + if (creator) { + options.pendingIntentCreatorBackgroundActivityStartMode = + ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOW_ALWAYS + } else { + options.pendingIntentBackgroundActivityStartMode = + ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOW_ALWAYS + } + return options + } + } +} diff --git a/app/src/main/java/org/linphone/compatibility/Compatibility.kt b/app/src/main/java/org/linphone/compatibility/Compatibility.kt index d78de59118..99dd1d02bb 100644 --- a/app/src/main/java/org/linphone/compatibility/Compatibility.kt +++ b/app/src/main/java/org/linphone/compatibility/Compatibility.kt @@ -22,11 +22,14 @@ package org.linphone.compatibility import android.Manifest import android.annotation.SuppressLint import android.app.Activity +import android.app.ActivityOptions import android.app.Notification +import android.app.PendingIntent import android.app.Service import android.content.Context import android.content.Intent import android.net.Uri +import android.os.Bundle import android.os.Environment import android.util.Patterns import android.view.View @@ -49,8 +52,8 @@ class Compatibility { id: Int, notification: Notification, foregroundServiceType: Int - ) { - if (Version.sdkAboveOrEqual(Version.API34_ANDROID_14_UPSIDE_DOWN_CAKE)) { + ): Boolean { + return if (Version.sdkAboveOrEqual(Version.API34_ANDROID_14_UPSIDE_DOWN_CAKE)) { Api34Compatibility.startServiceForeground( service, id, @@ -203,5 +206,24 @@ class Compatibility { } return Patterns.IP_ADDRESS.matcher(string).matches() } + + fun sendPendingIntent(pendingIntent: PendingIntent, bundle: Bundle) { + if (Version.sdkAboveOrEqual(Version.API34_ANDROID_14_UPSIDE_DOWN_CAKE)) { + return Api34Compatibility.sendPendingIntent(pendingIntent, bundle) + } + pendingIntent.send() + } + + fun getPendingIntentActivityOptions(creator: Boolean): ActivityOptions { + if (Version.sdkAboveOrEqual(Version.API36_ANDROID_16_BAKLAVA)) { + return Api36Compatibility.getPendingIntentActivityOptions(creator) + } else if (Version.sdkAboveOrEqual(Version.API34_ANDROID_14_UPSIDE_DOWN_CAKE)) { + return Api34Compatibility.getPendingIntentActivityOptions(creator) + } else if (Version.sdkAboveOrEqual(Version.API33_ANDROID_13_TIRAMISU)) { + return Api33Compatibility.getPendingIntentActivityOptions() + } + + return ActivityOptions.makeBasic() + } } } diff --git a/app/src/main/java/org/linphone/contacts/AvatarGenerator.kt b/app/src/main/java/org/linphone/contacts/AvatarGenerator.kt index 704e5543d3..a66cda5123 100644 --- a/app/src/main/java/org/linphone/contacts/AvatarGenerator.kt +++ b/app/src/main/java/org/linphone/contacts/AvatarGenerator.kt @@ -25,7 +25,6 @@ import android.graphics.Canvas import android.graphics.Paint import android.graphics.Rect import android.graphics.RectF -import android.graphics.drawable.BitmapDrawable import android.text.TextPaint import android.util.TypedValue import androidx.core.content.ContextCompat @@ -34,7 +33,6 @@ import androidx.core.graphics.drawable.IconCompat import org.linphone.R import org.linphone.utils.AppUtils import androidx.core.graphics.createBitmap -import androidx.core.graphics.drawable.toDrawable class AvatarGenerator(private val context: Context) { private var textSize: Float = AppUtils.getDimension(R.dimen.avatar_initials_text_size) @@ -92,10 +90,6 @@ class AvatarGenerator(private val context: Context) { return bitmap } - fun buildDrawable(): BitmapDrawable { - return buildBitmap(true).toDrawable(context.resources) - } - fun buildIcon(): IconCompat { return IconCompat.createWithAdaptiveBitmap(buildBitmap(false)) } diff --git a/app/src/main/java/org/linphone/contacts/ContactLoader.kt b/app/src/main/java/org/linphone/contacts/ContactLoader.kt index e6f766c4a0..8fe983d47b 100644 --- a/app/src/main/java/org/linphone/contacts/ContactLoader.kt +++ b/app/src/main/java/org/linphone/contacts/ContactLoader.kt @@ -63,8 +63,6 @@ class ContactLoader : LoaderManager.LoaderCallbacks { const val NATIVE_ADDRESS_BOOK_FRIEND_LIST = "Native address-book" const val LINPHONE_ADDRESS_BOOK_FRIEND_LIST = "Linphone address-book" - - private const val MIN_INTERVAL_TO_WAIT_BEFORE_REFRESH = 300000L // 5 minutes } private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @@ -102,7 +100,7 @@ class ContactLoader : LoaderManager.LoaderCallbacks { // WARNING: this doesn't prevent to be called again in onLoadFinished, // it will only have for effect that the notified cursor will be the same as before // instead of a new one with updated content! - // loader.setUpdateThrottle(MIN_INTERVAL_TO_WAIT_BEFORE_REFRESH) + // loader.setUpdateThrottle(300000L) return loader } @@ -324,83 +322,16 @@ class ContactLoader : LoaderManager.LoaderCallbacks { } Log.i("$TAG Friends added") } else { + val friendsArray = friends.values.toTypedArray() Log.i( "$TAG Friend list [$NATIVE_ADDRESS_BOOK_FRIEND_LIST] found, synchronizing existing friends with new ones" ) - for (localFriend in friendsList.friends) { - val newlyFetchedFriend = friends[localFriend.refKey] - if (newlyFetchedFriend != null) { - friends.remove(localFriend.refKey) - localFriend.nativeUri = - newlyFetchedFriend.nativeUri // Native URI isn't stored in linphone database, needs to be updated - if (newlyFetchedFriend.dumpVcard() == localFriend.dumpVcard()) continue - - localFriend.edit() - // Update basic fields that may have changed - localFriend.name = newlyFetchedFriend.name - localFriend.organization = newlyFetchedFriend.organization - localFriend.jobTitle = newlyFetchedFriend.jobTitle - localFriend.photo = newlyFetchedFriend.photo - - // Clear local friend phone numbers & add all newly fetched one ones - var atLeastAPhoneNumberWasRemoved = false - for (phoneNumber in localFriend.phoneNumbersWithLabel) { - val found = newlyFetchedFriend.phoneNumbers.find { - it == phoneNumber.phoneNumber - } - if (found == null) { - atLeastAPhoneNumberWasRemoved = true - } - localFriend.removePhoneNumberWithLabel(phoneNumber) - } - for (phoneNumber in newlyFetchedFriend.phoneNumbersWithLabel) { - localFriend.addPhoneNumberWithLabel(phoneNumber) - } - - // If at least a phone number was removed, remove all SIP address from local friend before adding all from newly fetched one. - // If none was removed, simply add SIP addresses from fetched contact that aren't already in the local friend. - if (atLeastAPhoneNumberWasRemoved) { - Log.w( - "$TAG At least a phone number was removed from native contact [${localFriend.name}], clearing all SIP addresses from local friend before adding back the ones that still exists" - ) - for (sipAddress in localFriend.addresses) { - localFriend.removeAddress(sipAddress) - } - } - - // Adding only newly added SIP address(es) in native contact if any - for (sipAddress in newlyFetchedFriend.addresses) { - localFriend.addAddress(sipAddress) - } - localFriend.done() - } else { - Log.i( - "$TAG Friend [${localFriend.name}] with ref key [${localFriend.refKey}] not found in newly fetched batch, removing it" - ) - friendsList.removeFriend(localFriend) - } - } - - // Check for newly created friends since last sync - val localFriends = friendsList.friends - for ((key, newFriend) in friends.entries) { - val found = localFriends.find { - it.refKey == key - } - if (found == null) { - if (newFriend.refKey == null) { - Log.w( - "$TAG Found friend [${newFriend.name}] with no refKey, using ID [$key]" - ) - newFriend.refKey = key - } - Log.i( - "$TAG Friend [${newFriend.name}] with ref key [${newFriend.refKey}] not found in currently stored list, adding it" - ) - friendsList.addLocalFriend(newFriend) - } + val changes = friendsList.synchronizeFriendsWith(friendsArray) + if (changes) { + Log.i("$TAG Locally stored friends synchronized with native address book") + } else { + Log.i("$TAG No changes detected between native address book and local friends storage") } - Log.i("$TAG Friends synchronized") } friends.clear() diff --git a/app/src/main/java/org/linphone/contacts/ContactsManager.kt b/app/src/main/java/org/linphone/contacts/ContactsManager.kt index 024cb0f377..93f3e08ec1 100644 --- a/app/src/main/java/org/linphone/contacts/ContactsManager.kt +++ b/app/src/main/java/org/linphone/contacts/ContactsManager.kt @@ -19,9 +19,7 @@ */ package org.linphone.contacts -import android.Manifest import android.content.ContentUris -import android.content.pm.PackageManager import android.graphics.Bitmap import android.net.Uri import android.os.Bundle @@ -29,7 +27,6 @@ import android.provider.ContactsContract import androidx.annotation.MainThread import androidx.annotation.UiThread import androidx.annotation.WorkerThread -import androidx.core.app.ActivityCompat import androidx.core.app.Person import androidx.core.graphics.drawable.IconCompat import androidx.core.text.isDigitsOnly @@ -66,6 +63,7 @@ import org.linphone.utils.ImageUtils import org.linphone.utils.LinphoneUtils import org.linphone.utils.PhoneNumberUtils import org.linphone.utils.ShortcutUtils +import java.io.FileNotFoundException class ContactsManager @UiThread @@ -98,8 +96,6 @@ class ContactsManager private val magicSearchListener = object : MagicSearchListenerStub() { @WorkerThread override fun onSearchResultsReceived(magicSearch: MagicSearch) { - reloadRemoteContactsJob?.cancel() - var queriedSipUri = "" for ((key, value) in magicSearchMap.entries) { if (value == magicSearch) { @@ -123,6 +119,7 @@ class ContactsManager Log.w("$TAG Received friend [${friend.name}] with SIP URI [$address] doesn't match queried SIP URI [$queriedSipUri]") } else { found = true + reloadRemoteContactsJob?.cancel() // Store friend in app's cache to be re-used in call history, conversations, etc... val temporaryFriendList = getRemoteContactDirectoriesCacheFriendList() @@ -262,6 +259,9 @@ class ContactsManager Log.i("$TAG Friend list [${friendList.displayName}] sync status changed to [$status]") when (status) { FriendList.SyncStatus.Successful -> { + if (friendList.type == FriendList.Type.VCard4) { + enableDirectoryPresenceSubscriptions(friendList) + } notifyContactsListChanged() } FriendList.SyncStatus.Failure -> { @@ -415,8 +415,9 @@ class ContactsManager notifyContactsListChanged() - Log.i("$TAG Native contacts have been loaded, creating chat rooms shortcuts") - ShortcutUtils.createShortcutsToChatRooms(coreContext.context) + Log.i("$TAG Native contacts have been loaded") + // No longer create chat room shortcuts depending on most recents ones, create it when a message is sent or received instead + // ShortcutUtils.createShortcutsToChatRooms(coreContext.context) } @WorkerThread @@ -442,8 +443,10 @@ class ContactsManager @WorkerThread fun findContactByAddress(address: Address): Friend? { + Log.i("$TAG Looking for friend matching SIP address [${address.asStringUriOnly()}]") val found = coreContext.core.findFriend(address) if (found != null) { + Log.i("$TAG Found friend [${found.name}] matching SIP address [${address.asStringUriOnly()}]") return found } @@ -472,8 +475,11 @@ class ContactsManager } return if (!username.isNullOrEmpty() && (username.startsWith("+") || username.isDigitsOnly())) { - Log.d("$TAG Looking for friend with phone number [$username]") + Log.i("$TAG Looking for friend using phone number [$username]") val foundUsingPhoneNumber = coreContext.core.findFriendByPhoneNumber(username) + if (foundUsingPhoneNumber != null) { + Log.i("$TAG Found friend [${foundUsingPhoneNumber.name}] matching phone number [$username]") + } foundUsingPhoneNumber } else { null @@ -597,14 +603,16 @@ class ContactsManager } val context = coreContext.context - if (ActivityCompat.checkSelfPermission( + ShortcutUtils.removeLegacyShortcuts(context) + // No longer create chat room shortcuts depending on most recents ones, create it when a message is sent or received instead + /*if (ActivityCompat.checkSelfPermission( context, Manifest.permission.READ_CONTACTS ) != PackageManager.PERMISSION_GRANTED ) { - Log.w("$TAG READ_CONTACTS permission was denied, creating chat rooms shortcuts") + Log.w("$TAG READ_CONTACTS permission was denied, creating chat rooms shortcuts now") ShortcutUtils.createShortcutsToChatRooms(context) - } + }*/ for (list in core.friendsLists) { if (list.type == FriendList.Type.CardDAV && !list.uri.isNullOrEmpty()) { @@ -616,11 +624,36 @@ class ContactsManager } } + @WorkerThread + private fun enableDirectoryPresenceSubscriptions(friendList: FriendList) { + val homeDomain = LinphoneUtils.getDefaultAccount()?.params?.domain ?: return + var changed = false + for (friend in friendList.friends) { + if (friend.isSubscribesEnabled) continue + if (friend.addresses.any { it.domain == homeDomain }) { + friend.edit() + friend.setSubscribesEnabled(true) + friend.done() + changed = true + } + } + if (changed) { + friendList.isSubscriptionsEnabled = true + friendList.updateSubscriptions() + Log.i("$TAG Enabled presence subscriptions for directory extensions on [$homeDomain]") + } + } + @WorkerThread fun onCoreStopped(core: Core) { Log.w("$TAG Core has been stopped") coroutineScope.cancel() + knownContactsAvatarsMap.clear() + unknownContactsAvatarsMap.clear() + conferenceAvatarMap.clear() + magicSearchMap.clear() + core.removeListener(coreListener) for (list in core.friendsLists) { @@ -736,6 +769,8 @@ fun Friend.getNativeContactPictureUri(): Uri? { fd.close() return pictureUri } + } catch (fnfe: FileNotFoundException) { + Log.w("[Contacts Manager] Can't open [$pictureUri] for contact [$name]: $fnfe") } catch (e: Exception) { Log.e("[Contacts Manager] Can't open [$pictureUri] for contact [$name]: $e") } @@ -795,6 +830,7 @@ fun Friend.getPerson(): Person { @WorkerThread fun Friend.getListOfSipAddresses(): ArrayList
{ val addressesList = arrayListOf
() + if (corePreferences.suppressSipAddresses) return addressesList for (address in addresses) { if (addressesList.find { it.weakEqual(address) } == null) { @@ -809,50 +845,42 @@ fun Friend.getListOfSipAddresses(): ArrayList
{ fun Friend.getListOfSipAddressesAndPhoneNumbers(listener: ContactNumberOrAddressClickListener): ArrayList { val addressesAndNumbers = arrayListOf() + // Will return an empty list if corePreferences.suppressSipAddresses == true for (address in getListOfSipAddresses()) { + if (LinphoneUtils.isSipAddressLinkedToPhoneNumberByPresence(this, address.asStringUriOnly())) { + continue + } + val data = ContactNumberOrAddressModel( this, address, - address.asStringUriOnly(), + LinphoneUtils.getDisplayAddress(address), true, // SIP addresses are always enabled listener, true ) addressesAndNumbers.add(data) } + if (corePreferences.hidePhoneNumbers) { return addressesAndNumbers } - val indexOfLastSipAddress = addressesAndNumbers.count() for (number in phoneNumbersWithLabel) { - val presenceModel = getPresenceModelForUriOrTel(number.phoneNumber) + val phoneNumber = number.phoneNumber + val presenceModel = getPresenceModelForUriOrTel(phoneNumber) val hasPresenceInfo = !presenceModel?.contact.isNullOrEmpty() var presenceAddress: Address? = null if (presenceModel != null && hasPresenceInfo) { - Log.d("[Friend] Phone number [${number.phoneNumber}] has presence information") - // Show linked SIP address if not already stored as-is val contact = presenceModel.contact if (!contact.isNullOrEmpty()) { val address = core.interpretUrl(contact, false) if (address != null) { address.clean() // To remove ;user=phone presenceAddress = address - if (addressesAndNumbers.find { it.address?.weakEqual(address) == true } == null) { - val data = ContactNumberOrAddressModel( - this, - address, - address.asStringUriOnly(), - true, // SIP addresses are always enabled - listener, - true - ) - addressesAndNumbers.add(indexOfLastSipAddress, data) - } - Log.d( - "[Friend] Phone number [${number.phoneNumber}] is linked to SIP address [${presenceAddress.asStringUriOnly()}]" - ) + } else { + Log.e("[Contacts Manager] Failed to parse phone number [$phoneNumber] contact address [$contact] from presence model!") } } } @@ -861,17 +889,20 @@ fun Friend.getListOfSipAddressesAndPhoneNumbers(listener: ContactNumberOrAddress val defaultAccount = LinphoneUtils.getDefaultAccount() val enablePhoneNumbers = hasPresenceInfo || !isEndToEndEncryptionMandatory() val address = presenceAddress ?: core.interpretUrl( - number.phoneNumber, + phoneNumber, LinphoneUtils.applyInternationalPrefix(defaultAccount) ) + address ?: continue + val label = PhoneNumberUtils.vcardParamStringToAddressBookLabel( coreContext.context.resources, number.label ?: "" ) + Log.d("[Contacts Manager] Parsed phone number [$phoneNumber] with label [$label] into address [${address.asStringUriOnly()}], presence address is [${presenceAddress?.asStringUriOnly()}]") val data = ContactNumberOrAddressModel( this, address, - number.phoneNumber, + phoneNumber, enablePhoneNumbers, listener, false, diff --git a/app/src/main/java/org/linphone/core/CoreContext.kt b/app/src/main/java/org/linphone/core/CoreContext.kt index 5d43fc26ee..13c07fc4ba 100644 --- a/app/src/main/java/org/linphone/core/CoreContext.kt +++ b/app/src/main/java/org/linphone/core/CoreContext.kt @@ -21,18 +21,23 @@ package org.linphone.core import android.annotation.SuppressLint import android.app.Application +import android.app.PendingIntent import android.content.Context import android.content.Context.POWER_SERVICE +import android.content.Context.SENSOR_SERVICE import android.content.Intent +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager import android.media.AudioDeviceCallback import android.media.AudioDeviceInfo import android.media.AudioManager +import android.os.Environment import android.os.Handler import android.os.HandlerThread import android.os.Looper import android.os.PowerManager -import android.provider.Settings -import android.provider.Settings.SettingNotFoundException import androidx.annotation.AnyThread import androidx.annotation.UiThread import androidx.annotation.WorkerThread @@ -51,6 +56,7 @@ import org.linphone.telecom.TelecomManager import org.linphone.ui.call.CallActivity import org.linphone.utils.ActivityMonitor import org.linphone.utils.AppUtils +import org.linphone.utils.AudioUtils import org.linphone.utils.Event import org.linphone.utils.FileUtils import org.linphone.utils.LinphoneUtils @@ -94,40 +100,40 @@ class CoreContext var isConnectedToAndroidAuto: Boolean = false val bearerAuthenticationRequestedEvent: MutableLiveData>> by lazy { - MutableLiveData>>() + MutableLiveData() } val digestAuthenticationRequestedEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val clearAuthenticationRequestDialogEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val refreshMicrophoneMuteStateEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val showGreenToastEvent: MutableLiveData>> by lazy { - MutableLiveData>>() + MutableLiveData() } val showRedToastEvent: MutableLiveData>> by lazy { - MutableLiveData>>() + MutableLiveData() } val showFormattedRedToastEvent: MutableLiveData>> by lazy { - MutableLiveData>>() + MutableLiveData() } val provisioningAppliedEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } private var filesToExportToNativeMediaGallery = arrayListOf() val filesToExportToNativeMediaGalleryEvent: MutableLiveData>> by lazy { - MutableLiveData>>() + MutableLiveData() } private var keepAliveServiceStarted = false @@ -142,20 +148,38 @@ class CoreContext override fun onAudioDevicesAdded(addedDevices: Array?) { if (!addedDevices.isNullOrEmpty()) { Log.i("$TAG [${addedDevices.size}] new device(s) have been added:") + var atLeastOneNewDeviceIsBluetooth = false + var atLeastOneNewDeviceIsHeadset = false for (device in addedDevices) { Log.i( "$TAG Added device [${device.productName}] with ID [${device.id}] and type [${device.type}]" ) - } - if (telecomManager.getCurrentlyFollowedCalls() <= 0) { - Log.i("$TAG No call found in Telecom's CallsManager, reloading sound devices in 500ms") - postOnCoreThreadDelayed({ core.reloadSoundDevices() }, 500) - } else { - Log.i( - "$TAG At least one active call in Telecom's CallsManager, let it handle the added device(s)" - ) + when (device.type) { + AudioDeviceInfo.TYPE_BLUETOOTH_SCO, AudioDeviceInfo.TYPE_BLE_HEADSET, AudioDeviceInfo.TYPE_BLE_SPEAKER, AudioDeviceInfo.TYPE_HEARING_AID, AudioDeviceInfo.TYPE_BLE_HEARING_AID -> { + atLeastOneNewDeviceIsBluetooth = true + } + + AudioDeviceInfo.TYPE_USB_HEADSET, AudioDeviceInfo.TYPE_WIRED_HEADPHONES, AudioDeviceInfo.TYPE_WIRED_HEADSET -> { + atLeastOneNewDeviceIsHeadset = true + } + else -> {} + } } + + Log.i("$TAG Reloading sound devices in 500ms") + postOnCoreThreadDelayed({ + Log.i("$TAG Reloading sound devices") + core.reloadSoundDevices() + + if (atLeastOneNewDeviceIsBluetooth && core.callsNb > 0 && corePreferences.routeAudioToBluetoothWhenPossible) { + Log.i("$TAG It seems a bluetooth device is now available, trying to route audio to it") + AudioUtils.routeAudioBluetoothOrHearingAid() + } else if (atLeastOneNewDeviceIsHeadset && core.callsNb > 0) { + Log.i("$TAG It seems a headset or headphones device is now available, trying to route audio to it") + AudioUtils.routeAudioToHeadset() + } + }, 500) } } @@ -168,14 +192,12 @@ class CoreContext "$TAG Removed device [${device.id}][${device.productName}][${device.type}]" ) } - if (telecomManager.getCurrentlyFollowedCalls() <= 0) { - Log.i("$TAG No call found in Telecom's CallsManager, reloading sound devices in 500ms") - postOnCoreThreadDelayed({ core.reloadSoundDevices() }, 500) - } else { - Log.i( - "$TAG At least one active call in Telecom's CallsManager, let it handle the removed device(s)" - ) - } + + Log.i("$TAG Reloading sound devices in 500ms") + postOnCoreThreadDelayed({ + Log.i("$TAG Reloading sound devices") + core.reloadSoundDevices() + }, 500) } } } @@ -311,12 +333,12 @@ class CoreContext val autoAnswerDelay = corePreferences.autoAnswerDelay if (autoAnswerDelay == 0) { Log.w("$TAG Auto answering call immediately") - answerCall(call) + answerCall(call, true) } else { Log.i("$TAG Scheduling auto answering in $autoAnswerDelay milliseconds") postOnCoreThreadDelayed({ Log.w("$TAG Auto answering call") - answerCall(call) + answerCall(call, true) }, autoAnswerDelay.toLong()) } } @@ -347,10 +369,20 @@ class CoreContext ) } } + Call.State.OutgoingRinging, Call.State.OutgoingEarlyMedia -> { + if (corePreferences.routeAudioToBluetoothWhenPossible) { + Log.i("$TAG Trying to route audio to either bluetooth, hearing aid, headphones or headset if available") + AudioUtils.routeAudioToAnyConnectedAudioDeviceOtherThanEarpieceAndSpeaker(call) + } + } Call.State.Connected -> { postOnMainThread { showCallActivity() } + if (corePreferences.routeAudioToBluetoothWhenPossible) { + Log.i("$TAG Call is connected, trying to route audio to either bluetooth, hearing aid, headphones or headset if available") + AudioUtils.routeAudioToAnyConnectedAudioDeviceOtherThanEarpieceAndSpeaker(call) + } } Call.State.StreamsRunning -> { if (previousCallState == Call.State.Connected) { @@ -406,6 +438,11 @@ class CoreContext Log.i("$TAG Available audio devices list was updated") } + @WorkerThread + override fun onFirstCallStarted(core: Core) { + Log.i("$TAG First call started") + } + @WorkerThread override fun onLastCallEnded(core: Core) { Log.i("$TAG Last call ended") @@ -434,6 +471,7 @@ class CoreContext Log.e( "$TAG Authentication request using Bearer method but authorization server is null!" ) + core.abortAuthentication(null) return } @@ -451,6 +489,7 @@ class CoreContext Log.e( "$TAG Authentication requested method is Bearer but no authorization server was found in auth info!" ) + core.abortAuthentication(null) } } AuthMethod.HttpDigest -> { @@ -495,7 +534,7 @@ class CoreContext Log.i( "$TAG New account configured: [${account.params.identityAddress?.asStringUriOnly()}]" ) - if (!core.isPushNotificationAvailable || !account.params.isPushNotificationAvailable) { + if (!account.params.isPushNotificationAvailable) { // if (!corePreferences.keepServiceAlive) { // Log.w( // "$TAG Newly added account (or the whole Core) doesn't support push notifications, enabling keep-alive foreground service..." @@ -543,7 +582,7 @@ class CoreContext private var logcatEnabled: Boolean = corePreferences.printLogsInLogcat private var crashlyticsEnabled: Boolean = corePreferences.sendLogsToCrashlytics - private var crashlyticsAvailable = true + private var crashlyticsAvailable = BuildConfig.CRASHLYTICS_ENABLED private val loggingServiceListener = object : LoggingServiceListenerStub() { @WorkerThread @@ -562,12 +601,30 @@ class CoreContext else -> android.util.Log.d(domain, message) } } - if (crashlyticsEnabled) { + if (crashlyticsAvailable && crashlyticsEnabled) { FirebaseCrashlytics.getInstance().log("[$domain] [${level.name}] $message") } } } + private val proximitySensorListener = object : SensorEventListener { + override fun onAccuracyChanged( + sensor: Sensor?, + accuracy: Int + ) { + } + + override fun onSensorChanged(event: SensorEvent?) { + if (event?.sensor?.type == Sensor.TYPE_PROXIMITY) { + if (event.values[0] == 0f) { + Log.i("$TAG Proximity sensor triggered, screen will turn off") + } else { + Log.i("$TAG Proximity sensor released, screen will turn back on") + } + } + } + } + init { (context as Application).registerActivityLifecycleCallbacks(activityMonitor) } @@ -649,6 +706,21 @@ class CoreContext Log.i("$TAG Core started, updating configuration if required") core.videoCodecPriorityPolicy = CodecPriorityPolicy.Auto + // Set in the Core the list of directories from which it is allowed to delete a file related to a chat message + val paths = arrayListOf() + if (Environment.getExternalStorageState() == Environment.MEDIA_MOUNTED) { + paths.add(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)?.absolutePath.orEmpty()) + paths.add(context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)?.absolutePath.orEmpty()) + paths.add(context.getExternalFilesDir(Compatibility.getRecordingsDirectory())?.absolutePath.orEmpty()) + } + paths.add(context.filesDir?.absolutePath.orEmpty()) + val pathsArray = arrayOfNulls(paths.size) + paths.toArray(pathsArray) + core.setChatMessageFilesDirectories(pathsArray) + for (path in paths) { + Log.i("$TAG Adding path [$path] to list of directories from which Core is allowed to delete files from") + } + val currentVersion = BuildConfig.VERSION_CODE val oldVersion = corePreferences.linphoneConfigurationVersion Log.w("$TAG Current configuration version is [$oldVersion]") @@ -662,6 +734,34 @@ class CoreContext disablePushNotificationsFromThirdPartySipAccounts() } else if (oldVersion < 600009) { // 6.0.9 removePortFromSipIdentity() + } else if (oldVersion < 602000) { // 6.2.0 + if (!core.isChatMessageFilesDeletionEnabled) { + core.isChatMessageFilesDeletionEnabled = true + Log.i("$TAG Core is allowed to automatically delete files from previously logged directories when a chat message is deleted") + } + + val rlsUri = core.config.getString("sip", "rls_uri", "").orEmpty() + if (rlsUri.isNotEmpty()) { + var accountOnDefaultDomainFound = false + for (account in core.accountList) { + if (account.params.identityAddress?.domain == corePreferences.defaultDomain) { + accountOnDefaultDomainFound = true + break + } + } + if (!accountOnDefaultDomainFound) { + Log.w("$TAG Removing rls_uri from [sip] section & all friend lists to prevent sending SUBSCRIBE") + core.config.setString("sip", "rls_uri", "") + for (friendList in core.friendsLists) { + if (friendList.rlsAddress != null) { + Log.i("$TAG Removing RLS URI from friend list [${friendList.displayName}]") + friendList.rlsAddress = null + } + } + } else { + Log.i("$TAG Keeping RLS URI as an account on the default domain has been found") + } + } } if (core.logCollectionUploadServerUrl.isNullOrEmpty()) { @@ -699,12 +799,21 @@ class CoreContext PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, "${context.packageName};proximity_sensor" ) + val sensorManager = context.getSystemService(SENSOR_SERVICE) as SensorManager + val proximity = sensorManager.getDefaultSensor(Sensor.TYPE_PROXIMITY) + val added = sensorManager.registerListener(proximitySensorListener, proximity, SensorManager.SENSOR_DELAY_NORMAL) + if (!added) { + Log.e("$TAG Failed to add proximity sensor listener!") + } } } @WorkerThread private fun onCoreStopped() { Log.w("$TAG Core is being shut down, notifying managers so they can remove their listeners and do some cleanup if needed") + val sensorManager = context.getSystemService(SENSOR_SERVICE) as SensorManager + sensorManager.unregisterListener(proximitySensorListener) + contactsManager.onCoreStopped(core) telecomManager.onCoreStopped(core) notificationsManager.onCoreStopped(core) @@ -839,6 +948,16 @@ class CoreContext } } + @AnyThread + fun abortBearerAuthIfAny() { + coreContext.postOnCoreThread { core -> + if (bearerAuthInfoPendingPasswordUpdate != null) { + Log.e("$TAG Aborting bearer authentication") + core.abortAuthentication(bearerAuthInfoPendingPasswordUpdate) + } + } + } + @WorkerThread fun isAddressMyself(address: Address): Boolean { val found = core.accountList.find { @@ -856,23 +975,25 @@ class CoreContext fun startAudioCall( address: Address, forceZRTP: Boolean = false, - localAddress: Address? = null + localAddress: Address? = null, + skipNetworkReachabilityTest: Boolean = false ) { val params = core.createCallParams(null) params?.isVideoEnabled = false - startCall(address, params, forceZRTP, localAddress) + startCall(address, params, forceZRTP, localAddress, skipNetworkReachabilityTest) } @WorkerThread fun startVideoCall( address: Address, forceZRTP: Boolean = false, - localAddress: Address? = null + localAddress: Address? = null, + skipNetworkReachabilityTest: Boolean = false ) { val params = core.createCallParams(null) params?.isVideoEnabled = true params?.videoDirection = MediaDirection.SendRecv - startCall(address, params, forceZRTP, localAddress) + startCall(address, params, forceZRTP, localAddress, skipNetworkReachabilityTest) } @WorkerThread @@ -880,9 +1001,10 @@ class CoreContext address: Address, callParams: CallParams? = null, forceZRTP: Boolean = false, - localAddress: Address? = null + localAddress: Address? = null, + skipNetworkReachabilityTest: Boolean = false ) { - if (!core.isNetworkReachable) { + if (!skipNetworkReachabilityTest && !core.isNetworkReachable) { Log.e("$TAG Network unreachable, abort outgoing call") return } @@ -980,7 +1102,7 @@ class CoreContext } @WorkerThread - fun answerCall(call: Call) { + fun answerCall(call: Call, autoAnswer: Boolean = false) { Log.i( "$TAG Answering call with remote address [${call.remoteAddress.asStringUriOnly()}] and to address [${call.toAddress.asStringUriOnly()}]" ) @@ -1006,6 +1128,12 @@ class CoreContext Log.i( "$TAG Enabling video on call params to prevent audio-only layout when answering" ) + } else if (autoAnswer) { + val videoBothWays = corePreferences.autoAnswerVideoCallsWithVideoDirectionSendReceive + if (videoBothWays) { + Log.i("$TAG Call is being auto-answered, requesting video in both ways according to user setting") + params.videoDirection = MediaDirection.SendRecv + } } call.acceptWithParams(params) @@ -1013,15 +1141,21 @@ class CoreContext @WorkerThread fun terminateCall(call: Call) { - if (call.dir == Call.Dir.Incoming && LinphoneUtils.isCallIncoming(call.state)) { - val reason = if (call.core.callsNb > 1) Reason.Busy else Reason.Declined - Log.i( - "$TAG Declining call [${call.remoteAddress.asStringUriOnly()}] with reason [$reason]" - ) - call.decline(reason) + val conference = call.conference + if (conference != null) { + Log.i("$TAG Terminating conference [${call.remoteAddress.asStringUriOnly()}]") + conference.terminate() } else { - Log.i("$TAG Terminating call [${call.remoteAddress.asStringUriOnly()}]") - call.terminate() + if (call.dir == Call.Dir.Incoming && LinphoneUtils.isCallIncoming(call.state)) { + val reason = if (call.core.callsNb > 1) Reason.Busy else Reason.Declined + Log.i( + "$TAG Declining call [${call.remoteAddress.asStringUriOnly()}] with reason [$reason]" + ) + call.decline(reason) + } else { + Log.i("$TAG Terminating call [${call.remoteAddress.asStringUriOnly()}]") + call.terminate() + } } } @@ -1033,7 +1167,17 @@ class CoreContext intent.addFlags( Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT ) - context.startActivity(intent) + val options = Compatibility.getPendingIntentActivityOptions(true) + val pendingIntent = PendingIntent.getActivity( + context, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + options.toBundle() + ) + + val senderOptions = Compatibility.getPendingIntentActivityOptions(false) + Compatibility.sendPendingIntent(pendingIntent, senderOptions.toBundle()) } @WorkerThread @@ -1068,23 +1212,6 @@ class CoreContext keepAliveServiceStarted = false } - @WorkerThread - fun playDtmf(character: Char, duration: Int = 200, ignoreSystemPolicy: Boolean = false) { - try { - if (ignoreSystemPolicy || Settings.System.getInt( - context.contentResolver, - Settings.System.DTMF_TONE_WHEN_DIALING - ) != 0 - ) { - core.playDtmf(character, duration) - } else { - Log.w("$TAG Numpad DTMF tones are disabled in system settings, not playing them") - } - } catch (snfe: SettingNotFoundException) { - Log.e("$TAG DTMF_TONE_WHEN_DIALING system setting not found: $snfe") - } - } - @WorkerThread fun computeUserAgent() { val savedDeviceName = corePreferences.deviceName @@ -1113,8 +1240,6 @@ class CoreContext core.setUserAgent(userAgent, sdkUserAgent) } - // Migration between versions related - @WorkerThread private fun removePortFromSipIdentity() { for (account in core.accountList) { @@ -1222,6 +1347,11 @@ class CoreContext @UiThread fun enableProximitySensor(enable: Boolean) { + if (enable && !corePreferences.useProximitySensor) { + Log.w("$TAG App tried to enable proximity sensor but it's been disabled in settings, doing nothing") + return + } + if (::proximityWakeLock.isInitialized) { if (enable && !proximityWakeLock.isHeld) { Log.i("$TAG Acquiring proximity sensor wake lock for 2 hours") @@ -1234,24 +1364,28 @@ class CoreContext } fun setBackCamera(): Boolean { - for (camera in core.videoDevicesList) { + val list = core.videoDevicesList + for (camera in list) { if (camera.contains("Back")) { - Log.i("TAG Found back facing camera [$camera], using it") + Log.i("$TAG Found back facing camera [$camera], using it") coreContext.core.videoDevice = camera return true } } + Log.i("$TAG Back camera wasn't found in [${list.size}] detected video devices") return false } fun setFrontCamera(): Boolean { - for (camera in core.videoDevicesList) { + val list = core.videoDevicesList + for (camera in list) { if (camera.contains("Front")) { Log.i("$TAG Found front facing camera [$camera], using it") coreContext.core.videoDevice = camera return true } } + Log.i("$TAG Front camera wasn't found in [${list.size}] detected video devices") return false } } diff --git a/app/src/main/java/org/linphone/core/CorePreferences.kt b/app/src/main/java/org/linphone/core/CorePreferences.kt index f6eadf299c..646ea689f9 100644 --- a/app/src/main/java/org/linphone/core/CorePreferences.kt +++ b/app/src/main/java/org/linphone/core/CorePreferences.kt @@ -40,84 +40,84 @@ class CorePreferences private var _config: Config? = null - @get:WorkerThread @set:WorkerThread + @get:AnyThread @set:WorkerThread var config: Config get() = _config ?: coreContext.core.config set(value) { _config = value } - @get:WorkerThread @set:WorkerThread + @get:AnyThread @set:WorkerThread var printLogsInLogcat: Boolean - get() = config.getBool("app", "debug", org.linphone.BuildConfig.DEBUG) + get() = config.getBool("app", "debug", BuildConfig.DEBUG) set(value) { config.setBool("app", "debug", value) } - @get:WorkerThread @set:WorkerThread + @get:AnyThread @set:WorkerThread var sendLogsToCrashlytics: Boolean get() = config.getBool("app", "send_logs_to_crashlytics", BuildConfig.CRASHLYTICS_ENABLED) set(value) { config.setBool("app", "send_logs_to_crashlytics", value) } - @get:WorkerThread @set:WorkerThread + @get:AnyThread @set:WorkerThread var firstLaunch: Boolean get() = config.getBool("app", "first_6.0_launch", true) set(value) { config.setBool("app", "first_6.0_launch", value) } - @get:WorkerThread @set:WorkerThread + @get:AnyThread @set:WorkerThread var linphoneConfigurationVersion: Int get() = config.getInt("app", "config_version", 52005) set(value) { config.setInt("app", "config_version", value) } - @get:WorkerThread @set:WorkerThread + @get:AnyThread @set:WorkerThread var autoStart: Boolean get() = config.getBool("app", "auto_start", true) set(value) { config.setBool("app", "auto_start", value) } - @get:WorkerThread @set:WorkerThread + @get:AnyThread @set:WorkerThread var checkForUpdateServerUrl: String get() = config.getString("misc", "version_check_url_root", "").orEmpty() set(value) { config.setString("misc", "version_check_url_root", value) } - @get:WorkerThread @set:WorkerThread + @get:AnyThread @set:WorkerThread var conditionsAndPrivacyPolicyAccepted: Boolean get() = config.getBool("app", "read_and_agree_terms_and_privacy", false) set(value) { config.setBool("app", "read_and_agree_terms_and_privacy", value) } - @get:WorkerThread @set:WorkerThread + @get:AnyThread @set:WorkerThread var publishPresence: Boolean get() = config.getBool("app", "publish_presence", true) set(value) { config.setBool("app", "publish_presence", value) } - @get:WorkerThread @set:WorkerThread + @get:AnyThread @set:WorkerThread var keepServiceAlive: Boolean get() = config.getBool("app", "keep_service_alive", false) set(value) { config.setBool("app", "keep_service_alive", value) } - @get:WorkerThread @set:WorkerThread + @get:AnyThread @set:WorkerThread var deviceName: String get() = config.getString("app", "device", "").orEmpty().trim() set(value) { config.setString("app", "device", value.trim()) } - @get:WorkerThread @set:WorkerThread + @get:AnyThread @set:WorkerThread var showDeveloperSettings: Boolean get() = config.getBool("ui", "show_developer_settings", false) set(value) { @@ -127,71 +127,100 @@ class CorePreferences // Call settings // This won't be done if bluetooth or wired headset is used - @get:WorkerThread @set:WorkerThread + @get:AnyThread @set:WorkerThread + var routeAudioToBluetoothWhenPossible: Boolean + get() = config.getBool("app", "route_audio_to_bluetooth_when_possible", false) + set(value) { + config.setBool("app", "route_audio_to_bluetooth_when_possible", value) + } + + @get:AnyThread @set:WorkerThread var routeAudioToSpeakerWhenVideoIsEnabled: Boolean get() = config.getBool("app", "route_audio_to_speaker_when_video_enabled", false) set(value) { config.setBool("app", "route_audio_to_speaker_when_video_enabled", value) } - @get:WorkerThread @set:WorkerThread + @get:AnyThread @set:WorkerThread var callRecordingUseSmffFormat: Boolean get() = config.getBool("app", "use_smff_for_call_recording", false) set(value) { config.setBool("app", "use_smff_for_call_recording", value) } - @get:WorkerThread @set:WorkerThread + @get:AnyThread @set:WorkerThread var automaticallyStartCallRecording: Boolean get() = config.getBool("app", "auto_start_call_record", false) set(value) { config.setBool("app", "auto_start_call_record", value) } - @get:WorkerThread @set:WorkerThread + @get:AnyThread @set:WorkerThread var showDialogWhenCallingDeviceUuidDirectly: Boolean get() = config.getBool("app", "show_confirmation_dialog_zrtp_trust_call", true) set(value) { config.setBool("app", "show_confirmation_dialog_zrtp_trust_call", value) } - @get:WorkerThread @set:WorkerThread + @get:AnyThread @set:WorkerThread var acceptEarlyMedia: Boolean get() = config.getBool("sip", "incoming_calls_early_media", false) set(value) { config.setBool("sip", "incoming_calls_early_media", value) } - @get:WorkerThread @set:WorkerThread + @get:AnyThread @set:WorkerThread var allowOutgoingEarlyMedia: Boolean get() = config.getBool("misc", "real_early_media", false) set(value) { config.setBool("misc", "real_early_media", value) } - @get:WorkerThread @set:WorkerThread + @get:AnyThread @set:WorkerThread var autoAnswerEnabled: Boolean get() = config.getBool("app", "auto_answer", false) set(value) { config.setBool("app", "auto_answer", value) } - @get:WorkerThread @set:WorkerThread + @get:AnyThread @set:WorkerThread var autoAnswerDelay: Int get() = config.getInt("app", "auto_answer_delay", 0) set(value) { config.setInt("app", "auto_answer_delay", value) } + @get:AnyThread @set:WorkerThread + var autoAnswerVideoCallsWithVideoDirectionSendReceive: Boolean + get() = config.getBool("app", "auto_answer_video_send_receive", false) + set(value) { + config.setBool("app", "auto_answer_video_send_receive", value) + } + + @get:AnyThread @set:WorkerThread + var showAdvancedCallStats: Boolean + get() = config.getBool("ui", "show_advanced_call_stats", false) + set(value) { + config.setBool("ui", "show_advanced_call_stats", value) + } + + @get:AnyThread @set:WorkerThread + var useProximitySensor: Boolean + get() = config.getBool("ui", "use_proximity_sensor", true) + set(value) { + config.setBool("ui", "use_proximity_sensor", value) + } + // Conversation related - @get:WorkerThread @set:WorkerThread + @get:AnyThread @set:WorkerThread var markConversationAsReadWhenDismissingMessageNotification: Boolean get() = config.getBool("app", "mark_as_read_notif_dismissal", false) set(value) { config.setBool("app", "mark_as_read_notif_dismissal", value) } + @get:AnyThread @set:WorkerThread var makePublicMediaFilesDownloaded: Boolean // Keep old name for backward compatibility get() = config.getBool("app", "make_downloaded_images_public_in_gallery", false) @@ -199,32 +228,60 @@ class CorePreferences config.setBool("app", "make_downloaded_images_public_in_gallery", value) } + @get:AnyThread @set:WorkerThread + var showChatMessageContentInNotification: Boolean + get() = config.getBool("ui", "display_notification_content", true) + set(value) { + config.setBool("ui", "display_notification_content", value) + } + // Conference related - @get:WorkerThread @set:WorkerThread + @get:AnyThread @set:WorkerThread var createEndToEndEncryptedMeetingsAndGroupCalls: Boolean get() = config.getBool("app", "create_e2e_encrypted_conferences", false) set(value) { config.setBool("app", "create_e2e_encrypted_conferences", value) } + @get:AnyThread @set:WorkerThread + var showPastMeetings: Boolean + get() = config.getBool("ui", "show_past_meetings", false) + set(value) { + config.setBool("ui", "show_past_meetings", value) + } + // Contacts related - @get:WorkerThread @set:WorkerThread + @get:AnyThread @set:WorkerThread + var sortContactsByFirstName: Boolean + get() = config.getBool("ui", "sort_contacts_by_first_name", true) // If disabled, last name will be used + set(value) { + config.setBool("ui", "sort_contacts_by_first_name", value) + } + + @get:AnyThread + var hideContactsWithoutPhoneNumberOrSipAddress: Boolean + get() = config.getBool("ui", "hide_contacts_without_phone_number_or_sip_address", false) + set(value) { + config.setBool("ui", "hide_contacts_without_phone_number_or_sip_address", value) + } + + @get:AnyThread @set:WorkerThread var contactsFilter: String get() = config.getString("ui", "contacts_filter", "")!! // Default value must be empty! set(value) { config.setString("ui", "contacts_filter", value) } - @get:WorkerThread @set:WorkerThread + @get:AnyThread @set:WorkerThread var showFavoriteContacts: Boolean get() = config.getBool("ui", "show_favorites_contacts", true) set(value) { config.setBool("ui", "show_favorites_contacts", value) } - @get:WorkerThread @set:WorkerThread + @get:AnyThread @set:WorkerThread var friendListInWhichStoreNewlyCreatedFriends: String get() = config.getString( "app", @@ -235,9 +292,23 @@ class CorePreferences config.setString("app", "friend_list_to_store_newly_created_contacts", value) } + @get:AnyThread @set:WorkerThread + var editNativeContactsInLinphone: Boolean + get() = config.getBool("ui", "edit_native_contact_in_linphone", false) + set(value) { + config.setBool("ui", "edit_native_contact_in_linphone", value) + } + + @get:AnyThread @set:WorkerThread + var disableAddContact: Boolean + get() = config.getBool("ui", "disable_add_contact", false) + set(value) { + config.setBool("ui", "disable_add_contact", value) + } + // Voice recordings related - @get:WorkerThread @set:WorkerThread + @get:AnyThread @set:WorkerThread var voiceRecordingMaxDuration: Int get() = config.getInt("app", "voice_recording_max_duration", 600000) // in ms set(value) = config.setInt("app", "voice_recording_max_duration", value) @@ -245,7 +316,7 @@ class CorePreferences // User interface related // -1 means auto, 0 no, 1 yes - @get:WorkerThread @set:WorkerThread + @get:AnyThread @set:WorkerThread var darkMode: Int get() { if (!darkModeAllowed) return 0 @@ -256,21 +327,21 @@ class CorePreferences } // Allows to make screenshots - @get:WorkerThread @set:WorkerThread + @get:AnyThread @set:WorkerThread var enableSecureMode: Boolean get() = config.getBool("ui", "enable_secure_mode", false) set(value) { config.setBool("ui", "enable_secure_mode", value) } - @get:WorkerThread @set:WorkerThread + @get:AnyThread @set:WorkerThread var automaticallyShowDialpad: Boolean get() = config.getBool("ui", "automatically_show_dialpad", false) set(value) { config.setBool("ui", "automatically_show_dialpad", value) } - @get:WorkerThread @set:WorkerThread + @get:AnyThread @set:WorkerThread var themeMainColor: String get() = config.getString("ui", "theme_main_color", "orange")!! set(value) { @@ -279,94 +350,113 @@ class CorePreferences // Customization options - @get:WorkerThread - val defaultDomain: String - get() = config.getString("app", "default_domain", "sip.linphone.org")!! + @get:AnyThread @set:WorkerThread + var showMicrophoneAndSpeakerVuMeters: Boolean + get() = config.getBool("ui", "show_mic_speaker_vu_meter", false) + set(value) { + config.setBool("ui", "show_mic_speaker_vu_meter", value) + } - val pushNotificationCompatibleDomains: Array + @get:AnyThread @set:WorkerThread + var pushNotificationCompatibleDomains: Array get() = config.getStringList("app", "push_notification_domains", arrayOf("sip.linphone.org")) + set(value) { + config.setStringList("app", "push_notification_domains", value) + } - @get:WorkerThread + @get:AnyThread + val defaultDomain: String + get() = config.getString("app", "default_domain", "sip.linphone.org")!! + + @get:AnyThread val darkModeAllowed: Boolean get() = config.getBool("ui", "dark_mode_allowed", true) - @get:WorkerThread + @get:AnyThread val changeMainColorAllowed: Boolean get() = config.getBool("ui", "change_main_color_allowed", false) - @get:WorkerThread + @get:AnyThread val onlyDisplaySipUriUsername: Boolean get() = config.getBool("ui", "only_display_sip_uri_username", true) - @get:WorkerThread + @get:AnyThread + val suppressSipAddresses: Boolean + get() = config.getBool("ui", "suppress_sip_addresses", false) + + @get:AnyThread + val hideSipAddresses: Boolean + get() = suppressSipAddresses || config.getBool("ui", "hide_sip_addresses", true) + + @get:AnyThread val disableChat: Boolean get() = config.getBool("ui", "disable_chat_feature", false) - @get:WorkerThread + @get:AnyThread val disableMeetings: Boolean get() = config.getBool("ui", "disable_meetings_feature", false) - @get:WorkerThread + @get:AnyThread val disableBroadcasts: Boolean get() = config.getBool("ui", "disable_broadcast_feature", true) // TODO FIXME: not implemented yet - @get:WorkerThread + @get:AnyThread val disableCallRecordings: Boolean get() = config.getBool("ui", "disable_call_recordings_feature", false) - @get:WorkerThread + @get:AnyThread val maxAccountsCount: Int get() = config.getInt("ui", "max_account", 0) // 0 means no max - @get:WorkerThread + @get:AnyThread val hidePhoneNumbers: Boolean get() = config.getBool("ui", "hide_phone_numbers", false) - @get:WorkerThread + @get:AnyThread val hideSettings: Boolean get() = config.getBool("ui", "hide_settings", false) - @get:WorkerThread + @get:AnyThread val hideAccountSettings: Boolean get() = config.getBool("ui", "hide_account_settings", false) - @get:WorkerThread + @get:AnyThread val hideAdvancedSettings: Boolean get() = config.getBool("ui", "hide_advanced_settings", false) - @get:WorkerThread + @get:AnyThread val hideAssistantCreateAccount: Boolean get() = config.getBool("ui", "assistant_hide_create_account", false) - @get:WorkerThread + @get:AnyThread val hideAssistantScanQrCode: Boolean get() = config.getBool("ui", "assistant_disable_qr_code", false) - @get:WorkerThread + @get:AnyThread val hideAssistantThirdPartySipAccount: Boolean get() = config.getBool("ui", "assistant_hide_third_party_account", false) - @get:WorkerThread + @get:AnyThread val magicSearchResultsLimit: Int get() = config.getInt("ui", "max_number_of_magic_search_results", 300) - @get:WorkerThread + @get:AnyThread val singleSignOnClientId: String get() = config.getString("app", "oidc_client_id", "linphone")!! - @get:WorkerThread + @get:AnyThread val useUsernameAsSingleSignOnLoginHint: Boolean get() = config.getBool("ui", "use_username_as_sso_login_hint", false) - @get:WorkerThread + @get:AnyThread val thirdPartySipAccountDefaultTransport: String get() = config.getString("ui", "assistant_third_party_sip_account_transport", "tls")!! - @get:WorkerThread + @get:AnyThread val thirdPartySipAccountDefaultDomain: String get() = config.getString("ui", "assistant_third_party_sip_account_domain", "")!! - @get:WorkerThread + @get:AnyThread val assistantDirectlyGoToThirdPartySipAccountLogin: Boolean get() = config.getBool( "ui", @@ -374,11 +464,11 @@ class CorePreferences false ) - @get:WorkerThread + @get:AnyThread val fetchContactsFromDefaultDirectory: Boolean get() = config.getBool("app", "fetch_contacts_from_default_directory", true) - @get:WorkerThread + @get:AnyThread val showLettersOnDialpad: Boolean get() = config.getBool("ui", "show_letters_on_dialpad", true) diff --git a/app/src/main/java/org/linphone/notifications/NotificationBroadcastReceiver.kt b/app/src/main/java/org/linphone/notifications/NotificationBroadcastReceiver.kt index ef9ff05660..7b98ba8385 100644 --- a/app/src/main/java/org/linphone/notifications/NotificationBroadcastReceiver.kt +++ b/app/src/main/java/org/linphone/notifications/NotificationBroadcastReceiver.kt @@ -26,8 +26,10 @@ import android.content.Context import android.content.Intent import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.core.Address +import org.linphone.core.AudioDevice import org.linphone.core.ConferenceParams import org.linphone.core.tools.Log +import org.linphone.utils.AudioUtils class NotificationBroadcastReceiver : BroadcastReceiver() { companion object { @@ -36,47 +38,69 @@ class NotificationBroadcastReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val notificationId = intent.getIntExtra(NotificationsManager.INTENT_NOTIF_ID, 0) - Log.i( - "$TAG Got notification broadcast for ID [$notificationId]" - ) + val action = intent.action + Log.i("$TAG Got notification broadcast for ID [$notificationId] with action [$action]") // Wait for coreContext to be ready to handle intent while (!coreContext.isReady()) { Thread.sleep(50) } - if (intent.action == NotificationsManager.INTENT_ANSWER_CALL_NOTIF_ACTION || intent.action == NotificationsManager.INTENT_HANGUP_CALL_NOTIF_ACTION) { - handleCallIntent(intent, notificationId) - } else if (intent.action == NotificationsManager.INTENT_REPLY_MESSAGE_NOTIF_ACTION || intent.action == NotificationsManager.INTENT_MARK_MESSAGE_AS_READ_NOTIF_ACTION) { - handleChatIntent(context, intent, notificationId) + if ( + action == NotificationsManager.INTENT_ANSWER_CALL_NOTIF_ACTION || + action == NotificationsManager.INTENT_HANGUP_CALL_NOTIF_ACTION || + action == NotificationsManager.INTENT_TOGGLE_SPEAKER_CALL_NOTIF_ACTION + ) { + handleCallIntent(intent, notificationId, action) + } else if ( + action == NotificationsManager.INTENT_REPLY_MESSAGE_NOTIF_ACTION || + action == NotificationsManager.INTENT_MARK_MESSAGE_AS_READ_NOTIF_ACTION + ) { + handleChatIntent(context, intent, notificationId, action) } } - private fun handleCallIntent(intent: Intent, notificationId: Int) { - val remoteSipAddress = intent.getStringExtra(NotificationsManager.INTENT_REMOTE_ADDRESS) - if (remoteSipAddress == null) { + private fun handleCallIntent(intent: Intent, notificationId: Int, action: String) { + val remoteSipUri = intent.getStringExtra(NotificationsManager.INTENT_REMOTE_SIP_URI) + if (remoteSipUri == null) { Log.e("$TAG Remote SIP address is null for call notification ID [$notificationId]") return } coreContext.postOnCoreThread { core -> val call = core.calls.find { - it.remoteAddress.asStringUriOnly() == remoteSipAddress + it.remoteAddress.asStringUriOnly() == remoteSipUri } if (call == null) { - Log.e("$TAG Couldn't find call from remote address [$remoteSipAddress]") + Log.e("$TAG Couldn't find call from remote address [$remoteSipUri]") } else { - if (intent.action == NotificationsManager.INTENT_ANSWER_CALL_NOTIF_ACTION) { - coreContext.answerCall(call) - } else { - coreContext.terminateCall(call) + when (action) { + NotificationsManager.INTENT_ANSWER_CALL_NOTIF_ACTION -> { + Log.i("$TAG Answering call with remote address [$remoteSipUri]") + coreContext.answerCall(call) + } + NotificationsManager.INTENT_HANGUP_CALL_NOTIF_ACTION -> { + Log.i("$TAG Declining/terminating call with remote address [$remoteSipUri]") + coreContext.terminateCall(call) + } + NotificationsManager.INTENT_TOGGLE_SPEAKER_CALL_NOTIF_ACTION -> { + val audioDevice = call.outputAudioDevice + val isUsingSpeaker = audioDevice?.type == AudioDevice.Type.Speaker + if (isUsingSpeaker) { + Log.i("$TAG Routing audio to earpiece for call [$remoteSipUri]") + AudioUtils.routeAudioToEarpiece(call) + } else { + Log.i("$TAG Routing audio to speaker for call [$remoteSipUri]") + AudioUtils.routeAudioToSpeaker(call) + } + } } } } } - private fun handleChatIntent(context: Context, intent: Intent, notificationId: Int) { - val remoteSipAddress = intent.getStringExtra(NotificationsManager.INTENT_REMOTE_ADDRESS) + private fun handleChatIntent(context: Context, intent: Intent, notificationId: Int, action: String) { + val remoteSipAddress = intent.getStringExtra(NotificationsManager.INTENT_REMOTE_SIP_URI) if (remoteSipAddress == null) { Log.e("$TAG Remote SIP address is null for notification ID [$notificationId]") return @@ -88,7 +112,7 @@ class NotificationBroadcastReceiver : BroadcastReceiver() { } val reply = getMessageText(intent)?.toString() - if (intent.action == NotificationsManager.INTENT_REPLY_MESSAGE_NOTIF_ACTION) { + if (action == NotificationsManager.INTENT_REPLY_MESSAGE_NOTIF_ACTION) { if (reply == null) { Log.e("$TAG Couldn't get reply text") return @@ -128,13 +152,13 @@ class NotificationBroadcastReceiver : BroadcastReceiver() { return@postOnCoreThread } - if (intent.action == NotificationsManager.INTENT_REPLY_MESSAGE_NOTIF_ACTION) { + if (action == NotificationsManager.INTENT_REPLY_MESSAGE_NOTIF_ACTION) { val msg = room.createMessageFromUtf8(reply) msg.userData = notificationId msg.addListener(coreContext.notificationsManager.chatMessageListener) msg.send() Log.i("$TAG Reply sent for notif id [$notificationId]") - } else if (intent.action == NotificationsManager.INTENT_MARK_MESSAGE_AS_READ_NOTIF_ACTION) { + } else if (action == NotificationsManager.INTENT_MARK_MESSAGE_AS_READ_NOTIF_ACTION) { Log.i("$TAG Marking chat room from notification id [$notificationId] as read") room.markAsRead() if (!coreContext.notificationsManager.dismissChatNotification(room)) { diff --git a/app/src/main/java/org/linphone/notifications/NotificationsManager.kt b/app/src/main/java/org/linphone/notifications/NotificationsManager.kt index 754037854b..a2020912c4 100644 --- a/app/src/main/java/org/linphone/notifications/NotificationsManager.kt +++ b/app/src/main/java/org/linphone/notifications/NotificationsManager.kt @@ -53,7 +53,9 @@ import org.linphone.contacts.AvatarGenerator import org.linphone.contacts.ContactsManager.ContactsListener import org.linphone.contacts.getAvatarBitmap import org.linphone.contacts.getPerson +import org.linphone.core.Account import org.linphone.core.Address +import org.linphone.core.AudioDevice import org.linphone.core.Call import org.linphone.core.ChatMessage import org.linphone.core.ChatMessageListener @@ -68,6 +70,7 @@ import org.linphone.core.CoreListenerStub import org.linphone.core.Factory import org.linphone.core.Friend import org.linphone.core.MediaDirection +import org.linphone.core.RegistrationState import org.linphone.core.tools.Log import org.linphone.ui.call.CallActivity import org.linphone.ui.main.MainActivity @@ -86,21 +89,33 @@ class NotificationsManager const val INTENT_HANGUP_CALL_NOTIF_ACTION = "org.linphone.HANGUP_CALL_ACTION" const val INTENT_ANSWER_CALL_NOTIF_ACTION = "org.linphone.ANSWER_CALL_ACTION" + const val INTENT_TOGGLE_SPEAKER_CALL_NOTIF_ACTION = "org.linphone.TOGGLE_SPEAKER_CALL_ACTION" const val INTENT_REPLY_MESSAGE_NOTIF_ACTION = "org.linphone.REPLY_ACTION" const val INTENT_MARK_MESSAGE_AS_READ_NOTIF_ACTION = "org.linphone.MARK_AS_READ_ACTION" + const val INTENT_FOREGROUND_SERVICE_NOTIF_DISMISSED_ACTION = "org.linphone.INTENT_FOREGROUND_SERVICE_NOTIF_DISMISSED_ACTION" + + const val INTENT_ANSWER_CALL_NOTIF_CODE = 2 + const val INTENT_HANGUP_CALL_NOTIF_CODE = 3 + const val INTENT_TOGGLE_SPEAKER_CALL_NOTIF_CODE = 4 + const val INTENT_NOTIF_ID = "NOTIFICATION_ID" const val KEY_TEXT_REPLY = "key_text_reply" const val INTENT_LOCAL_IDENTITY = "LOCAL_IDENTITY" - const val INTENT_REMOTE_ADDRESS = "REMOTE_ADDRESS" + const val INTENT_REMOTE_SIP_URI = "REMOTE_ADDRESS" const val CHAT_TAG = "Chat" + + private const val ACCOUNT_ERROR_TAG = "Account Error" + private const val IN_CALL_ERROR_TAG = "Call Error" + private const val MISSED_CALL_TAG = "Missed call" - const val CHAT_NOTIFICATIONS_GROUP = "CHAT_NOTIF_GROUP" + private const val CHAT_NOTIFICATIONS_GROUP = "CHAT_NOTIF_GROUP" - private const val INCOMING_CALL_ID = 1 private const val DUMMY_NOTIF_ID = 3 private const val KEEP_ALIVE_FOR_THIRD_PARTY_ACCOUNTS_ID = 5 + private const val ACCOUNT_REGISTRATION_ERROR_ID = 7 + private const val IN_CALL_FOREGROUND_SERVICE_ERROR_ID = 8 private const val MISSED_CALL_ID = 10 } @@ -122,6 +137,7 @@ class NotificationsManager private val callNotificationsMap: HashMap = HashMap() private val chatNotificationsMap: HashMap = HashMap() private val previousChatNotifications: ArrayList = arrayListOf() + private val accountsErrorNotificationsMap: HashMap = HashMap() private val notificationsMap = HashMap() @@ -151,7 +167,12 @@ class NotificationsManager Log.i( "$TAG Found call [${addressMatch.asStringUriOnly()}] with contact in notifications, updating it" ) - updateCallNotification(notifiable, addressMatch, friend) + val call = coreContext.core.getCallByRemoteAddress2(addressMatch) + if (call == null) { + Log.e("$TAG Failed to get Call from Core using remote address [${addressMatch.asStringUriOnly()}]") + return + } + updateCallNotification(notifiable, call, friend) } } @@ -205,22 +226,17 @@ class NotificationsManager showCallNotification(call, false) } Call.State.Connected -> { - if (call.dir == Call.Dir.Incoming) { - Log.i( - "$TAG Connected call was incoming (so it was answered), removing incoming call notification" - ) - removeIncomingCallNotification() - } Log.i( - "$TAG Showing connected call notification for [${call.remoteAddress.asStringUriOnly()}]" + "$TAG Updating incoming call notification to active call for [${call.remoteAddress.asStringUriOnly()}]" ) + currentlyRingingCallRemoteAddress = null showCallNotification(call, false) } Call.State.StreamsRunning -> { val notifiable = getNotifiableForCall(call) if (notifiable.notificationId == currentInCallServiceNotificationId) { Log.i( - "$TAG Update foreground service type in case video was enabled/disabled since last time" + "$TAG Update foreground Service type in case video was enabled/disabled since last time" ) startInCallForegroundService(call) } @@ -238,7 +254,7 @@ class NotificationsManager Log.w("$TAG We are waiting for service to be started as foreground, starting it now") showCallNotification(call, false) } - removeIncomingCallNotification() + removeIncomingCallNotificationIfAny(call) } else { Log.i( "$TAG Removing terminated/declined call notification for [${remoteSipAddress.asStringUriOnly()}]" @@ -270,12 +286,29 @@ class NotificationsManager override fun onLastCallEnded(core: Core) { Log.i("$TAG Last call ended") if (inCallServiceForegroundNotificationPublished) { - Log.i("$TAG Stopping foreground service") + Log.i("$TAG Stopping foreground Service") stopInCallForegroundService() } else { Log.i("$TAG In-Call service was never started as foreground, waiting for it to be started to stop it") waitForInCallServiceForegroundToStopIt = true } + + if (notificationsMap.containsKey(IN_CALL_FOREGROUND_SERVICE_ERROR_ID)) { + Log.i("$TAG Removing in-call foreground Service error notification") + cancelNotification(IN_CALL_FOREGROUND_SERVICE_ERROR_ID, IN_CALL_ERROR_TAG) + } + } + + @WorkerThread + override fun onAudioDeviceChanged(core: Core, audioDevice: AudioDevice) { + if (core.callsNb == 0) return + + val call = core.currentCall ?: core.calls.firstOrNull() + if (call != null) { + Log.i("$TAG Audio device changed, updating call [${call.remoteAddress.asStringUriOnly()}] notification") + val notifiable = getNotifiableForCall(call) + updateCallNotification(notifiable, call, null) + } } @WorkerThread @@ -311,15 +344,14 @@ class NotificationsManager return } - if (ShortcutUtils.isShortcutToChatRoomAlreadyCreated(context, chatRoom)) { - Log.i("$TAG Conversation shortcut already exists") - showChatRoomNotification(chatRoom, messages) - } else { - Log.i( - "$TAG Ensure conversation shortcut exists for notification" - ) - ShortcutUtils.createShortcutsToChatRooms(context) - showChatRoomNotification(chatRoom, messages) + showChatRoomNotification(chatRoom, messages) + } + + @WorkerThread + override fun onMessageSent(core: Core, chatRoom: ChatRoom, message: ChatMessage) { + if (!ShortcutUtils.isShortcutToChatRoomAlreadyCreated(context, chatRoom)) { + Log.i("$TAG A message was sent in a chat room for which there is no dynamic shortcut, let's create it") + ShortcutUtils.createDynamicShortcutToChatRoom(context, chatRoom) } } @@ -429,6 +461,46 @@ class NotificationsManager ) dismissChatNotification(chatRoom) } + + @WorkerThread + override fun onAccountRegistrationStateChanged( + core: Core, + account: Account, + state: RegistrationState?, + message: String + ) { + if (state == RegistrationState.Failed) { + showAccountErrorNotification(account) + } else if (state == RegistrationState.Ok) { + // Check if a notification exists for that identity address and if yes, remove it + val identity = account.params.identityAddress?.asStringUriOnly().orEmpty() + val notificationId = accountsErrorNotificationsMap.getOrDefault(identity, -1) + if (notificationId != -1) { + accountsErrorNotificationsMap.remove(identity) + Log.i("$TAG Removing account registration error notification with ID [$notificationId] for [$identity]") + cancelNotification(notificationId, ACCOUNT_ERROR_TAG) + } + } + } + + @WorkerThread + override fun onMessageRetracted(core: Core, chatRoom: ChatRoom, message: ChatMessage) { + Log.i("$TAG A message has been retracted, checking if notification should be updated") + updateConversationNotification(chatRoom, message) + } + + @WorkerThread + override fun onMessageContentEdited(core: Core, chatRoom: ChatRoom, message: ChatMessage) { + Log.i("$TAG A message has been edited, checking if notification should be updated") + updateConversationNotification(chatRoom, message) + } + + override fun onChatRoomSubjectChanged(core: Core, chatRoom: ChatRoom) { + if (ShortcutUtils.isShortcutToChatRoomAlreadyCreated(coreContext.context, chatRoom)) { + Log.i("$TAG Updating chat room shortcut with new subject [${chatRoom.subjectUtf8}]") + ShortcutUtils.createOrUpdateChatRoomShortcut(coreContext.context, chatRoom) + } + } } val chatMessageListener: ChatMessageListener = object : ChatMessageListenerStub() { @@ -572,6 +644,7 @@ class NotificationsManager createMissedCallNotificationChannel() createActiveCallNotificationChannel() createMessageChannel() + createAccountErrorNotificationChannel() } @WorkerThread @@ -610,8 +683,31 @@ class NotificationsManager } @WorkerThread - fun removeIncomingCallNotification() { - if (currentInCallServiceNotificationId == INCOMING_CALL_ID) { + fun showInCallForegroundServiceNotificationIfNeeded() { + if (currentInCallServiceNotificationId == -1) { + Log.w("$TAG No current in-call foreground Service notification found, try to create it now") + val call = coreContext.core.currentCall ?: coreContext.core.calls.find { + LinphoneUtils.isCallActive(it.state) + } ?: coreContext.core.calls.find { + LinphoneUtils.isCallPaused(it.state) + } + if (call != null) { + Log.i("$TAG Using call [${call.remoteAddress.asStringUriOnly()}] for foreground Service notification") + showCallNotification(call, LinphoneUtils.isCallIncoming(call.state)) + } else { + Log.w("$TAG No active call found for foreground Service notification, aborting") + } + } else { + Log.i("$TAG There is already a foreground Service notification for a call, nothing to do") + } + } + + @WorkerThread + fun removeIncomingCallNotificationIfAny(call: Call) { + val notifiable = getNotifiableForCall(call) + val notificationId = notifiable.notificationId + + if (currentInCallServiceNotificationId == notificationId) { if (inCallService != null) { Log.i( "$TAG Service found, stopping it as foreground before cancelling notification" @@ -627,7 +723,7 @@ class NotificationsManager ) } - cancelNotification(INCOMING_CALL_ID) + cancelNotification(notificationId) currentlyRingingCallRemoteAddress = null } @@ -636,18 +732,21 @@ class NotificationsManager val notifiable = getNotifiableForCall(call) val callNotificationIntent = Intent(context, CallActivity::class.java) - callNotificationIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + callNotificationIntent.addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT + ) if (isIncoming) { callNotificationIntent.putExtra("IncomingCall", true) } else { callNotificationIntent.putExtra("ActiveCall", true) } - + val options = Compatibility.getPendingIntentActivityOptions(true) val pendingIntent = PendingIntent.getActivity( context, 0, callNotificationIntent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + options.toBundle() ) val notification = createCallNotification( @@ -660,14 +759,14 @@ class NotificationsManager if (isIncoming) { currentlyRingingCallRemoteAddress = call.remoteAddress if (currentInCallServiceNotificationId == -1) { - Log.i("$TAG No current in-call foreground service notification found, using this one") - showIncomingCallForegroundServiceNotification(notification) + Log.i("$TAG No current in-call foreground Service notification found, using this one") + showIncomingCallForegroundServiceNotification(notifiable.notificationId, notification) } else { - notify(INCOMING_CALL_ID, notification) + notify(notifiable.notificationId, notification) } } else { if (currentInCallServiceNotificationId == -1) { - Log.i("$TAG No current in-call foreground service notification found, using this one") + Log.i("$TAG No current in-call foreground Service notification found, using this one") showInCallForegroundServiceNotification(call, notifiable, notification) } else { notify(notifiable.notificationId, notification) @@ -723,31 +822,35 @@ class NotificationsManager } @WorkerThread - private fun showIncomingCallForegroundServiceNotification(notification: Notification) { + private fun showIncomingCallForegroundServiceNotification(notificationId: Int, notification: Notification) { Log.i("$TAG Trying to start foreground Service using incoming call notification") val service = inCallService if (service != null) { if (Compatibility.isPostNotificationsPermissionGranted(context)) { Log.i( - "$TAG Service found, starting it as foreground using notification ID [$INCOMING_CALL_ID] with type PHONE_CALL" + "$TAG Service found, starting it as foreground using notification ID [$notificationId] with type PHONE_CALL" ) - Compatibility.startServiceForeground( + val success = Compatibility.startServiceForeground( service, - INCOMING_CALL_ID, + notificationId, notification, Compatibility.FOREGROUND_SERVICE_TYPE_PHONE_CALL ) - notificationsMap[INCOMING_CALL_ID] = notification - currentInCallServiceNotificationId = INCOMING_CALL_ID - inCallServiceForegroundNotificationPublished = true - Log.i("$TAG Incoming call notification with ID [$INCOMING_CALL_ID] has been used to start service as foreground") - - if (waitForInCallServiceForegroundToStopIt) { - Log.i("$TAG We were waiting for foreground service to be started to stop it, doing it") - stopInCallForegroundService() + if (success) { + notificationsMap[notificationId] = notification + currentInCallServiceNotificationId = notificationId + inCallServiceForegroundNotificationPublished = true + Log.i("$TAG Incoming call notification with ID [$notificationId] has been used to start service as foreground") + + if (waitForInCallServiceForegroundToStopIt) { + Log.i("$TAG We were waiting for foreground Service to be started to stop it, doing it") + stopInCallForegroundService() + } + } else { + Log.e("$TAG Failed to start incoming call foreground Service!") } } else { - Log.e("$TAG POST_NOTIFICATIONS permission isn't granted, don't start foreground service!") + Log.e("$TAG POST_NOTIFICATIONS permission isn't granted, don't start foreground Service!") } } else { Log.w("$TAG Core Foreground Service hasn't started yet...") @@ -756,14 +859,15 @@ class NotificationsManager @WorkerThread private fun startInCallForegroundService(call: Call) { + val notifiable = getNotifiableForCall(call) + val notificationId = notifiable.notificationId + if (LinphoneUtils.isCallIncoming(call.state)) { - val notification = notificationsMap[INCOMING_CALL_ID] + val notification = notificationsMap[notificationId] if (notification != null) { - showIncomingCallForegroundServiceNotification(notification) + showIncomingCallForegroundServiceNotification(notificationId, notification) } else { - Log.w( - "$TAG Failed to find notification for incoming call with ID [$INCOMING_CALL_ID]" - ) + Log.w("$TAG Failed to find notification for incoming call with ID [$notificationId]") } return } @@ -779,17 +883,13 @@ class NotificationsManager val channel = notificationManager.getNotificationChannel(channelId) val importance = channel?.importance ?: NotificationManagerCompat.IMPORTANCE_NONE if (importance == NotificationManagerCompat.IMPORTANCE_NONE) { - Log.e("$TAG Calls channel has been disabled, can't start foreground service!") + Log.e("$TAG Calls channel has been disabled, can't start foreground Service!") stopInCallForegroundService() return } - val notifiable = getNotifiableForCall(call) - val notificationId = notifiable.notificationId val notification = if (notificationsMap.containsKey(notificationId)) { notificationsMap[notificationId] - } else if (notificationsMap.containsKey(INCOMING_CALL_ID)) { - notificationsMap[INCOMING_CALL_ID] } else { Log.w("$TAG Failed to find a notification for call [${call.remoteAddress.asStringUriOnly()}] in map") null @@ -827,7 +927,7 @@ class NotificationsManager ) { mask = mask or Compatibility.FOREGROUND_SERVICE_TYPE_MICROPHONE Log.i( - "$TAG RECORD_AUDIO permission has been granted, adding FOREGROUND_SERVICE_TYPE_MICROPHONE to foreground Service types mask" + "$TAG RECORD_AUDIO permission has been granted, adding MICROPHONE to foreground Service types mask" ) } val isSendingVideo = when (call.currentParams.videoDirection) { @@ -842,7 +942,7 @@ class NotificationsManager ) { mask = mask or Compatibility.FOREGROUND_SERVICE_TYPE_CAMERA Log.i( - "$TAG CAMERA permission has been granted, adding FOREGROUND_SERVICE_TYPE_CAMERA to foreground Service types mask" + "$TAG CAMERA permission has been granted, adding CAMERA to foreground Service types mask" ) } } @@ -852,23 +952,41 @@ class NotificationsManager Log.i( "$TAG Service found, starting it as foreground using notification ID [${notifiable.notificationId}] with type(s) [${foregroundServiceTypeMaskToString(mask)}]($mask)" ) - Compatibility.startServiceForeground( + val success = Compatibility.startServiceForeground( service, notifiable.notificationId, notification, mask ) - notificationsMap[notifiable.notificationId] = notification - currentInCallServiceNotificationId = notifiable.notificationId - inCallServiceForegroundNotificationPublished = true - Log.i("$TAG Call notification with ID [${notifiable.notificationId}] has been used to start service as foreground") + if (success) { + if (notificationsMap.containsKey(IN_CALL_FOREGROUND_SERVICE_ERROR_ID)) { + Log.i("$TAG Removing previous in-call foreground Service error notification") + cancelNotification(IN_CALL_FOREGROUND_SERVICE_ERROR_ID, IN_CALL_ERROR_TAG) + } - if (waitForInCallServiceForegroundToStopIt) { - Log.i("$TAG We were waiting for foreground service to be started to stop it, doing it") - stopInCallForegroundService() + notificationsMap[notifiable.notificationId] = notification + currentInCallServiceNotificationId = notifiable.notificationId + inCallServiceForegroundNotificationPublished = true + Log.i("$TAG Call notification with ID [${notifiable.notificationId}] has been used to start service as foreground") + + if (waitForInCallServiceForegroundToStopIt) { + Log.i("$TAG We were waiting for foreground Service to be started to stop it, doing it") + stopInCallForegroundService() + } + } else { + Log.e("$TAG Failed to start call foreground Service!") + // In case of incoming call the notification ID would be in the map + // so we have to remove it as notification is no longer displayed + if (notificationsMap.containsKey(notifiable.notificationId)) { + notificationsMap.remove(notifiable.notificationId) + } + if (currentInCallServiceNotificationId == notifiable.notificationId) { + currentInCallServiceNotificationId = -1 + } + showInCallForegroundServiceErrorNotification() } } else { - Log.e("$TAG POST_NOTIFICATIONS permission isn't granted, don't start foreground service!") + Log.e("$TAG POST_NOTIFICATIONS permission isn't granted, don't start foreground Service!") } } @@ -903,18 +1021,22 @@ class NotificationsManager Log.i( "$TAG Service found, starting it as foreground using dummy notification ID [$DUMMY_NOTIF_ID]" ) - Compatibility.startServiceForeground( + val success = Compatibility.startServiceForeground( service, DUMMY_NOTIF_ID, notification, Compatibility.FOREGROUND_SERVICE_TYPE_PHONE_CALL ) - notificationsMap[INCOMING_CALL_ID] = notification - currentInCallServiceNotificationId = DUMMY_NOTIF_ID - inCallServiceForegroundNotificationPublished = true - Log.i("$TAG Dummy notification with ID [$DUMMY_NOTIF_ID] has been used to start service as foreground") + if (success) { + notificationsMap[DUMMY_NOTIF_ID] = notification + currentInCallServiceNotificationId = DUMMY_NOTIF_ID + inCallServiceForegroundNotificationPublished = true + Log.i("$TAG Dummy notification with ID [$DUMMY_NOTIF_ID] has been used to start service as foreground") + } else { + Log.e("$TAG Failed to start dummy call foreground Service!") + } } else { - Log.e("$TAG POST_NOTIFICATIONS permission isn't granted, don't start foreground service!") + Log.e("$TAG POST_NOTIFICATIONS permission isn't granted, don't start foreground Service!") } } else { Log.w("$TAG Core Foreground Service hasn't started yet...") @@ -925,13 +1047,15 @@ class NotificationsManager private fun stopInCallForegroundService() { val service = inCallService if (service != null) { - Log.i( - "$TAG Stopping foreground Service (was using notification ID [$currentInCallServiceNotificationId])" - ) - service.stopForeground(STOP_FOREGROUND_REMOVE) - service.stopSelf() - inCallServiceForegroundNotificationPublished = false - waitForInCallServiceForegroundToStopIt = false + if (currentInCallServiceNotificationId != -1) { + Log.i( + "$TAG Stopping foreground Service (was using notification ID [$currentInCallServiceNotificationId])" + ) + service.stopForeground(STOP_FOREGROUND_REMOVE) + service.stopSelf() + inCallServiceForegroundNotificationPublished = false + waitForInCallServiceForegroundToStopIt = false + } } else { Log.w("$TAG Can't stop foreground Service & notif, no Service was found") } @@ -952,7 +1076,7 @@ class NotificationsManager notifiable.isGroup = false } else { notifiable.isGroup = true - notifiable.groupTitle = chatRoom.subject + notifiable.groupTitle = chatRoom.subjectUtf8 } for (message in chatRoom.unreadHistory) { @@ -961,6 +1085,9 @@ class NotificationsManager notifiable.messages.add(notifiableMessage) } } else { + // Update notification subject in case it has changed since last message + notifiable.groupTitle = chatRoom.subjectUtf8 + for (message in messages) { if (message.isRead || message.isOutgoing) continue val notifiableMessage = getNotifiableForChatMessage(message) @@ -977,14 +1104,20 @@ class NotificationsManager val notifiable = getNotifiableForConversation(chatRoom, messages) if (!chatRoom.hasCapability(ChatRoom.Capabilities.OneToOne.toInt())) { - if (chatRoom.subject != notifiable.groupTitle) { + if (chatRoom.subjectUtf8 != notifiable.groupTitle) { Log.i( - "$TAG Updating notification subject from [${notifiable.groupTitle}] to [${chatRoom.subject}]" + "$TAG Updating notification subject from [${notifiable.groupTitle}] to [${chatRoom.subjectUtf8}]" ) - notifiable.groupTitle = chatRoom.subject + notifiable.groupTitle = chatRoom.subjectUtf8 } } + if (notifiable.messages.count() == messages.size) { + Log.i("$TAG Creating or updating chat room shortcut") + // Only do it the first time we create the notification + ShortcutUtils.createOrUpdateChatRoomShortcut(context, chatRoom) + } + if (notifiable.messages.isNotEmpty()) { val me = coreContext.contactsManager.getMePerson(chatRoom.localAddress) val pendingIntent = getChatRoomPendingIntent(chatRoom, notifiable.notificationId) @@ -1039,6 +1172,7 @@ class NotificationsManager val notifiableMessage = NotifiableMessage( text, + message.messageId, contact, displayName, address.asStringUriOnly(), @@ -1051,6 +1185,11 @@ class NotificationsManager notifiable.messages.add(notifiableMessage) if (notifiable.messages.isNotEmpty()) { + if (notifiable.messages.count() == 1) { + // Only do it the first time we create the notification + ShortcutUtils.createOrUpdateChatRoomShortcut(context, chatRoom) + } + val me = coreContext.contactsManager.getMePerson(chatRoom.localAddress) val pendingIntent = getChatRoomPendingIntent(chatRoom, notifiable.notificationId) val notification = createMessageNotification( @@ -1094,6 +1233,90 @@ class NotificationsManager notify(notifiable.notificationId, notification, CHAT_TAG) } + @WorkerThread + private fun showAccountErrorNotification(account: Account) { + // Don't do it if background mode is not enabled, otherwise it will trigger every time + // the app is put in background and it's not relevant as long as push notifications work + if (!corePreferences.keepServiceAlive) return + + // Do not notify connexion error in background if account if push notification are available + if (account.params.isPushNotificationAvailable) return + + if (Compatibility.isPostNotificationsPermissionGranted(context)) { + val pendingIntent = TaskStackBuilder.create(context).run { + addNextIntentWithParentStack( + Intent(context, MainActivity::class.java).apply { + action = Intent.ACTION_MAIN // Needed as well + } + ) + getPendingIntent( + ACCOUNT_REGISTRATION_ERROR_ID, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + )!! + } + + val identity = account.params.identityAddress?.asStringUriOnly().orEmpty() + val notificationId = identity.hashCode() + + val notification = NotificationCompat.Builder( + context, + context.getString(R.string.notification_channel_account_error_id) + ) + .setContentTitle(context.getString(R.string.notification_account_registration_error_title, identity)) + .setContentText(context.getString(R.string.notification_account_registration_error_message)) + .setSmallIcon(R.drawable.linphone_notification) + .setAutoCancel(false) + .setOngoing(true) + .setCategory(NotificationCompat.CATEGORY_ERROR) + .setWhen(System.currentTimeMillis()) + .setShowWhen(true) + .setContentIntent(pendingIntent) + .build() + + accountsErrorNotificationsMap[identity] = notificationId + Log.i("$TAG Showing account registration error notification with ID [$notificationId] for [$identity]") + notify(notificationId, notification, ACCOUNT_ERROR_TAG) + } + } + + @WorkerThread + private fun showInCallForegroundServiceErrorNotification() { + if (Compatibility.isPostNotificationsPermissionGranted(context)) { + val pendingIntent = TaskStackBuilder.create(context).run { + addNextIntentWithParentStack( + Intent(context, CallActivity::class.java).apply { + action = Intent.ACTION_MAIN // Needed as well + flags = Intent.FLAG_ACTIVITY_REORDER_TO_FRONT + } + ) + getPendingIntent( + IN_CALL_FOREGROUND_SERVICE_ERROR_ID, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + )!! + } + + val notification = NotificationCompat.Builder( + context, + context.getString(R.string.notification_channel_account_error_id) + ) + .setContentTitle(context.getString(R.string.notification_in_call_foreground_service_error_title)) + .setContentText(context.getString(R.string.notification_in_call_foreground_service_error_message)) + .setSmallIcon(R.drawable.warning_circle) + .setAutoCancel(true) + .setOngoing(false) + .setCategory(NotificationCompat.CATEGORY_ERROR) + .setWhen(System.currentTimeMillis()) + .setShowWhen(true) + .setContentIntent(pendingIntent) + .build() + + val notificationId = IN_CALL_FOREGROUND_SERVICE_ERROR_ID + Log.i("$TAG Showing in-call foreground Service error notification with ID [$notificationId]") + notificationsMap[notificationId] = notification + notify(notificationId, notification, IN_CALL_ERROR_TAG) + } + } + @SuppressLint("MissingPermission") @WorkerThread private fun notify(id: Int, notification: Notification, tag: String? = null) { @@ -1132,7 +1355,7 @@ class NotificationsManager @WorkerThread private fun getNotificationIdForCall(call: Call): Int { - return call.callLog.startDate.toInt() + return call.hashCode() } @WorkerThread @@ -1158,6 +1381,7 @@ class NotificationsManager val address = message.fromAddress val notifiableMessage = NotifiableMessage( text, + message.messageId, contact, displayName, address.asStringUriOnly(), @@ -1187,6 +1411,7 @@ class NotificationsManager return notifiableMessage } + @SuppressLint("FullScreenIntentPolicy") @WorkerThread private fun createCallNotification( call: Call, @@ -1283,38 +1508,47 @@ class NotificationsManager setPriority(NotificationCompat.PRIORITY_HIGH) } setWhen(call.callLog.startDate * 1000) // Linphone timestamps are in seconds + setShowWhen(true) setAutoCancel(false) setOngoing(true) setContentIntent(pendingIntent) setFullScreenIntent(pendingIntent, true) } + if (!isIncoming) { + val toggleSpeakerIntent = getCallToggleSpeakerPendingIntent(notifiable) + + val audioDevice = call.outputAudioDevice + val isUsingSpeaker = audioDevice?.type == AudioDevice.Type.Speaker + + val toggleSpeakerAction = if (isUsingSpeaker) { + Log.i("$TAG Call is using speaker, adding action to disable it") + val text = AppUtils.getString(R.string.notification_disable_speaker_for_call) + NotificationCompat.Action.Builder(R.drawable.speaker_slash, text, toggleSpeakerIntent).build() + } else { + Log.i("$TAG Call is not using speaker, adding action to enable it") + val text = AppUtils.getString(R.string.notification_enable_speaker_for_call) + NotificationCompat.Action.Builder(R.drawable.speaker_high, text, toggleSpeakerIntent).build() + } + builder.addAction(toggleSpeakerAction) + } + return builder.build() } @WorkerThread private fun updateCallNotification( notifiable: Notifiable, - remoteAddress: Address, - friend: Friend + call: Call, + friend: Friend? ) { - val call = coreContext.core.getCallByRemoteAddress2(remoteAddress) - if (call == null) { - Log.w( - "$TAG Failed to find call with remote SIP URI [${remoteAddress.asStringUriOnly()}]" - ) - return - } val isIncoming = LinphoneUtils.isCallIncoming(call.state) - val notification = if (isIncoming) { - notificationsMap[INCOMING_CALL_ID] - } else { - notificationsMap[notifiable.notificationId] - } + val notificationId = notifiable.notificationId + val notification = notificationsMap[notificationId] if (notification == null) { Log.w( - "$TAG Failed to find notification with ID [${notifiable.notificationId}], creating a new one" + "$TAG Failed to find notification with ID [$notificationId], creating a new one" ) showCallNotification(call, isIncoming, friend) return @@ -1330,16 +1564,16 @@ class NotificationsManager ) if (isIncoming) { if (!currentlyDisplayedIncomingCallFragment) { - Log.i("$TAG Updating incoming call notification with ID [$INCOMING_CALL_ID]") - notify(INCOMING_CALL_ID, newNotification) + Log.i("$TAG Updating incoming call notification with ID [$notificationId]") + notify(notificationId, newNotification) } else { Log.i( "$TAG Incoming call fragment is visible, do not re-send an incoming call notification" ) } } else { - Log.i("$TAG Updating call notification with ID [${notifiable.notificationId}]") - notify(notifiable.notificationId, newNotification) + Log.i("$TAG Updating call notification with ID [$notificationId]") + notify(notificationId, newNotification) } } @@ -1370,8 +1604,13 @@ class NotificationsManager } val senderPerson = if (message.isOutgoing) null else person // Use null for ourselves + val text = if (corePreferences.showChatMessageContentInNotification) { + message.message + } else { + AppUtils.getString(R.string.notification_chat_message_hidden_content) + } val tmp = NotificationCompat.MessagingStyle.Message( - message.message, + text, message.time, senderPerson ) @@ -1428,7 +1667,12 @@ class NotificationsManager val address = call.remoteAddress.asStringUriOnly() val notifiable: Notifiable? = callNotificationsMap[address] if (notifiable != null) { - cancelNotification(notifiable.notificationId) + if (notificationsMap.containsKey(notifiable.notificationId)) { + cancelNotification(notifiable.notificationId) + } else if (notificationsMap.containsKey(IN_CALL_FOREGROUND_SERVICE_ERROR_ID)) { + Log.i("$TAG Removing previous in-call foreground Service error notification") + cancelNotification(IN_CALL_FOREGROUND_SERVICE_ERROR_ID, IN_CALL_ERROR_TAG) + } callNotificationsMap.remove(address) } else { Log.w("$TAG No notification found for call with remote address [$address]") @@ -1464,13 +1708,15 @@ class NotificationsManager @AnyThread 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) + hangupIntent.apply { + action = INTENT_HANGUP_CALL_NOTIF_ACTION + putExtra(INTENT_NOTIF_ID, notifiable.notificationId) + putExtra(INTENT_REMOTE_SIP_URI, notifiable.remoteAddress) + } return PendingIntent.getBroadcast( context, - 3, + INTENT_HANGUP_CALL_NOTIF_CODE, hangupIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) @@ -1479,13 +1725,32 @@ class NotificationsManager @AnyThread 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) + answerIntent.apply { + action = INTENT_ANSWER_CALL_NOTIF_ACTION + putExtra(INTENT_NOTIF_ID, notifiable.notificationId) + putExtra(INTENT_REMOTE_SIP_URI, notifiable.remoteAddress) + } + + return PendingIntent.getBroadcast( + context, + INTENT_ANSWER_CALL_NOTIF_CODE, + answerIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } + + @AnyThread + fun getCallToggleSpeakerPendingIntent(notifiable: Notifiable): PendingIntent { + val answerIntent = Intent(context, NotificationBroadcastReceiver::class.java) + answerIntent.apply { + action = INTENT_TOGGLE_SPEAKER_CALL_NOTIF_ACTION + putExtra(INTENT_NOTIF_ID, notifiable.notificationId) + putExtra(INTENT_REMOTE_SIP_URI, notifiable.remoteAddress) + } return PendingIntent.getBroadcast( context, - 2, + INTENT_TOGGLE_SPEAKER_CALL_NOTIF_CODE, answerIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) @@ -1501,6 +1766,7 @@ class NotificationsManager val senderAddress = message.fromAddress val reply = NotifiableMessage( text, + message.messageId, null, notifiable.myself ?: LinphoneUtils.getDisplayName(senderAddress), senderAddress.asStringUriOnly(), @@ -1529,10 +1795,12 @@ class NotificationsManager RemoteInput.Builder(KEY_TEXT_REPLY).setLabel(replyLabel).build() val replyIntent = Intent(context, NotificationBroadcastReceiver::class.java) - replyIntent.action = INTENT_REPLY_MESSAGE_NOTIF_ACTION - replyIntent.putExtra(INTENT_NOTIF_ID, notifiable.notificationId) - replyIntent.putExtra(INTENT_LOCAL_IDENTITY, notifiable.localIdentity) - replyIntent.putExtra(INTENT_REMOTE_ADDRESS, notifiable.remoteAddress) + replyIntent.apply { + action = INTENT_REPLY_MESSAGE_NOTIF_ACTION + putExtra(INTENT_NOTIF_ID, notifiable.notificationId) + putExtra(INTENT_LOCAL_IDENTITY, notifiable.localIdentity) + putExtra(INTENT_REMOTE_SIP_URI, notifiable.remoteAddress) + } // PendingIntents attached to actions with remote inputs must be mutable val replyPendingIntent = PendingIntent.getBroadcast( @@ -1556,10 +1824,12 @@ class NotificationsManager @AnyThread private fun getMarkMessageAsReadPendingIntent(notifiable: Notifiable): PendingIntent { val markAsReadIntent = Intent(context, NotificationBroadcastReceiver::class.java) - markAsReadIntent.action = INTENT_MARK_MESSAGE_AS_READ_NOTIF_ACTION - markAsReadIntent.putExtra(INTENT_NOTIF_ID, notifiable.notificationId) - markAsReadIntent.putExtra(INTENT_LOCAL_IDENTITY, notifiable.localIdentity) - markAsReadIntent.putExtra(INTENT_REMOTE_ADDRESS, notifiable.remoteAddress) + markAsReadIntent.apply { + action = INTENT_MARK_MESSAGE_AS_READ_NOTIF_ACTION + putExtra(INTENT_NOTIF_ID, notifiable.notificationId) + putExtra(INTENT_LOCAL_IDENTITY, notifiable.localIdentity) + putExtra(INTENT_REMOTE_SIP_URI, notifiable.remoteAddress) + } return PendingIntent.getBroadcast( context, @@ -1606,7 +1876,7 @@ class NotificationsManager val importance = channel?.importance ?: NotificationManagerCompat.IMPORTANCE_NONE if (importance == NotificationManagerCompat.IMPORTANCE_NONE) { Log.e( - "$TAG Keep alive for third party accounts Service channel has been disabled, can't start foreground service!" + "$TAG Keep alive for third party accounts Service channel has been disabled, can't start foreground Service!" ) return } @@ -1627,7 +1897,8 @@ class NotificationsManager val builder = NotificationCompat.Builder(context, channelId) .setSmallIcon(R.drawable.linphone_notification) - .setContentText(AppUtils.getString(R.string.notification_keep_app_alive_message)) + .setContentText(AppUtils.getString(R.string.notification_keep_app_alive_description)) + .setSubText(AppUtils.getString(R.string.notification_keep_app_alive_message)) .setAutoCancel(false) .setOngoing(true) .setCategory(NotificationCompat.CATEGORY_SERVICE) @@ -1635,23 +1906,44 @@ class NotificationsManager .setPriority(NotificationCompat.PRIORITY_LOW) .setShowWhen(false) .setContentIntent(pendingIntent) + .setDeleteIntent(getForegroundServiceDismissedIntent()) val notification = builder.build() Log.i( "$TAG Keep alive for third party accounts Service found, starting it as foreground using notification ID [$KEEP_ALIVE_FOR_THIRD_PARTY_ACCOUNTS_ID] with type [SPECIAL_USE]" ) - Compatibility.startServiceForeground( + val success = Compatibility.startServiceForeground( service, KEEP_ALIVE_FOR_THIRD_PARTY_ACCOUNTS_ID, notification, Compatibility.FOREGROUND_SERVICE_TYPE_SPECIAL_USE ) + if (!success) { + Log.e("$TAG Failed to start keep alive foreground Service!") + } currentKeepAliveThirdPartyAccountsForegroundServiceNotificationId = KEEP_ALIVE_FOR_THIRD_PARTY_ACCOUNTS_ID } else { Log.w("$TAG Keep alive for third party accounts Service hasn't started yet...") } } + @AnyThread + private fun getForegroundServiceDismissedIntent(): PendingIntent { + val foregroundServiceDismissedIntent = Intent( + context, + CoreKeepAliveThirdPartyAccountsService::class.java + ).apply { + action = INTENT_FOREGROUND_SERVICE_NOTIF_DISMISSED_ACTION + } + + return PendingIntent.getService( + context, + KEEP_ALIVE_FOR_THIRD_PARTY_ACCOUNTS_ID, + foregroundServiceDismissedIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } + @MainThread private fun stopKeepAliveServiceForeground() { val service = keepAliveService @@ -1741,6 +2033,21 @@ class NotificationsManager notificationManager.createNotificationChannel(channel) } + @MainThread + private fun createAccountErrorNotificationChannel() { + val id = context.getString(R.string.notification_channel_account_error_id) + val name = context.getString(R.string.notification_channel_account_error_name) + + val channel = NotificationChannel(id, name, NotificationManager.IMPORTANCE_HIGH).apply { + description = name + lockscreenVisibility = Notification.VISIBILITY_PUBLIC + enableLights(true) + enableVibration(true) + setShowBadge(true) + } + notificationManager.createNotificationChannel(channel) + } + @WorkerThread private fun getChatRoomPendingIntent(chatRoom: ChatRoom, notificationId: Int): PendingIntent { val args = Bundle() @@ -1751,7 +2058,7 @@ class NotificationsManager return TaskStackBuilder.create(context).run { addNextIntentWithParentStack( Intent(context, MainActivity::class.java).apply { - setAction(Intent.ACTION_MAIN) // Needed as well + action = Intent.ACTION_MAIN // Needed as well putExtras(args) // Need to pass args here for Chat extra } ) @@ -1774,6 +2081,47 @@ class NotificationsManager } } + @WorkerThread + private fun updateConversationNotification(chatRoom: ChatRoom, message: ChatMessage) { + if (corePreferences.disableChat) return + + val chatRoomPeerAddress = chatRoom.peerAddress.asStringUriOnly() + val notifiable: Notifiable? = chatNotificationsMap[chatRoomPeerAddress] + if (notifiable == null) { + Log.i("$TAG No notification for conversation [$chatRoomPeerAddress], nothing to do") + return + } + + val found = notifiable.messages.find { + it.messageId == message.messageId + } + if (found != null) { + Log.i("$TAG Edited message is in the currently displayed notification, updating it") + val index = notifiable.messages.indexOf(found) + notifiable.messages.remove(found) + if (!message.isRetracted) { + val notifiableMessage = getNotifiableForChatMessage(message) + notifiable.messages.add(index, notifiableMessage) + } + chatNotificationsMap[chatRoomPeerAddress] = notifiable + + val me = coreContext.contactsManager.getMePerson(chatRoom.localAddress) + val pendingIntent = getChatRoomPendingIntent( + chatRoom, + notifiable.notificationId + ) + val notification = createMessageNotification( + notifiable, + pendingIntent, + LinphoneUtils.getConversationId(chatRoom), + me + ) + notify(notifiable.notificationId, notification, CHAT_TAG) + } else { + Log.i("$TAG Edited/retracted message isn't in currently displayed notification, nothing to update") + } + } + @AnyThread fun foregroundServiceTypeMaskToString(mask: Int): String { val stringBuilder = StringBuilder() @@ -1807,6 +2155,7 @@ class NotificationsManager class NotifiableMessage( val message: String, + val messageId: String, var friend: Friend?, var sender: String, val senderAddress: String, diff --git a/app/src/main/java/org/linphone/telecom/TelecomCallControlCallback.kt b/app/src/main/java/org/linphone/telecom/TelecomCallControlCallback.kt index 46b31ff90a..58e06d5121 100644 --- a/app/src/main/java/org/linphone/telecom/TelecomCallControlCallback.kt +++ b/app/src/main/java/org/linphone/telecom/TelecomCallControlCallback.kt @@ -26,13 +26,11 @@ import androidx.core.telecom.CallControlResult import androidx.core.telecom.CallControlScope import androidx.core.telecom.CallEndpointCompat import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.LinphoneApplication.Companion.corePreferences -import org.linphone.core.AudioDevice import org.linphone.core.Call import org.linphone.core.CallListenerStub import org.linphone.core.Reason @@ -48,15 +46,8 @@ class TelecomCallControlCallback( ) { companion object { private const val TAG = "[Telecom Call Control Callback]" - - private const val DELAY_BEFORE_RELOADING_SOUND_DEVICES_MS = 100L } - private var availableEndpoints: List = arrayListOf() - private var currentEndpoint = CallEndpointCompat.TYPE_UNKNOWN - private var endpointUpdateRequestFromLinphone: Boolean = false - private var latestLinphoneRequestedEndpoint: CallEndpointCompat? = null - private var mutedByTelecomManager = false private val callListener = object : CallListenerStub() { @@ -125,75 +116,11 @@ class TelecomCallControlCallback( } callControl.availableEndpoints.onEach { list -> - Log.i("$TAG New available audio endpoints list") - if (availableEndpoints != list) { - Log.i( - "$TAG List size of available audio endpoints has changed, reload sound devices in SDK in [$DELAY_BEFORE_RELOADING_SOUND_DEVICES_MS] ms" - ) - coreContext.postOnCoreThreadDelayed({ core -> - core.reloadSoundDevices() - Log.i("$TAG Sound devices reloaded") - }, DELAY_BEFORE_RELOADING_SOUND_DEVICES_MS) - } - - availableEndpoints = list - for (endpoint in list) { - Log.i("$TAG Available audio endpoint [${endpoint.name}]") - } + Log.i("$TAG New available audio endpoints list but ignoring it") }.launchIn(scope) callControl.currentCallEndpoint.onEach { endpoint -> - var newEndpointToUse = endpoint - if (endpointUpdateRequestFromLinphone) { - Log.i("$TAG Linphone requests to use [${endpoint.name}] audio endpoint with type [${endpointTypeToString(endpoint.type)}]") - } else { - Log.i("$TAG Android requests us to use [${endpoint.name}] audio endpoint with type [${endpointTypeToString(endpoint.type)}]") - } - - val requestedEndpoint = latestLinphoneRequestedEndpoint - if (endpointUpdateRequestFromLinphone && requestedEndpoint != null && requestedEndpoint != endpoint) { - Log.w("$TAG WARNING: Linphone requested endpoint [${requestedEndpoint.name}] but Telecom Manager notified endpoint [${endpoint.name}], trying to use the one we requested anyway") - newEndpointToUse = requestedEndpoint - } - - val type = newEndpointToUse.type - currentEndpoint = type - if (!endpointUpdateRequestFromLinphone && !coreContext.isConnectedToAndroidAuto && (type == CallEndpointCompat.Companion.TYPE_EARPIECE || type == CallEndpointCompat.Companion.TYPE_SPEAKER)) { - endpointUpdateRequestFromLinphone = false - Log.w("$TAG Device isn't connected to Android Auto, do not follow system request to change audio endpoint to [${newEndpointToUse.name}] with type [${endpointTypeToString(type)}]") - return@onEach - } - endpointUpdateRequestFromLinphone = false - - // Change audio route in SDK, this way the usual listener will trigger - // and we'll be able to update the UI accordingly - val route = arrayListOf() - when (type) { - CallEndpointCompat.Companion.TYPE_EARPIECE -> { - route.add(AudioDevice.Type.Earpiece) - } - CallEndpointCompat.Companion.TYPE_SPEAKER -> { - route.add(AudioDevice.Type.Speaker) - } - CallEndpointCompat.Companion.TYPE_BLUETOOTH -> { - route.add(AudioDevice.Type.Bluetooth) - route.add(AudioDevice.Type.HearingAid) - } - CallEndpointCompat.Companion.TYPE_WIRED_HEADSET -> { - route.add(AudioDevice.Type.Headphones) - route.add(AudioDevice.Type.Headset) - } - } - if (route.isNotEmpty()) { - coreContext.postOnCoreThread { - if (!AudioUtils.applyAudioRouteChangeInLinphone(call, route)) { - Log.w("$TAG Failed to apply audio route change, trying again in 200ms") - coreContext.postOnCoreThreadDelayed({ - AudioUtils.applyAudioRouteChangeInLinphone(call, route) - }, 200) - } - } - } + Log.i("$TAG Android requests us to use [${endpoint.name}] audio endpoint with type [${endpointTypeToString(endpoint.type)}], ignoring it") }.launchIn(scope) callControl.isMuted.onEach { muted -> @@ -224,89 +151,12 @@ class TelecomCallControlCallback( }.launchIn(scope) } - fun applyAudioRouteToCallWithId(routes: List): Boolean { - Log.i("$TAG Looking for audio endpoint with type [${routes.first()}]") - - var wiredHeadsetFound = false - var skippedBecauseAlreadyInUse = false - for (endpoint in availableEndpoints) { - Log.i( - "$TAG Found audio endpoint [${endpoint.name}] with type [${endpointTypeToString(endpoint.type)}]" - ) - val matches = when (endpoint.type) { - CallEndpointCompat.Companion.TYPE_EARPIECE -> { - routes.find { it == AudioDevice.Type.Earpiece } - } - CallEndpointCompat.Companion.TYPE_SPEAKER -> { - routes.find { it == AudioDevice.Type.Speaker } - } - CallEndpointCompat.Companion.TYPE_BLUETOOTH -> { - routes.find { it == AudioDevice.Type.Bluetooth || it == AudioDevice.Type.HearingAid } - } - CallEndpointCompat.Companion.TYPE_WIRED_HEADSET -> { - wiredHeadsetFound = true - routes.find { it == AudioDevice.Type.Headset || it == AudioDevice.Type.Headphones } - } - else -> null - } - - if (matches != null) { - Log.i( - "$TAG Found matching audio endpoint [${endpoint.name}] with type [${endpointTypeToString(endpoint.type)}], trying to use it" - ) - if (currentEndpoint == endpoint.type) { - Log.w("$TAG Endpoint already in use, skipping") - skippedBecauseAlreadyInUse = true - continue - } - - var success = false - scope.launch { - Log.i("$TAG Requesting audio endpoint change to [${endpoint.name}] with type [${endpointTypeToString(endpoint.type)}]") - endpointUpdateRequestFromLinphone = true - latestLinphoneRequestedEndpoint = endpoint - var result: CallControlResult = callControl.requestEndpointChange(endpoint) - var attempts = 1 - while (result is CallControlResult.Error && attempts <= 2) { - delay(100) - Log.i( - "$TAG Previous attempt failed [$result], requesting again audio endpoint change to [${endpoint.name}] with type [${endpointTypeToString(endpoint.type)}]" - ) - result = callControl.requestEndpointChange(endpoint) - attempts += 1 - } - - if (result is CallControlResult.Error) { - Log.e("$TAG Failed to change endpoint audio device, error [$result]") - } else { - Log.i( - "$TAG It took [$attempts] attempt(s) to change endpoint audio device..." - ) - currentEndpoint = endpoint.type - success = true - } - } - - return success - } - } - - if (routes.size == 1 && routes[0] == AudioDevice.Type.Earpiece && wiredHeadsetFound) { - Log.e("$TAG User asked for earpiece but endpoint doesn't exists!") - } else if (skippedBecauseAlreadyInUse) { - Log.w("$TAG This endpoint was already in use (according to Telecom Manager), force changing the device in Linphone just in case") - } else { - Log.e("$TAG No matching endpoint found") - } - return false - } - private fun answerCall() { val isVideo = LinphoneUtils.isVideoEnabled(call) val type = if (isVideo) { - CallAttributesCompat.Companion.CALL_TYPE_VIDEO_CALL + CallAttributesCompat.CALL_TYPE_VIDEO_CALL } else { - CallAttributesCompat.Companion.CALL_TYPE_AUDIO_CALL + CallAttributesCompat.CALL_TYPE_AUDIO_CALL } scope.launch { Log.i("$TAG Answering [${if (isVideo) "video" else "audio"}] call") @@ -389,12 +239,12 @@ class TelecomCallControlCallback( private fun endpointTypeToString(type: Int): String { return when (type) { - CallEndpointCompat.Companion.TYPE_UNKNOWN -> "UNKNOWN" - CallEndpointCompat.Companion.TYPE_EARPIECE -> "EARPIECE" - CallEndpointCompat.Companion.TYPE_BLUETOOTH -> "BLUETOOTH" - CallEndpointCompat.Companion.TYPE_WIRED_HEADSET -> "WIRED HEADSET" - CallEndpointCompat.Companion.TYPE_SPEAKER -> "SPEAKER" - CallEndpointCompat.Companion.TYPE_STREAMING -> "STREAMING" + CallEndpointCompat.TYPE_UNKNOWN -> "UNKNOWN" + CallEndpointCompat.TYPE_EARPIECE -> "EARPIECE" + CallEndpointCompat.TYPE_BLUETOOTH -> "BLUETOOTH" + CallEndpointCompat.TYPE_WIRED_HEADSET -> "WIRED HEADSET" + CallEndpointCompat.TYPE_SPEAKER -> "SPEAKER" + CallEndpointCompat.TYPE_STREAMING -> "STREAMING" else -> "UNEXPECTED: $type" } } diff --git a/app/src/main/java/org/linphone/telecom/TelecomManager.kt b/app/src/main/java/org/linphone/telecom/TelecomManager.kt index 09cab3d994..9b802de6f5 100644 --- a/app/src/main/java/org/linphone/telecom/TelecomManager.kt +++ b/app/src/main/java/org/linphone/telecom/TelecomManager.kt @@ -29,7 +29,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import org.linphone.LinphoneApplication.Companion.coreContext -import org.linphone.core.AudioDevice import org.linphone.core.Call import org.linphone.core.Core import org.linphone.core.CoreListenerStub @@ -78,22 +77,16 @@ class TelecomManager Log.i( "$TAG Feature is [${if (hasTelecomFeature) "available" else "not available"}]" ) - try { - callsManager.registerAppWithTelecom( - CallsManager.CAPABILITY_BASELINE or - CallsManager.CAPABILITY_SUPPORTS_VIDEO_CALLING - ) - Log.i("$TAG App has been registered with Telecom") - } catch (e: Exception) { - Log.e("$TAG Can't init TelecomManager: $e") + if (hasTelecomFeature) { + try { + callsManager.registerAppWithTelecom(CallsManager.CAPABILITY_SUPPORTS_VIDEO_CALLING) + Log.i("$TAG App has been registered with Telecom") + } catch (e: Exception) { + Log.e("$TAG Can't init TelecomManager: $e") + } } } - @WorkerThread - fun getCurrentlyFollowedCalls(): Int { - return currentlyFollowedCalls - } - @WorkerThread fun onCallCreated(call: Call) { Log.i("$TAG Call to [${call.remoteAddress.asStringUriOnly()}] created in state [${call.state}]") @@ -117,11 +110,12 @@ class TelecomManager friend?.name ?: LinphoneUtils.getDisplayName(address) } - val isVideo = LinphoneUtils.isVideoEnabled(call) - val type = if (isVideo) { - CallAttributesCompat.CALL_TYPE_VIDEO_CALL - } else { + // Always set type to video (if enabled in Core) as it indicates that video is supported, not that it's being used at the time + // https://developer.android.com/reference/kotlin/androidx/core/telecom/CallAttributesCompat#CALL_TYPE_VIDEO_CALL() + val type = if (!call.core.isVideoEnabled) { CallAttributesCompat.CALL_TYPE_AUDIO_CALL + } else { + CallAttributesCompat.CALL_TYPE_VIDEO_CALL } scope.launch { @@ -131,7 +125,8 @@ class TelecomManager uri, direction, type, - capabilities + capabilities, + isLogExcluded = true ) Log.i("$TAG Adding call to Telecom's CallsManager with attributes [$callAttributes]") @@ -217,18 +212,4 @@ class TelecomManager core.removeListener(coreListener) } } - - @WorkerThread - fun applyAudioRouteToCallWithId(routes: List, callId: String): Boolean { - Log.i( - "$TAG Looking for audio endpoint with type [${routes.first()}] for call with ID [$callId]" - ) - val callControlCallback = map[callId] - if (callControlCallback == null) { - Log.w("$TAG Failed to find callbacks for call with ID [$callId]") - return false - } - - return callControlCallback.applyAudioRouteToCallWithId(routes) - } } diff --git a/app/src/main/java/org/linphone/ui/GenericActivity.kt b/app/src/main/java/org/linphone/ui/GenericActivity.kt index 6eb20d8d0f..ddcf12f1ed 100644 --- a/app/src/main/java/org/linphone/ui/GenericActivity.kt +++ b/app/src/main/java/org/linphone/ui/GenericActivity.kt @@ -59,12 +59,15 @@ open class GenericActivity : AppCompatActivity() { mainColor = corePreferences.themeMainColor val theme = super.theme when (mainColor) { - "yellow" -> theme.applyStyle(R.style.Theme_LinphoneYellow, true) - "green" -> theme.applyStyle(R.style.Theme_LinphoneGreen, true) - "blue" -> theme.applyStyle(R.style.Theme_LinphoneBlue, true) - "red" -> theme.applyStyle(R.style.Theme_LinphoneRed, true) - "pink" -> theme.applyStyle(R.style.Theme_LinphonePink, true) - "purple" -> theme.applyStyle(R.style.Theme_LinphonePurple, true) + "terracotta" -> theme.applyStyle(R.style.Theme_LinphoneTerracotta, true) + "lavender" -> theme.applyStyle(R.style.Theme_LinphoneLavender, true) + "honey" -> theme.applyStyle(R.style.Theme_LinphoneHoney, true) + "burgundy" -> theme.applyStyle(R.style.Theme_LinphoneBurgundy, true) + "mint" -> theme.applyStyle(R.style.Theme_LinphoneMint, true) + "coral" -> theme.applyStyle(R.style.Theme_LinphoneCoral, true) + "plum" -> theme.applyStyle(R.style.Theme_LinphonePlum, true) + "titanium" -> theme.applyStyle(R.style.Theme_LinphoneTitanium, true) + "mineral_blue" -> theme.applyStyle(R.style.Theme_LinphoneMineralBlue, true) else -> theme.applyStyle(R.style.Theme_Linphone, true) } return theme diff --git a/app/src/main/java/org/linphone/ui/GenericViewModel.kt b/app/src/main/java/org/linphone/ui/GenericViewModel.kt index b31e4ef38c..33416de63a 100644 --- a/app/src/main/java/org/linphone/ui/GenericViewModel.kt +++ b/app/src/main/java/org/linphone/ui/GenericViewModel.kt @@ -28,20 +28,20 @@ import org.linphone.utils.Event open class GenericViewModel : ViewModel() { // Message res id, icon val showGreenToastEvent: MutableLiveData>> by lazy { - MutableLiveData>>() + MutableLiveData() } val showFormattedGreenToastEvent: MutableLiveData>> by lazy { - MutableLiveData>>() + MutableLiveData() } // Message res id, icon val showRedToastEvent: MutableLiveData>> by lazy { - MutableLiveData>>() + MutableLiveData() } val showFormattedRedToastEvent: MutableLiveData>> by lazy { - MutableLiveData>>() + MutableLiveData() } fun showGreenToast(@StringRes message: Int, @DrawableRes icon: Int) { diff --git a/app/src/main/java/org/linphone/ui/assistant/AssistantActivity.kt b/app/src/main/java/org/linphone/ui/assistant/AssistantActivity.kt index 7f1275c1bb..e0699e0cfb 100644 --- a/app/src/main/java/org/linphone/ui/assistant/AssistantActivity.kt +++ b/app/src/main/java/org/linphone/ui/assistant/AssistantActivity.kt @@ -22,7 +22,7 @@ package org.linphone.ui.assistant import android.content.pm.PackageManager import android.os.Bundle import android.view.ViewGroup -import androidx.activity.addCallback +import androidx.activity.OnBackPressedCallback import androidx.activity.enableEdgeToEdge import androidx.annotation.UiThread import androidx.core.content.ContextCompat @@ -51,6 +51,15 @@ class AssistantActivity : GenericActivity() { private lateinit var binding: AssistantActivityBinding + private val backPressedCallback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + val navController = binding.assistantNavContainer.findNavController() + if (navController.currentDestination?.id != R.id.landingFragment) { + navController.popBackStack() + } + } + } + override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) @@ -75,8 +84,9 @@ class AssistantActivity : GenericActivity() { if (core.accountList.isEmpty()) { Log.i("$TAG No account configured, disabling back gesture") coreContext.postOnMainThread { - // Disable back gesture / button - onBackPressedDispatcher.addCallback { } + // Only allow to navigate back within the assistant nav controller, + // not to leave the AssistantActivity + onBackPressedDispatcher.addCallback(backPressedCallback) } } } diff --git a/app/src/main/java/org/linphone/ui/assistant/fragment/PermissionsFragment.kt b/app/src/main/java/org/linphone/ui/assistant/fragment/PermissionsFragment.kt index 562ae89364..4fd6d6c847 100644 --- a/app/src/main/java/org/linphone/ui/assistant/fragment/PermissionsFragment.kt +++ b/app/src/main/java/org/linphone/ui/assistant/fragment/PermissionsFragment.kt @@ -29,6 +29,7 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.UiThread import androidx.core.content.ContextCompat import androidx.navigation.fragment.findNavController +import androidx.navigation.navGraphViewModels import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.R import org.linphone.compatibility.Compatibility @@ -36,6 +37,8 @@ import org.linphone.core.tools.Log import org.linphone.databinding.AssistantPermissionsFragmentBinding import org.linphone.ui.GenericFragment import org.linphone.ui.assistant.AssistantActivity +import org.linphone.ui.assistant.viewmodel.PermissionsViewModel +import kotlin.getValue @UiThread class PermissionsFragment : GenericFragment() { @@ -45,6 +48,10 @@ class PermissionsFragment : GenericFragment() { private lateinit var binding: AssistantPermissionsFragmentBinding + private val viewModel: PermissionsViewModel by navGraphViewModels( + R.id.assistant_nav_graph + ) + private var leaving = false private val requestPermissionLauncher = registerForActivityResult( @@ -93,6 +100,7 @@ class PermissionsFragment : GenericFragment() { super.onViewCreated(view, savedInstanceState) binding.lifecycleOwner = viewLifecycleOwner + binding.viewModel = viewModel binding.setBackClickListener { findNavController().popBackStack() @@ -174,16 +182,20 @@ class PermissionsFragment : GenericFragment() { if (findNavController().currentDestination?.id == R.id.permissionsFragment) { val action = PermissionsFragmentDirections.actionPermissionsFragmentToLandingFragment() + leaving = false findNavController().navigate(action) } } private fun areAllPermissionsGranted(): Boolean { for (permission in Compatibility.getAllRequiredPermissionsArray()) { - if (ContextCompat.checkSelfPermission(requireContext(), permission) != PackageManager.PERMISSION_GRANTED) { + val granted = ContextCompat.checkSelfPermission(requireContext(), permission) == PackageManager.PERMISSION_GRANTED + viewModel.setPermissionGranted(permission, granted) + if (!granted) { Log.w("$TAG Permission [$permission] hasn't been granted yet!") return false } + } return Compatibility.hasFullScreenIntentPermission(requireContext()) } diff --git a/app/src/main/java/org/linphone/ui/assistant/fragment/QrCodeScannerFragment.kt b/app/src/main/java/org/linphone/ui/assistant/fragment/QrCodeScannerFragment.kt index 8a7c49b1ae..41e79ba8c7 100644 --- a/app/src/main/java/org/linphone/ui/assistant/fragment/QrCodeScannerFragment.kt +++ b/app/src/main/java/org/linphone/ui/assistant/fragment/QrCodeScannerFragment.kt @@ -20,6 +20,7 @@ package org.linphone.ui.assistant.fragment import android.Manifest +import android.content.Intent import android.content.pm.PackageManager import android.os.Bundle import android.view.LayoutInflater @@ -38,7 +39,7 @@ import org.linphone.databinding.AssistantQrCodeScannerFragmentBinding import org.linphone.ui.GenericActivity import org.linphone.ui.GenericFragment import org.linphone.ui.assistant.viewmodel.QrCodeViewModel -import org.linphone.ui.main.sso.fragment.SingleSignOnFragmentDirections +import org.linphone.ui.sso.SingleSignOnActivity @UiThread class QrCodeScannerFragment : GenericFragment() { @@ -105,17 +106,13 @@ class QrCodeScannerFragment : GenericFragment() { it.consume { pair -> val serverUrl = pair.first val username = pair.second - Log.i( - "$TAG Navigating to Single Sign On Fragment with server URL [$serverUrl] and username [$username]" + "$TAG Bearer auth requested, navigating to Single Sign On Fragment with server URL [$serverUrl] and username [$username]" ) - if (findNavController().currentDestination?.id == R.id.qrCodeScannerFragment) { - val action = SingleSignOnFragmentDirections.actionGlobalSingleSignOnFragment( - serverUrl, - username - ) - findNavController().navigate(action) - } + val intent = Intent(requireContext(), SingleSignOnActivity::class.java) + intent.putExtra(SingleSignOnActivity.INTENT_EXTRA_USERNAME, username) + intent.putExtra(SingleSignOnActivity.INTENT_EXTRA_SERVER_URL, serverUrl) + startActivity(intent) } } @@ -172,6 +169,7 @@ class QrCodeScannerFragment : GenericFragment() { core.nativePreviewWindowId = binding.qrCodePreview core.isQrcodeVideoPreviewEnabled = true core.isVideoPreviewEnabled = true + Log.i("$TAG Video preview with QR scanner enabled") } } } diff --git a/app/src/main/java/org/linphone/ui/assistant/fragment/RecoverAccountFragment.kt b/app/src/main/java/org/linphone/ui/assistant/fragment/RecoverAccountFragment.kt index 9471d83aa3..93b1a0bbef 100644 --- a/app/src/main/java/org/linphone/ui/assistant/fragment/RecoverAccountFragment.kt +++ b/app/src/main/java/org/linphone/ui/assistant/fragment/RecoverAccountFragment.kt @@ -33,7 +33,7 @@ import org.linphone.R import org.linphone.core.tools.Log import org.linphone.ui.GenericFragment import org.linphone.databinding.AssistantRecoverAccountFragmentBinding -import org.linphone.ui.assistant.viewmodel.RecoverPhoneAccountViewModel +import org.linphone.ui.assistant.viewmodel.AccountCreationViewModel import kotlin.getValue @UiThread @@ -44,7 +44,7 @@ class RecoverAccountFragment : GenericFragment() { private lateinit var binding: AssistantRecoverAccountFragmentBinding - private val viewModel: RecoverPhoneAccountViewModel by navGraphViewModels( + private val viewModel: AccountCreationViewModel by navGraphViewModels( R.id.assistant_nav_graph ) @@ -64,6 +64,13 @@ class RecoverAccountFragment : GenericFragment() { binding.viewModel = viewModel observeToastEvents(viewModel) + viewModel.accountRecoveryTokenReceivedEvent.observe(viewLifecycleOwner) { + it.consume { token -> + Log.i("$TAG Account recovery token received [$token], opening browser") + recoverPhoneNumberAccount(token) + } + } + binding.setBackClickListener { goBack() } @@ -73,10 +80,7 @@ class RecoverAccountFragment : GenericFragment() { } binding.setRecoverPhoneNumberAccountClickListener { - if (findNavController().currentDestination?.id == R.id.recoverAccountFragment) { - val action = RecoverAccountFragmentDirections.actionRecoverAccountFragmentToRecoverPhoneAccountFragment() - findNavController().navigate(action) - } + viewModel.requestAccountRecoveryToken() } } @@ -85,8 +89,32 @@ class RecoverAccountFragment : GenericFragment() { } private fun recoverEmailAccount() { - val url = getString(R.string.web_platform_forgotten_password_url) + val rootUrl = getString(R.string.web_platform_forgotten_password_url) + val url = "$rootUrl/recovery/email" + try { + Log.i("$TAG Trying to open [$url] URL") + val browserIntent = Intent(Intent.ACTION_VIEW, url.toUri()) + startActivity(browserIntent) + } catch (ise: IllegalStateException) { + Log.e( + "$TAG Can't start ACTION_VIEW intent for URL [$url], IllegalStateException: $ise" + ) + } catch (anfe: ActivityNotFoundException) { + Log.e( + "$TAG Can't start ACTION_VIEW intent for URL [$url], ActivityNotFoundException: $anfe" + ) + } catch (e: Exception) { + Log.e( + "$TAG Can't start ACTION_VIEW intent for URL [$url]: $e" + ) + } + } + + private fun recoverPhoneNumberAccount(recoveryToken: String) { + val rootUrl = getString(R.string.web_platform_forgotten_password_url) + val url = "$rootUrl/recovery/phone/$recoveryToken" try { + Log.i("$TAG Trying to open [$url] URL") val browserIntent = Intent(Intent.ACTION_VIEW, url.toUri()) startActivity(browserIntent) } catch (ise: IllegalStateException) { diff --git a/app/src/main/java/org/linphone/ui/assistant/fragment/RecoverPhoneAccountCodeConfirmationFragment.kt b/app/src/main/java/org/linphone/ui/assistant/fragment/RecoverPhoneAccountCodeConfirmationFragment.kt deleted file mode 100644 index 77715e7b18..0000000000 --- a/app/src/main/java/org/linphone/ui/assistant/fragment/RecoverPhoneAccountCodeConfirmationFragment.kt +++ /dev/null @@ -1,100 +0,0 @@ -/* - * Copyright (c) 2010-2025 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.ui.assistant.fragment - -import android.content.ClipboardManager -import android.content.Context -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.annotation.UiThread -import androidx.navigation.fragment.findNavController -import androidx.navigation.navGraphViewModels -import org.linphone.R -import org.linphone.core.tools.Log -import org.linphone.databinding.AssistantRecoverPhoneAccountConfirmSmsCodeFragmentBinding -import org.linphone.ui.GenericFragment -import org.linphone.ui.assistant.viewmodel.RecoverPhoneAccountViewModel - -@UiThread -class RecoverPhoneAccountCodeConfirmationFragment : GenericFragment() { - companion object { - private const val TAG = "[Recover Phone Account Code Confirmation Fragment]" - } - - private lateinit var binding: AssistantRecoverPhoneAccountConfirmSmsCodeFragmentBinding - - private val viewModel: RecoverPhoneAccountViewModel by navGraphViewModels( - R.id.assistant_nav_graph - ) - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - binding = AssistantRecoverPhoneAccountConfirmSmsCodeFragmentBinding.inflate(layoutInflater) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - binding.lifecycleOwner = viewLifecycleOwner - binding.viewModel = viewModel - observeToastEvents(viewModel) - - binding.setBackClickListener { - goBack() - } - - viewModel.accountCreatedEvent.observe(viewLifecycleOwner) { - it.consume { identity -> - Log.i("$TAG Account [$identity] has been created, leaving assistant") - requireActivity().finish() - } - } - - // This won't work starting Android 10 as clipboard access is denied unless app has focus, - // which won't be the case when the SMS arrives unless it is added into clipboard from a notification - val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - clipboard.addPrimaryClipChangedListener { - val data = clipboard.primaryClip - if (data != null && data.itemCount > 0) { - val clip = data.getItemAt(0).text.toString() - if (clip.length == 4) { - Log.i( - "$TAG Found 4 digits [$clip] as primary clip in clipboard, using it and clear it" - ) - viewModel.smsCodeFirstDigit.value = clip[0].toString() - viewModel.smsCodeSecondDigit.value = clip[1].toString() - viewModel.smsCodeThirdDigit.value = clip[2].toString() - viewModel.smsCodeLastDigit.value = clip[3].toString() - clipboard.clearPrimaryClip() - } - } - } - } - - private fun goBack() { - findNavController().popBackStack() - } -} diff --git a/app/src/main/java/org/linphone/ui/assistant/fragment/RecoverPhoneAccountFragment.kt b/app/src/main/java/org/linphone/ui/assistant/fragment/RecoverPhoneAccountFragment.kt deleted file mode 100644 index 81f03ad133..0000000000 --- a/app/src/main/java/org/linphone/ui/assistant/fragment/RecoverPhoneAccountFragment.kt +++ /dev/null @@ -1,183 +0,0 @@ -/* - * Copyright (c) 2010-2025 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.ui.assistant.fragment - -import android.content.Context -import android.os.Bundle -import android.telephony.TelephonyManager -import android.text.Editable -import android.text.TextWatcher -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.AdapterView -import android.widget.ArrayAdapter -import androidx.annotation.UiThread -import androidx.appcompat.widget.AppCompatTextView -import androidx.navigation.fragment.findNavController -import androidx.navigation.navGraphViewModels -import org.linphone.LinphoneApplication.Companion.coreContext -import org.linphone.R -import org.linphone.core.tools.Log -import org.linphone.ui.GenericFragment -import org.linphone.databinding.AssistantRecoverPhoneAccountFragmentBinding -import org.linphone.ui.assistant.viewmodel.RecoverPhoneAccountViewModel -import org.linphone.utils.AppUtils -import org.linphone.utils.ConfirmationDialogModel -import org.linphone.utils.DialogUtils -import org.linphone.utils.PhoneNumberUtils -import kotlin.getValue - -@UiThread -class RecoverPhoneAccountFragment : GenericFragment() { - companion object { - private const val TAG = "[Recover Phone Account Fragment]" - } - - private lateinit var binding: AssistantRecoverPhoneAccountFragmentBinding - - private val viewModel: RecoverPhoneAccountViewModel by navGraphViewModels( - R.id.assistant_nav_graph - ) - - private val dropdownListener = object : AdapterView.OnItemSelectedListener { - override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { - val dialPlan = viewModel.dialPlansList[position] - Log.i( - "$TAG Selected dialplan updated [+${dialPlan.countryCallingCode}] / [${dialPlan.country}]" - ) - viewModel.selectedDialPlan.value = dialPlan - } - - override fun onNothingSelected(parent: AdapterView<*>?) { - } - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - binding = AssistantRecoverPhoneAccountFragmentBinding.inflate(layoutInflater) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - binding.lifecycleOwner = viewLifecycleOwner - binding.viewModel = viewModel - observeToastEvents(viewModel) - - binding.setBackClickListener { - goBack() - } - - binding.phoneNumber.addTextChangedListener(object : TextWatcher { - override fun afterTextChanged(s: Editable?) { - viewModel.phoneNumberError.value = "" - } - - override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} - - override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} - }) - - viewModel.normalizedPhoneNumberEvent.observe(viewLifecycleOwner) { - it.consume { number -> - Log.i("$TAG Showing confirmation dialog for phone number [$number]") - showPhoneNumberConfirmationDialog(number) - } - } - - viewModel.goToSmsValidationEvent.observe(viewLifecycleOwner) { - it.consume { - if (findNavController().currentDestination?.id == R.id.recoverPhoneAccountFragment) { - Log.i("$TAG Going to SMS code validation fragment") - val action = RecoverPhoneAccountFragmentDirections.actionRecoverPhoneAccountFragmentToRecoverPhoneAccountCodeConfirmationFragment() - findNavController().navigate(action) - } - } - } - - val telephonyManager = requireContext().getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager - val countryIso = telephonyManager.networkCountryIso - coreContext.postOnCoreThread { - val fragmentContext = context ?: return@postOnCoreThread - - val adapter = object : ArrayAdapter( - fragmentContext, - R.layout.drop_down_item, - viewModel.dialPlansLabelList - ) { - override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { - val view = convertView ?: super.getView(position, null, parent) - val label = viewModel.dialPlansShortLabelList[position] - (view as? AppCompatTextView)?.text = label - return view - } - } - adapter.setDropDownViewResource(R.layout.assistant_country_picker_dropdown_cell) - - val dialPlan = PhoneNumberUtils.getDeviceDialPlan(countryIso) - var default = 0 - if (dialPlan != null) { - viewModel.selectedDialPlan.postValue(dialPlan) - default = viewModel.dialPlansList.indexOf(dialPlan) - } - - coreContext.postOnMainThread { - binding.prefix.adapter = adapter - binding.prefix.setSelection(default) - binding.prefix.onItemSelectedListener = dropdownListener - } - } - } - - private fun goBack() { - findNavController().popBackStack() - } - - private fun showPhoneNumberConfirmationDialog(number: String) { - val label = AppUtils.getFormattedString(R.string.assistant_dialog_confirm_phone_number_message, number) - val model = ConfirmationDialogModel(label) - val dialog = DialogUtils.getAccountCreationPhoneNumberConfirmationDialog( - requireActivity(), - model - ) - - model.dismissEvent.observe(viewLifecycleOwner) { - it.consume { - Log.w("$TAG User dismissed the dialog, aborting recovery process") - dialog.dismiss() - } - } - - model.confirmEvent.observe(viewLifecycleOwner) { - it.consume { - Log.i("$TAG User confirmed the phone number, requesting account creation token & SMS code") - viewModel.startRecoveryProcess() - dialog.dismiss() - } - } - - dialog.show() - } -} diff --git a/app/src/main/java/org/linphone/ui/assistant/fragment/RegisterCodeConfirmationFragment.kt b/app/src/main/java/org/linphone/ui/assistant/fragment/RegisterCodeConfirmationFragment.kt index fa033c07b2..b58725be6f 100644 --- a/app/src/main/java/org/linphone/ui/assistant/fragment/RegisterCodeConfirmationFragment.kt +++ b/app/src/main/java/org/linphone/ui/assistant/fragment/RegisterCodeConfirmationFragment.kt @@ -46,6 +46,8 @@ class RegisterCodeConfirmationFragment : GenericFragment() { R.id.assistant_nav_graph ) + private var accountCreated = false + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -70,6 +72,7 @@ class RegisterCodeConfirmationFragment : GenericFragment() { it.consume { val identity = viewModel.username.value.orEmpty() Log.i("$TAG Account [$identity] has been created, leaving assistant") + accountCreated = true requireActivity().finish() } } @@ -95,6 +98,14 @@ class RegisterCodeConfirmationFragment : GenericFragment() { } } + override fun onDestroy() { + super.onDestroy() + if (!accountCreated) { + Log.w("$TAG Account wasn't completely created, remove Account & Auth Info from Core") + viewModel.abortAccountCreation() + } + } + private fun goBack() { findNavController().popBackStack() } diff --git a/app/src/main/java/org/linphone/ui/assistant/fragment/RegisterFragment.kt b/app/src/main/java/org/linphone/ui/assistant/fragment/RegisterFragment.kt index 1afb07dea1..c2d72588ce 100644 --- a/app/src/main/java/org/linphone/ui/assistant/fragment/RegisterFragment.kt +++ b/app/src/main/java/org/linphone/ui/assistant/fragment/RegisterFragment.kt @@ -42,7 +42,6 @@ import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.R import org.linphone.core.tools.Log import org.linphone.databinding.AssistantRegisterFragmentBinding -import org.linphone.ui.GenericActivity import org.linphone.ui.GenericFragment import org.linphone.ui.assistant.viewmodel.AccountCreationViewModel import org.linphone.utils.ConfirmationDialogModel @@ -101,23 +100,7 @@ class RegisterFragment : GenericFragment() { } binding.setOpenSubscribeWebPageClickListener { - val url = getString(R.string.web_platform_register_email_url) - try { - val browserIntent = Intent(Intent.ACTION_VIEW, url.toUri()) - startActivity(browserIntent) - } catch (ise: IllegalStateException) { - Log.e( - "$TAG Can't start ACTION_VIEW intent for URL [$url], IllegalStateException: $ise" - ) - } catch (anfe: ActivityNotFoundException) { - Log.e( - "$TAG Can't start ACTION_VIEW intent for URL [$url], ActivityNotFoundException: $anfe" - ) - } catch (e: Exception) { - Log.e( - "$TAG Can't start ACTION_VIEW intent for URL [$url]: $e" - ) - } + openSubscribeOnlineWebpage() } binding.username.addTextChangedListener(object : TextWatcher { @@ -153,6 +136,12 @@ class RegisterFragment : GenericFragment() { } } + viewModel.accountCantBeCreatedBySmsEvent.observe(viewLifecycleOwner) { + it.consume { + showPhoneNumberValidationNotAvailableDialog() + } + } + viewModel.goToSmsCodeConfirmationViewEvent.observe(viewLifecycleOwner) { it.consume { Log.i("$TAG Going to SMS code confirmation fragment") @@ -164,15 +153,6 @@ class RegisterFragment : GenericFragment() { } } - viewModel.errorHappenedEvent.observe(viewLifecycleOwner) { - it.consume { error -> - (requireActivity() as GenericActivity).showRedToast( - error, - R.drawable.warning_circle - ) - } - } - val telephonyManager = requireContext().getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager val countryIso = telephonyManager.networkCountryIso coreContext.postOnCoreThread { @@ -234,4 +214,47 @@ class RegisterFragment : GenericFragment() { dialog.show() } + + private fun openSubscribeOnlineWebpage() { + val url = getString(R.string.web_platform_register_email_url) + try { + val browserIntent = Intent(Intent.ACTION_VIEW, url.toUri()) + startActivity(browserIntent) + } catch (ise: IllegalStateException) { + Log.e( + "$TAG Can't start ACTION_VIEW intent for URL [$url], IllegalStateException: $ise" + ) + } catch (anfe: ActivityNotFoundException) { + Log.e( + "$TAG Can't start ACTION_VIEW intent for URL [$url], ActivityNotFoundException: $anfe" + ) + } catch (e: Exception) { + Log.e( + "$TAG Can't start ACTION_VIEW intent for URL [$url]: $e" + ) + } + } + + private fun showPhoneNumberValidationNotAvailableDialog() { + val model = ConfirmationDialogModel() + val dialog = DialogUtils.getAccountCreationPhoneNumberValidationNotAvailableDialog( + requireActivity(), + model + ) + + model.dismissEvent.observe(viewLifecycleOwner) { + it.consume { + dialog.dismiss() + } + } + + model.confirmEvent.observe(viewLifecycleOwner) { + it.consume { + openSubscribeOnlineWebpage() + dialog.dismiss() + } + } + + dialog.show() + } } diff --git a/app/src/main/java/org/linphone/ui/assistant/fragment/ThirdPartySipAccountLoginFragment.kt b/app/src/main/java/org/linphone/ui/assistant/fragment/ThirdPartySipAccountLoginFragment.kt index 72cb21e996..eda1fb8cd6 100644 --- a/app/src/main/java/org/linphone/ui/assistant/fragment/ThirdPartySipAccountLoginFragment.kt +++ b/app/src/main/java/org/linphone/ui/assistant/fragment/ThirdPartySipAccountLoginFragment.kt @@ -20,6 +20,7 @@ package org.linphone.ui.assistant.fragment import android.content.Context +import android.content.Intent import android.os.Bundle import android.telephony.TelephonyManager import android.view.LayoutInflater @@ -40,7 +41,8 @@ import org.linphone.databinding.AssistantThirdPartySipAccountLoginFragmentBindin import org.linphone.ui.GenericActivity import org.linphone.ui.GenericFragment import org.linphone.ui.assistant.viewmodel.ThirdPartySipAccountLoginViewModel -import org.linphone.ui.main.sso.fragment.SingleSignOnFragmentDirections +import org.linphone.ui.sso.SingleSignOnActivity +import org.linphone.utils.DialogUtils import org.linphone.utils.PhoneNumberUtils @UiThread @@ -98,6 +100,10 @@ class ThirdPartySipAccountLoginFragment : GenericFragment() { goBack() } + binding.setOutboundProxyTooltipClickListener { + showOutboundProxyInfoDialog() + } + viewModel.showPassword.observe(viewLifecycleOwner) { lifecycleScope.launch { delay(50) @@ -133,15 +139,12 @@ class ThirdPartySipAccountLoginFragment : GenericFragment() { val username = pair.second Log.i( - "$TAG Navigating to Single Sign On Fragment with server URL [$serverUrl] and username [$username]" + "$TAG Bearer auth request, navigating to Single Sign On Fragment with server URL [$serverUrl] and username [$username]" ) - if (findNavController().currentDestination?.id == R.id.thirdPartySipAccountLoginFragment) { - val action = SingleSignOnFragmentDirections.actionGlobalSingleSignOnFragment( - serverUrl, - username - ) - findNavController().navigate(action) - } + val intent = Intent(requireContext(), SingleSignOnActivity::class.java) + intent.putExtra(SingleSignOnActivity.INTENT_EXTRA_USERNAME, username) + intent.putExtra(SingleSignOnActivity.INTENT_EXTRA_SERVER_URL, serverUrl) + startActivity(intent) } } @@ -159,4 +162,9 @@ class ThirdPartySipAccountLoginFragment : GenericFragment() { private fun goBack() { findNavController().popBackStack() } + + private fun showOutboundProxyInfoDialog() { + val dialog = DialogUtils.getAccountOutboundProxyHelpDialog(requireActivity()) + dialog.show() + } } diff --git a/app/src/main/java/org/linphone/ui/assistant/viewmodel/AccountCreationViewModel.kt b/app/src/main/java/org/linphone/ui/assistant/viewmodel/AccountCreationViewModel.kt index 788933fba7..9c8670b488 100644 --- a/app/src/main/java/org/linphone/ui/assistant/viewmodel/AccountCreationViewModel.kt +++ b/app/src/main/java/org/linphone/ui/assistant/viewmodel/AccountCreationViewModel.kt @@ -47,7 +47,6 @@ import org.linphone.core.Dictionary import org.linphone.core.Factory import org.linphone.core.tools.Log import org.linphone.ui.GenericViewModel -import org.linphone.utils.AppUtils import org.linphone.utils.Event import org.linphone.utils.LinphoneUtils @@ -105,15 +104,21 @@ class AccountCreationViewModel val accountCreatedEvent = MutableLiveData>() - val errorHappenedEvent: MutableLiveData> by lazy { - MutableLiveData>() + val accountRecoveryTokenReceivedEvent: MutableLiveData> by lazy { + MutableLiveData() + } + + val accountCantBeCreatedBySmsEvent: MutableLiveData> by lazy { + MutableLiveData() } private var waitingForFlexiApiPushToken = false private var waitForPushJob: Job? = null private lateinit var accountManagerServices: AccountManagerServices + private var requestedTokenIsForAccountCreation: Boolean = true private var accountCreationToken: String? = null + private var accountRecoveryToken: String? = null private var accountCreatedAuthInfo: AuthInfo? = null private var accountCreated: Account? = null @@ -124,7 +129,7 @@ class AccountCreationViewModel request: AccountManagerServicesRequest, data: String? ) { - Log.i("$TAG Request [$request] was successful, data is [$data]") + Log.i("$TAG Request [${request.type}] was successful, data is [$data]") operationInProgress.postValue(false) when (request.type) { @@ -138,6 +143,10 @@ class AccountCreationViewModel ) } } + AccountManagerServicesRequest.Type.SendAccountCreationTokenByPush, + AccountManagerServicesRequest.Type.SendAccountRecoveryTokenByPush -> { + Log.i("$TAG Send token by push notification request has been accepted, it should be received soon") + } AccountManagerServicesRequest.Type.SendPhoneNumberLinkingCodeBySms -> { goToSmsCodeConfirmationViewEvent.postValue(Event(true)) } @@ -156,12 +165,16 @@ class AccountCreationViewModel parameterErrors: Dictionary? ) { Log.e( - "$TAG Request [$request] returned an error with status code [$statusCode] and message [$errorMessage]" + "$TAG Request [${request.type}] returned an error with status code [$statusCode] and message [$errorMessage]" ) operationInProgress.postValue(false) if (!errorMessage.isNullOrEmpty()) { - showFormattedRedToast(errorMessage, R.drawable.warning_circle) + if (request.type == AccountManagerServicesRequest.Type.SendPhoneNumberLinkingCodeBySms && statusCode == 422) { + // Do not show error message sent by the account management platform for this specific scenario + } else { + showFormattedRedToast(errorMessage, R.drawable.warning_circle) + } } for (parameter in parameterErrors?.keys.orEmpty()) { @@ -174,25 +187,28 @@ class AccountCreationViewModel } when (request.type) { - AccountManagerServicesRequest.Type.SendAccountCreationTokenByPush -> { + AccountManagerServicesRequest.Type.SendAccountCreationTokenByPush, + AccountManagerServicesRequest.Type.SendAccountRecoveryTokenByPush -> { Log.w("$TAG Cancelling job waiting for push notification") waitingForFlexiApiPushToken = false waitForPushJob?.cancel() } AccountManagerServicesRequest.Type.SendPhoneNumberLinkingCodeBySms -> { - val authInfo = accountCreatedAuthInfo - if (authInfo != null) { - coreContext.core.removeAuthInfo(authInfo) - } - val account = accountCreated - if (account != null) { - coreContext.core.removeAccount(account) + Log.e("$TAG Error sending SMS code, clearing auth info & account") + removePendingAccount() + + if (statusCode == 422) { + accountCantBeCreatedBySmsEvent.postValue(Event(true)) } } + AccountManagerServicesRequest.Type.LinkPhoneNumberUsingCode -> { + Log.e("$TAG Wrong confirmation code") + } else -> { } } createEnabled.postValue(true) + lockUsernameAndPassword.postValue(false) } } @@ -220,11 +236,19 @@ class AccountCreationViewModel val token = customPayload.getString("token") if (token.isNotEmpty()) { - accountCreationToken = token - Log.i( - "$TAG Extracted token [$accountCreationToken] from push payload, creating account" - ) - createAccount() + if (requestedTokenIsForAccountCreation) { + accountCreationToken = token + Log.i( + "$TAG Extracted token [$accountCreationToken] from push payload, creating account" + ) + createAccount() + } else { + accountRecoveryToken = token + Log.i( + "$TAG Extracted token [$accountRecoveryToken] from push payload, opening browser" + ) + accountRecoveryTokenReceivedEvent.postValue(Event(token)) + } } else { Log.e("$TAG Push payload JSON object has an empty 'token'!") onFlexiApiTokenRequestError() @@ -317,9 +341,7 @@ class AccountCreationViewModel normalizedPhoneNumberEvent.postValue(Event(formattedPhoneNumber)) } else { Log.e("$TAG Account manager services hasn't been initialized!") - errorHappenedEvent.postValue( - Event(AppUtils.getString(R.string.assistant_account_register_unexpected_error)) - ) + showRedToast(R.string.assistant_account_register_unexpected_error, R.drawable.warning_circle) } } } @@ -330,8 +352,8 @@ class AccountCreationViewModel coreContext.postOnCoreThread { if (accountCreationToken.isNullOrEmpty()) { - Log.i("$TAG We don't have a creation token, let's request one") - requestFlexiApiToken() + Log.i("$TAG We don't have an account creation token yet, let's request one") + requestFlexiApiToken(requestAccountCreationToken = true) } else { val authInfo = accountCreatedAuthInfo if (authInfo != null) { @@ -345,6 +367,20 @@ class AccountCreationViewModel } } + @UiThread + fun requestAccountRecoveryToken() { + coreContext.postOnCoreThread { + val existingToken = accountRecoveryToken + if (existingToken.isNullOrEmpty()) { + Log.i("$TAG We don't have an account recovery token yet, let's request one") + requestFlexiApiToken(requestAccountCreationToken = false) + } else { + Log.i("$TAG We've already have a token [$existingToken], using it") + accountRecoveryTokenReceivedEvent.postValue(Event(existingToken)) + } + } + } + @UiThread fun toggleShowPassword() { showPassword.value = showPassword.value == false @@ -387,6 +423,27 @@ class AccountCreationViewModel } } + @UiThread + fun abortAccountCreation() { + coreContext.postOnCoreThread { + removePendingAccount() + } + } + + @WorkerThread + private fun removePendingAccount() { + val authInfo = accountCreatedAuthInfo + if (authInfo != null) { + coreContext.core.removeAuthInfo(authInfo) + accountCreatedAuthInfo = null + } + val account = accountCreated + if (account != null) { + coreContext.core.removeAccount(account) + accountCreated = null + } + } + @WorkerThread private fun sendCodeBySms() { usernameError.postValue("") @@ -519,7 +576,8 @@ class AccountCreationViewModel } @WorkerThread - private fun requestFlexiApiToken() { + private fun requestFlexiApiToken(requestAccountCreationToken: Boolean) { + requestedTokenIsForAccountCreation = requestAccountCreationToken if (!coreContext.core.isPushNotificationAvailable) { Log.e( "$TAG Core says push notification aren't available, can't request a token from FlexiAPI" @@ -545,11 +603,21 @@ class AccountCreationViewModel } // Request an auth token, will be sent by push - val request = accountManagerServices.createSendAccountCreationTokenByPushRequest( - provider, - param, - prid - ) + val request = if (requestAccountCreationToken) { + Log.i("$TAG Requesting account creation token") + accountManagerServices.createSendAccountCreationTokenByPushRequest( + provider, + param, + prid + ) + } else { + Log.i("$TAG Requesting account recovery token") + accountManagerServices.createSendAccountRecoveryTokenByPushRequest( + provider, + param, + prid + ) + } request.addListener(accountManagerServicesListener) request.submit() @@ -580,12 +648,6 @@ class AccountCreationViewModel private fun onFlexiApiTokenRequestError() { Log.e("$TAG Flexi API token request by push error!") operationInProgress.postValue(false) - errorHappenedEvent.postValue( - Event( - AppUtils.getString( - R.string.assistant_account_register_push_notification_not_received_error - ) - ) - ) + showRedToast(R.string.assistant_account_register_push_notification_not_received_error, R.drawable.warning_circle) } } diff --git a/app/src/main/java/org/linphone/ui/assistant/viewmodel/AccountLoginViewModel.kt b/app/src/main/java/org/linphone/ui/assistant/viewmodel/AccountLoginViewModel.kt index e46f441423..18209d0bce 100644 --- a/app/src/main/java/org/linphone/ui/assistant/viewmodel/AccountLoginViewModel.kt +++ b/app/src/main/java/org/linphone/ui/assistant/viewmodel/AccountLoginViewModel.kt @@ -68,15 +68,15 @@ open class AccountLoginViewModel val registrationInProgress = MutableLiveData() val accountLoggedInEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val accountLoginErrorEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val skipLandingToThirdPartySipAccountEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } var conditionsAndPrivacyPolicyAccepted = false diff --git a/app/src/main/java/org/linphone/ui/assistant/viewmodel/PermissionsViewModel.kt b/app/src/main/java/org/linphone/ui/assistant/viewmodel/PermissionsViewModel.kt new file mode 100644 index 0000000000..9030a56893 --- /dev/null +++ b/app/src/main/java/org/linphone/ui/assistant/viewmodel/PermissionsViewModel.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2010-2025 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.ui.assistant.viewmodel + +import android.Manifest +import androidx.annotation.UiThread +import androidx.lifecycle.MutableLiveData +import org.linphone.core.tools.Log +import org.linphone.ui.GenericViewModel + +class PermissionsViewModel +@UiThread +constructor() : GenericViewModel() { + companion object { + private const val TAG = "[Permissions ViewModel]" + } + + val cameraPermissionGranted = MutableLiveData() + + val recordAudioPermissionGranted = MutableLiveData() + + val readContactsPermissionGranted = MutableLiveData() + + val postNotificationsPermissionGranted = MutableLiveData() + + fun setPermissionGranted(permission: String, granted: Boolean) { + Log.i("$TAG Permission [$permission] is ${if (granted) "granted" else "not granted yet/denied"}") + when (permission) { + Manifest.permission.READ_CONTACTS -> readContactsPermissionGranted.postValue(granted) + Manifest.permission.RECORD_AUDIO -> recordAudioPermissionGranted.postValue(granted) + Manifest.permission.CAMERA -> cameraPermissionGranted.postValue(granted) + Manifest.permission.POST_NOTIFICATIONS -> postNotificationsPermissionGranted.postValue(granted) + } + } +} diff --git a/app/src/main/java/org/linphone/ui/assistant/viewmodel/QrCodeViewModel.kt b/app/src/main/java/org/linphone/ui/assistant/viewmodel/QrCodeViewModel.kt index 643b488483..452dff940f 100644 --- a/app/src/main/java/org/linphone/ui/assistant/viewmodel/QrCodeViewModel.kt +++ b/app/src/main/java/org/linphone/ui/assistant/viewmodel/QrCodeViewModel.kt @@ -105,6 +105,11 @@ class QrCodeViewModel init { coreContext.postOnCoreThread { core -> core.addListener(coreListener) + + val coreGlobalState = core.globalState + if (coreGlobalState != GlobalState.On) { + Log.e("$TAG Core isn't ON (current state is [$coreGlobalState]), video preview won't work!") + } } } @@ -121,6 +126,7 @@ class QrCodeViewModel coreContext.postOnCoreThread { core -> // Just in case, on some devices such as Xiaomi Redmi Note 5 // this is required right after granting the CAMERA permission + Log.i("$TAG Reloading video devices, might be needed if CAMERA permission was granted very recently") core.reloadVideoDevices() if (!coreContext.setBackCamera()) { diff --git a/app/src/main/java/org/linphone/ui/assistant/viewmodel/RecoverPhoneAccountViewModel.kt b/app/src/main/java/org/linphone/ui/assistant/viewmodel/RecoverPhoneAccountViewModel.kt deleted file mode 100644 index 2290a10683..0000000000 --- a/app/src/main/java/org/linphone/ui/assistant/viewmodel/RecoverPhoneAccountViewModel.kt +++ /dev/null @@ -1,449 +0,0 @@ -/* - * Copyright (c) 2010-2025 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.ui.assistant.viewmodel - -import androidx.annotation.UiThread -import androidx.annotation.WorkerThread -import androidx.lifecycle.MediatorLiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.json.JSONException -import org.json.JSONObject -import org.linphone.LinphoneApplication.Companion.coreContext -import org.linphone.LinphoneApplication.Companion.corePreferences -import org.linphone.R -import org.linphone.core.AccountCreator -import org.linphone.core.AccountCreatorListenerStub -import org.linphone.core.AccountManagerServices -import org.linphone.core.AccountManagerServicesRequest -import org.linphone.core.AccountManagerServicesRequestListenerStub -import org.linphone.core.Core -import org.linphone.core.CoreListenerStub -import org.linphone.core.DialPlan -import org.linphone.core.Dictionary -import org.linphone.core.Factory -import org.linphone.core.tools.Log -import org.linphone.ui.GenericViewModel -import org.linphone.utils.Event -import org.linphone.utils.LinphoneUtils -import java.util.Locale - -class RecoverPhoneAccountViewModel : GenericViewModel() { - companion object { - private const val TAG = "[Recover Phone Account ViewModel]" - - private const val TIME_TO_WAIT_FOR_PUSH_NOTIFICATION_WITH_ACCOUNT_CREATION_TOKEN = 5000 - } - - val pushNotificationsAvailable = MutableLiveData() - - val dialPlansLabelList = arrayListOf() - - val dialPlansShortLabelList = arrayListOf() - - val dialPlansList = arrayListOf() - - val selectedDialPlan = MutableLiveData() - - val phoneNumber = MutableLiveData() - - val phoneNumberError = MutableLiveData() - - val confirmationMessage = MutableLiveData() - - val smsCodeFirstDigit = MutableLiveData() - val smsCodeSecondDigit = MutableLiveData() - val smsCodeThirdDigit = MutableLiveData() - val smsCodeLastDigit = MutableLiveData() - - val operationInProgress = MutableLiveData() - - val recoverEnabled = MediatorLiveData() - - private var normalizedPhoneNumber: String? = null - val normalizedPhoneNumberEvent = MutableLiveData>() - - val goToSmsValidationEvent = MutableLiveData>() - - val accountCreatedEvent = MutableLiveData>() - - private lateinit var accountManagerServices: AccountManagerServices - private val accountManagerServicesListener = object : AccountManagerServicesRequestListenerStub() { - @WorkerThread - override fun onRequestSuccessful( - request: AccountManagerServicesRequest, - data: String? - ) { - Log.i("$TAG Request [$request] was successful, data is [$data]") - operationInProgress.postValue(false) - } - - @WorkerThread - override fun onRequestError( - request: AccountManagerServicesRequest, - statusCode: Int, - errorMessage: String?, - parameterErrors: Dictionary? - ) { - Log.e( - "$TAG Request [$request] returned an error with status code [$statusCode] and message [$errorMessage]" - ) - operationInProgress.postValue(false) - - if (!errorMessage.isNullOrEmpty()) { - showFormattedRedToast(errorMessage, R.drawable.warning_circle) - } - - when (request.type) { - AccountManagerServicesRequest.Type.SendAccountCreationTokenByPush -> { - Log.w("$TAG Cancelling job waiting for push notification") - waitingForFlexiApiPushToken = false - waitForPushJob?.cancel() - } - else -> { - } - } - recoverEnabled.postValue(true) - } - } - private var accountCreationToken: String? = null - - private var waitingForFlexiApiPushToken = false - private var waitForPushJob: Job? = null - - private lateinit var accountCreator: AccountCreator - private val accountCreatorListener = object : AccountCreatorListenerStub() { - @WorkerThread - override fun onRecoverAccount( - creator: AccountCreator, - status: AccountCreator.Status, - response: String? - ) { - Log.i("$TAG Recover account status is $status") - operationInProgress.postValue(false) - - if (status == AccountCreator.Status.RequestOk) { - goToSmsValidationEvent.postValue(Event(true)) - } else { - Log.e("$TAG Error in onRecoverAccount [${status.name}]") - showFormattedRedToast(status.name, R.drawable.warning_circle) - } - } - - @WorkerThread - override fun onLoginLinphoneAccount( - creator: AccountCreator, - status: AccountCreator.Status, - response: String? - ) { - Log.i("$TAG onLoginLinphoneAccount status is $status") - operationInProgress.postValue(false) - - if (status == AccountCreator.Status.RequestOk) { - if (!createAccountAndAuthInfo()) { - Log.e("$TAG Failed to create account object") - } - } else { - Log.e("$TAG Error in onRecoverAccount [${status.name}]") - showFormattedRedToast(status.name, R.drawable.warning_circle) - } - } - } - - private val coreListener = object : CoreListenerStub() { - @WorkerThread - override fun onPushNotificationReceived(core: Core, payload: String?) { - Log.i("$TAG Push received: [$payload]") - - val data = payload.orEmpty() - if (data.isNotEmpty()) { - try { - // This is because JSONObject.toString() done by the SDK will result in payload looking like {"custom-payload":"{\"token\":\"value\"}"} - val cleanPayload = data - .replace("\\\"", "\"") - .replace("\"{", "{") - .replace("}\"", "}") - Log.i("$TAG Cleaned payload is: [$cleanPayload]") - - val json = JSONObject(cleanPayload) - val customPayload = json.getJSONObject("custom-payload") - if (customPayload.has("token")) { - waitForPushJob?.cancel() - waitingForFlexiApiPushToken = false - operationInProgress.postValue(false) - - val token = customPayload.getString("token") - if (token.isNotEmpty()) { - accountCreationToken = token - Log.i( - "$TAG Extracted token [$accountCreationToken] from push payload, recovering account" - ) - requestSmsCode() - } else { - Log.e("$TAG Push payload JSON object has an empty 'token'!") - onFlexiApiTokenRequestError() - } - } else { - Log.e("$TAG Push payload JSON object has no 'token' key!") - onFlexiApiTokenRequestError() - } - } catch (e: JSONException) { - Log.e("$TAG Exception trying to parse push payload as JSON: [$e]") - onFlexiApiTokenRequestError() - } - } else { - Log.e("$TAG Push payload is null or empty, can't extract auth token!") - onFlexiApiTokenRequestError() - } - } - } - - init { - coreContext.postOnCoreThread { core -> - core.addListener(coreListener) - - pushNotificationsAvailable.postValue(LinphoneUtils.arePushNotificationsAvailable(core)) - - val dialPlans = Factory.instance().dialPlans.toList() - for (dialPlan in dialPlans) { - dialPlansList.add(dialPlan) - dialPlansLabelList.add( - "${dialPlan.flag} ${dialPlan.country} | +${dialPlan.countryCallingCode}" - ) - dialPlansShortLabelList.add( - "${dialPlan.flag} +${dialPlan.countryCallingCode}" - ) - } - - accountManagerServices = core.createAccountManagerServices() - accountManagerServices.language = Locale.getDefault().language // Returns en, fr, etc... - - accountCreator = core.createAccountCreator("https://subscribe.linphone.org/api/") - accountCreator.addListener(accountCreatorListener) - } - - recoverEnabled.addSource(selectedDialPlan) { - recoverEnabled.value = phoneNumber.value.orEmpty().isNotEmpty() && selectedDialPlan.value?.countryCallingCode.orEmpty().isNotEmpty() - } - recoverEnabled.addSource(phoneNumber) { - recoverEnabled.value = phoneNumber.value.orEmpty().isNotEmpty() && selectedDialPlan.value?.countryCallingCode.orEmpty().isNotEmpty() - } - } - - override fun onCleared() { - coreContext.postOnCoreThread { core -> - core.removeListener(coreListener) - accountCreator.removeListener(accountCreatorListener) - } - - super.onCleared() - } - - @UiThread - fun sendCode() { - coreContext.postOnCoreThread { - val dialPlan = selectedDialPlan.value - if (dialPlan == null) { - Log.e("$TAG No dial plan (country) selected!") - return@postOnCoreThread - } - val number = phoneNumber.value.orEmpty().trim() - val formattedPhoneNumber = dialPlan.formatPhoneNumber(number, false) - Log.i( - "$TAG Formatted phone number [$number] using dial plan [${dialPlan.country}] is [$formattedPhoneNumber]" - ) - - val message = coreContext.context.getString( - R.string.assistant_account_creation_sms_confirmation_explanation, - formattedPhoneNumber - ) - normalizedPhoneNumber = formattedPhoneNumber - confirmationMessage.postValue(message) - normalizedPhoneNumberEvent.postValue(Event(formattedPhoneNumber)) - } - } - - @WorkerThread - fun requestSmsCode() { - operationInProgress.postValue(true) - - coreContext.core.loadConfigFromXml(corePreferences.linphoneDefaultValuesPath) - accountCreator.domain = corePreferences.defaultDomain - - val dialPlan = selectedDialPlan.value - if (dialPlan == null) { - Log.e("$TAG No dial plan (country) selected!") - return - } - val number = phoneNumber.value.orEmpty().trim() - val countryCallingCode = dialPlan.countryCallingCode - var result = AccountCreator.PhoneNumberStatus.fromInt( - accountCreator.setPhoneNumber(number, countryCallingCode) - ) - if (result != AccountCreator.PhoneNumberStatus.Ok) { - Log.e( - "$TAG Error [$result] setting the phone number: $number with prefix: $countryCallingCode" - ) - phoneNumberError.postValue(result.name) - operationInProgress.postValue(false) - return - } - Log.i("$TAG Phone number is ${accountCreator.phoneNumber}") - - val result2 = accountCreator.setUsername(accountCreator.phoneNumber) - if (result2 != AccountCreator.UsernameStatus.Ok) { - Log.e( - "$TAG Error [${result2.name}] setting the username: ${accountCreator.phoneNumber}" - ) - phoneNumberError.postValue(result2.name) - operationInProgress.postValue(false) - return - } - Log.i("$TAG Username is ${accountCreator.username}") - - accountCreator.token = accountCreationToken - Log.i("$TAG Token is ${accountCreator.token}") - - val status = accountCreator.recoverAccount() - Log.i("$TAG Recover account returned $status") - if (status != AccountCreator.Status.RequestOk) { - operationInProgress.postValue(false) - Log.e("$TAG Error doing recoverAccount [${status.name}]") - showFormattedRedToast(status.name, R.drawable.warning_circle) - } - } - - @UiThread - fun validateCode() { - operationInProgress.value = true - - coreContext.postOnCoreThread { core -> - val code = - "${smsCodeFirstDigit.value.orEmpty().trim()}${smsCodeSecondDigit.value.orEmpty().trim()}${smsCodeThirdDigit.value.orEmpty().trim()}${smsCodeLastDigit.value.orEmpty().trim()}" - accountCreator.activationCode = code - val status = accountCreator.loginLinphoneAccount() - Log.i("$TAG Code [$code] validation result is $status") - if (status != AccountCreator.Status.RequestOk) { - operationInProgress.postValue(false) - Log.e("$TAG Error doing loginLinphoneAccount [${status.name}]") - showFormattedRedToast(status.name, R.drawable.warning_circle) - } - - // Reset code - smsCodeFirstDigit.postValue("") - smsCodeSecondDigit.postValue("") - smsCodeThirdDigit.postValue("") - smsCodeLastDigit.postValue("") - } - } - - @WorkerThread - private fun createAccountAndAuthInfo(): Boolean { - val account = accountCreator.createAccountInCore() - - if (account == null) { - Log.e("$TAG Account creator couldn't create account") - return false - } - coreContext.core.defaultAccount = account - - val username = account.params.identityAddress?.username.orEmpty() - Log.i("$TAG Account created with username [$username]") - accountCreatedEvent.postValue(Event(username)) - return true - } - - @UiThread - fun startRecoveryProcess() { - coreContext.postOnCoreThread { - requestFlexiApiToken() - } - } - - @WorkerThread - private fun requestFlexiApiToken() { - if (!coreContext.core.isPushNotificationAvailable) { - Log.e( - "$TAG Core says push notification aren't available, can't request a token from FlexiAPI" - ) - onFlexiApiTokenRequestError() - return - } - - operationInProgress.postValue(true) - recoverEnabled.postValue(false) - - val pushConfig = coreContext.core.pushNotificationConfig - if (pushConfig != null) { - val provider = pushConfig.provider - val param = pushConfig.param - val prid = pushConfig.prid - if (provider.isNullOrEmpty() || param.isNullOrEmpty() || prid.isNullOrEmpty()) { - Log.e( - "$TAG At least one mandatory push information (provider [$provider], param [$param], prid [$prid]) is missing!" - ) - onFlexiApiTokenRequestError() - return - } - - // Request an auth token, will be sent by push - val request = accountManagerServices.createSendAccountCreationTokenByPushRequest( - provider, - param, - prid - ) - request.addListener(accountManagerServicesListener) - request.submit() - - val waitFor = TIME_TO_WAIT_FOR_PUSH_NOTIFICATION_WITH_ACCOUNT_CREATION_TOKEN - waitingForFlexiApiPushToken = true - waitForPushJob?.cancel() - - Log.i("$TAG Waiting push with auth token for $waitFor ms") - waitForPushJob = viewModelScope.launch { - withContext(Dispatchers.IO) { - delay(waitFor.toLong()) - } - withContext(Dispatchers.Main) { - if (waitingForFlexiApiPushToken) { - waitingForFlexiApiPushToken = false - Log.e("$TAG Auth token wasn't received by push in [$waitFor] ms") - onFlexiApiTokenRequestError() - } - } - } - } else { - Log.e("$TAG No push configuration object in Core, shouldn't happen!") - onFlexiApiTokenRequestError() - } - } - - @WorkerThread - private fun onFlexiApiTokenRequestError() { - Log.e("$TAG Flexi API token request by push error!") - operationInProgress.postValue(false) - showRedToast(R.string.assistant_account_register_push_notification_not_received_error, R.drawable.warning_circle) - } -} diff --git a/app/src/main/java/org/linphone/ui/assistant/viewmodel/ThirdPartySipAccountLoginViewModel.kt b/app/src/main/java/org/linphone/ui/assistant/viewmodel/ThirdPartySipAccountLoginViewModel.kt index 1b7c62adf3..3b16100fca 100644 --- a/app/src/main/java/org/linphone/ui/assistant/viewmodel/ThirdPartySipAccountLoginViewModel.kt +++ b/app/src/main/java/org/linphone/ui/assistant/viewmodel/ThirdPartySipAccountLoginViewModel.kt @@ -67,6 +67,8 @@ class ThirdPartySipAccountLoginViewModel val expandAdvancedSettings = MutableLiveData() + val proxy = MutableLiveData() + val outboundProxy = MutableLiveData() val loginEnabled = MediatorLiveData() @@ -74,15 +76,15 @@ class ThirdPartySipAccountLoginViewModel val registrationInProgress = MutableLiveData() val accountLoggedInEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val accountLoginErrorEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val defaultTransportIndexEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val availableTransports = arrayListOf() @@ -236,25 +238,45 @@ class ThirdPartySipAccountLoginViewModel } accountParams.identityAddress = identityAddress - val outboundProxyValue = outboundProxy.value.orEmpty().trim() - val serverAddress = if (outboundProxyValue.isNotEmpty()) { - val server = if (outboundProxyValue.startsWith("sip:")) { - outboundProxyValue + val proxyServerValue = proxy.value.orEmpty().trim() + val proxyServerAddress = if (proxyServerValue.isNotEmpty()) { + val server = if (proxyServerValue.startsWith("sip:")) { + proxyServerValue } else { - "sip:$outboundProxyValue" + "sip:$proxyServerValue" } Factory.instance().createAddress(server) } else { domainAddress ?: Factory.instance().createAddress("sip:$domainWithoutSip") } - - serverAddress?.transport = when (transport.value.orEmpty().trim()) { + proxyServerAddress?.transport = when (transport.value.orEmpty().trim()) { TransportType.Tcp.name.uppercase(Locale.getDefault()) -> TransportType.Tcp TransportType.Tls.name.uppercase(Locale.getDefault()) -> TransportType.Tls else -> TransportType.Udp } - Log.i("$TAG Created proxy server SIP address [${serverAddress?.asStringUriOnly()}]") - accountParams.serverAddress = serverAddress + Log.i("$TAG Created proxy server SIP address [${proxyServerAddress?.asStringUriOnly()}]") + accountParams.serverAddress = proxyServerAddress + + val outboundProxyValue = outboundProxy.value.orEmpty().trim() + val outboundProxyAddress = if (outboundProxyValue.isNotEmpty()) { + val server = if (outboundProxyValue.startsWith("sip:")) { + outboundProxyValue + } else { + "sip:$outboundProxyValue" + } + Factory.instance().createAddress(server) + } else { + null + } + if (outboundProxyAddress != null) { + outboundProxyAddress.transport = when (transport.value.orEmpty().trim()) { + TransportType.Tcp.name.uppercase(Locale.getDefault()) -> TransportType.Tcp + TransportType.Tls.name.uppercase(Locale.getDefault()) -> TransportType.Tls + else -> TransportType.Udp + } + Log.i("$TAG Created outbound proxy server SIP address [${outboundProxyAddress?.asStringUriOnly()}]") + accountParams.setRoutesAddresses(arrayOf(outboundProxyAddress)) + } val prefix = internationalPrefix.value.orEmpty().trim() val isoCountryCode = internationalPrefixIsoCountryCode.value.orEmpty() diff --git a/app/src/main/java/org/linphone/ui/call/CallActivity.kt b/app/src/main/java/org/linphone/ui/call/CallActivity.kt index 4a0ff2b28c..bcbe1ba31b 100644 --- a/app/src/main/java/org/linphone/ui/call/CallActivity.kt +++ b/app/src/main/java/org/linphone/ui/call/CallActivity.kt @@ -25,6 +25,10 @@ import android.content.pm.PackageManager import android.content.res.Resources import android.graphics.Color import android.os.Bundle +import android.view.KeyEvent +import android.view.KeyboardShortcutGroup +import android.view.KeyboardShortcutInfo +import android.view.Menu import androidx.activity.SystemBarStyle import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts @@ -66,6 +70,7 @@ import org.linphone.ui.call.viewmodel.CallsViewModel import org.linphone.ui.call.viewmodel.CurrentCallViewModel import org.linphone.ui.call.viewmodel.SharedCallViewModel import org.linphone.ui.main.MainActivity +import org.linphone.utils.AppUtils @UiThread class CallActivity : GenericActivity() { @@ -109,12 +114,15 @@ class CallActivity : GenericActivity() { val mainColor = corePreferences.themeMainColor val theme = super.getTheme() when (mainColor) { - "yellow" -> theme.applyStyle(R.style.Theme_LinphoneInCallYellow, true) - "green" -> theme.applyStyle(R.style.Theme_LinphoneInCallGreen, true) - "blue" -> theme.applyStyle(R.style.Theme_LinphoneInCallBlue, true) - "red" -> theme.applyStyle(R.style.Theme_LinphoneInCallRed, true) - "pink" -> theme.applyStyle(R.style.Theme_LinphoneInCallPink, true) - "purple" -> theme.applyStyle(R.style.Theme_LinphoneInCallPurple, true) + "terracotta" -> theme.applyStyle(R.style.Theme_LinphoneInCallTerracotta, true) + "lavender" -> theme.applyStyle(R.style.Theme_LinphoneInCallLavender, true) + "honey" -> theme.applyStyle(R.style.Theme_LinphoneInCallHoney, true) + "burgundy" -> theme.applyStyle(R.style.Theme_LinphoneInCallBurgundy, true) + "mint" -> theme.applyStyle(R.style.Theme_LinphoneInCallMint, true) + "coral" -> theme.applyStyle(R.style.Theme_LinphoneInCallCoral, true) + "plum" -> theme.applyStyle(R.style.Theme_LinphoneInCallPlum, true) + "titanium" -> theme.applyStyle(R.style.Theme_LinphoneInCallTitanium, true) + "mineral_blue" -> theme.applyStyle(R.style.Theme_LinphoneInCallMineralBlue, true) else -> theme.applyStyle(R.style.Theme_LinphoneInCall, true) } return theme @@ -259,6 +267,18 @@ class CallActivity : GenericActivity() { coreContext.enableProximitySensor(enabled) } + callViewModel.goToCallEvent.observe(this) { + it.consume { + navigateToActiveCall(true) + } + } + + callViewModel.goToConferenceEvent.observe(this) { + it.consume { + navigateToActiveCall(false) + } + } + callsViewModel.showIncomingCallEvent.observe(this) { it.consume { val action = IncomingCallFragmentDirections.actionGlobalIncomingCallFragment() @@ -358,6 +378,16 @@ class CallActivity : GenericActivity() { if (::callViewModel.isInitialized) { callViewModel.pipMode.value = isInPipMode } + + if (callsViewModel.callsCount.value == 0) { + Log.w("$TAG Call activity is being resumed but no call was found, finishing activity") + finish() + } + + coreContext.postOnCoreThread { + coreContext.notificationsManager.showInCallForegroundServiceNotificationIfNeeded() + callViewModel.updateProximitySensor() + } } override fun onPause() { @@ -408,7 +438,51 @@ class CallActivity : GenericActivity() { } } - @UiThread + override fun onProvideKeyboardShortcuts( + data: MutableList?, + menu: Menu?, + deviceId: Int + ) { + super.onProvideKeyboardShortcuts(data, menu, deviceId) + + val keyboardShortcutGroup = KeyboardShortcutGroup( + "Answer/Decline incoming call", + listOf( + KeyboardShortcutInfo( + AppUtils.getString(R.string.call_action_answer), + KeyEvent.KEYCODE_A, + KeyEvent.META_CTRL_ON or KeyEvent.META_SHIFT_ON + ), + KeyboardShortcutInfo( + AppUtils.getString(R.string.call_action_decline), + KeyEvent.KEYCODE_D, + KeyEvent.META_CTRL_ON or KeyEvent.META_SHIFT_ON + ) + ) + ) + data?.add(keyboardShortcutGroup) + Log.i("$TAG Incoming call answer/decline shortcuts added") + } + + override fun onKeyShortcut(keyCode: Int, event: KeyEvent?): Boolean { + if (event?.isCtrlPressed == true && event.isShiftPressed) { + val navController = findNavController(R.id.call_nav_container) + if (navController.currentDestination?.id == R.id.incomingCallFragment) { + when (keyCode) { + KeyEvent.KEYCODE_A -> { + Log.i("$TAG Answer incoming call shortcut triggered") + callViewModel.answer() + } + KeyEvent.KEYCODE_D -> { + Log.i("$TAG Decline incoming call shortcut triggered") + callViewModel.hangUp() + } + } + } + } + return true + } + fun goToMainActivity() { if (isPipSupported && callViewModel.isVideoEnabled.value == true) { Log.i("$TAG User is going back to MainActivity, try entering PiP mode") diff --git a/app/src/main/java/org/linphone/ui/call/adapter/CallsListAdapter.kt b/app/src/main/java/org/linphone/ui/call/adapter/CallsListAdapter.kt index 40060638c6..f23f323abb 100644 --- a/app/src/main/java/org/linphone/ui/call/adapter/CallsListAdapter.kt +++ b/app/src/main/java/org/linphone/ui/call/adapter/CallsListAdapter.kt @@ -33,16 +33,16 @@ import org.linphone.databinding.CallListCellBinding import org.linphone.ui.call.model.CallModel import org.linphone.utils.Event -class CallsListAdapter : +class CallsListAdapter(private val showTransferIconInsteadOfCallState: Boolean = false) : ListAdapter(CallDiffCallback()) { var selectedAdapterPosition = -1 val callClickedEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val callLongClickedEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { @@ -55,6 +55,7 @@ class CallsListAdapter : val viewHolder = ViewHolder(binding) binding.apply { lifecycleOwner = parent.findViewTreeLifecycleOwner() + showTransferIcon = showTransferIconInsteadOfCallState setOnClickListener { callClickedEvent.value = Event(model!!) diff --git a/app/src/main/java/org/linphone/ui/call/adapter/ConferenceParticipantsListAdapter.kt b/app/src/main/java/org/linphone/ui/call/adapter/ConferenceParticipantsListAdapter.kt index f56514bbaf..8147629257 100644 --- a/app/src/main/java/org/linphone/ui/call/adapter/ConferenceParticipantsListAdapter.kt +++ b/app/src/main/java/org/linphone/ui/call/adapter/ConferenceParticipantsListAdapter.kt @@ -49,7 +49,7 @@ class ConferenceParticipantsListAdapter : (holder as ViewHolder).bind(getItem(position)) } - inner class ViewHolder( + class ViewHolder( val binding: CallConferenceParticipantListCellBinding ) : RecyclerView.ViewHolder(binding.root) { @UiThread diff --git a/app/src/main/java/org/linphone/ui/call/conference/fragment/ActiveConferenceCallFragment.kt b/app/src/main/java/org/linphone/ui/call/conference/fragment/ActiveConferenceCallFragment.kt index 342263f3a0..58e1739350 100644 --- a/app/src/main/java/org/linphone/ui/call/conference/fragment/ActiveConferenceCallFragment.kt +++ b/app/src/main/java/org/linphone/ui/call/conference/fragment/ActiveConferenceCallFragment.kt @@ -19,9 +19,6 @@ */ package org.linphone.ui.call.conference.fragment -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context import android.os.Bundle import android.os.SystemClock import android.view.LayoutInflater @@ -43,6 +40,7 @@ import org.linphone.ui.call.CallActivity import org.linphone.ui.call.fragment.GenericCallFragment import org.linphone.ui.call.viewmodel.CallsViewModel import org.linphone.ui.call.viewmodel.CurrentCallViewModel +import org.linphone.utils.AppUtils import org.linphone.utils.Event import org.linphone.utils.startAnimatedDrawable @@ -239,17 +237,6 @@ class ActiveConferenceCallFragment : GenericCallFragment() { } } - callViewModel.goToCallEvent.observe(viewLifecycleOwner) { - it.consume { - if (findNavController().currentDestination?.id == R.id.activeConferenceCallFragment) { - Log.i("$TAG Going to active call fragment") - val action = - ActiveConferenceCallFragmentDirections.actionActiveConferenceCallFragmentToActiveCallFragment() - findNavController().navigate(action) - } - } - } - binding.setBackClickListener { (requireActivity() as CallActivity).goToMainActivity() } @@ -272,14 +259,12 @@ class ActiveConferenceCallFragment : GenericCallFragment() { } } - binding.setShareConferenceClickListener { + binding.setCopyConferenceUriToClipboardClickListener { val sipUri = callViewModel.conferenceModel.sipUri.value.orEmpty() if (sipUri.isNotEmpty()) { - Log.i("$TAG Sharing conference SIP URI [$sipUri]") - - val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + Log.i("$TAG Copying conference SIP URI [$sipUri] into clipboard") val label = "Conference SIP address" - clipboard.setPrimaryClip(ClipData.newPlainText(label, sipUri)) + AppUtils.copyToClipboard(requireContext(), label, sipUri) } } diff --git a/app/src/main/java/org/linphone/ui/call/conference/fragment/ConferenceAddParticipantsFragment.kt b/app/src/main/java/org/linphone/ui/call/conference/fragment/ConferenceAddParticipantsFragment.kt index 998cf6dd4d..5688a4e59f 100644 --- a/app/src/main/java/org/linphone/ui/call/conference/fragment/ConferenceAddParticipantsFragment.kt +++ b/app/src/main/java/org/linphone/ui/call/conference/fragment/ConferenceAddParticipantsFragment.kt @@ -24,12 +24,9 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.annotation.UiThread -import androidx.annotation.WorkerThread import androidx.core.view.doOnPreDraw import androidx.lifecycle.ViewModelProvider import androidx.navigation.fragment.findNavController -import org.linphone.core.Address -import org.linphone.core.Friend import org.linphone.core.tools.Log import org.linphone.databinding.GenericAddParticipantsFragmentBinding import org.linphone.ui.call.viewmodel.CurrentCallViewModel @@ -66,11 +63,6 @@ class ConferenceAddParticipantsFragment : GenericAddressPickerFragment() { return false } - @WorkerThread - override fun onSingleAddressSelected(address: Address, friend: Friend) { - Log.e("$TAG This shouldn't happen as we should always be in multiple selection mode here!") - } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { viewModel = ViewModelProvider(this)[AddParticipantsViewModel::class.java] diff --git a/app/src/main/java/org/linphone/ui/call/conference/fragment/ConferenceParticipantsListFragment.kt b/app/src/main/java/org/linphone/ui/call/conference/fragment/ConferenceParticipantsListFragment.kt index 67aafce798..c444569b70 100644 --- a/app/src/main/java/org/linphone/ui/call/conference/fragment/ConferenceParticipantsListFragment.kt +++ b/app/src/main/java/org/linphone/ui/call/conference/fragment/ConferenceParticipantsListFragment.kt @@ -19,9 +19,6 @@ */ package org.linphone.ui.call.conference.fragment -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context import android.os.Bundle import android.view.Gravity import android.view.LayoutInflater @@ -45,6 +42,7 @@ import org.linphone.ui.GenericActivity import org.linphone.ui.call.adapter.ConferenceParticipantsListAdapter import org.linphone.ui.call.fragment.GenericCallFragment import org.linphone.ui.call.viewmodel.CurrentCallViewModel +import org.linphone.utils.AppUtils import org.linphone.utils.ConfirmationDialogModel import org.linphone.utils.DialogUtils @@ -96,6 +94,8 @@ class ConferenceParticipantsListFragment : GenericCallFragment() { binding.participantsList.setHasFixedSize(true) binding.participantsList.layoutManager = LinearLayoutManager(requireContext()) + binding.participantsList.outlineProvider = outlineProvider + binding.participantsList.clipToOutline = true binding.setBackClickListener { findNavController().popBackStack() @@ -207,10 +207,8 @@ class ConferenceParticipantsListFragment : GenericCallFragment() { val sipUri = viewModel.conferenceModel.sipUri.value.orEmpty() if (sipUri.isNotEmpty()) { Log.i("$TAG Sharing conference SIP URI [$sipUri]") - - val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val label = "Conference SIP address" - clipboard.setPrimaryClip(ClipData.newPlainText(label, sipUri)) + AppUtils.copyToClipboard(requireContext(), label, sipUri) } popupWindow.dismiss() diff --git a/app/src/main/java/org/linphone/ui/call/conference/model/ConferenceParticipantDeviceModel.kt b/app/src/main/java/org/linphone/ui/call/conference/model/ConferenceParticipantDeviceModel.kt index 7ec3e54019..2d9d937ae7 100644 --- a/app/src/main/java/org/linphone/ui/call/conference/model/ConferenceParticipantDeviceModel.kt +++ b/app/src/main/java/org/linphone/ui/call/conference/model/ConferenceParticipantDeviceModel.kt @@ -42,9 +42,9 @@ class ConferenceParticipantDeviceModel val avatarModel = coreContext.contactsManager.getContactAvatarModelForAddress(device.address) - val name = avatarModel.contactName ?: device.name ?: LinphoneUtils.getDisplayName( - device.address - ) + val name = avatarModel.contactName ?: device.name.orEmpty().ifEmpty { + LinphoneUtils.getDisplayName(device.address) + } val isMuted = MutableLiveData() diff --git a/app/src/main/java/org/linphone/ui/call/conference/viewmodel/ConferenceViewModel.kt b/app/src/main/java/org/linphone/ui/call/conference/viewmodel/ConferenceViewModel.kt index 7b24ff2977..1af6b1366d 100644 --- a/app/src/main/java/org/linphone/ui/call/conference/viewmodel/ConferenceViewModel.kt +++ b/app/src/main/java/org/linphone/ui/call/conference/viewmodel/ConferenceViewModel.kt @@ -66,6 +66,8 @@ class ConferenceViewModel val isCurrentCallInConference = MutableLiveData() + val isEndToEndEncrypted = MutableLiveData() + val conferenceLayout = MutableLiveData() val screenSharingParticipantName = MutableLiveData() @@ -83,21 +85,23 @@ class ConferenceViewModel val fullScreenMode = MutableLiveData() val firstParticipantOtherThanOurselvesJoinedEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val showLayoutMenuEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val removeParticipantEvent: MutableLiveData>> by lazy { - MutableLiveData>>() + MutableLiveData() } val goToConversationEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } + var conferenceConfigured = false + private lateinit var conference: Conference private val conferenceListener = object : ConferenceListenerStub() { @@ -127,10 +131,13 @@ class ConferenceViewModel conference: Conference, device: ParticipantDevice ) { - if (conference.isMe(device.address)) { + if (device.isMe) { + Log.i("$TAG Our device media capability changed") val direction = device.getStreamCapability(StreamType.Video) val sendingVideo = direction == MediaDirection.SendRecv || direction == MediaDirection.SendOnly localVideoStreamToggled(sendingVideo) + } else { + Log.i("$TAG Participant [${device.address.asStringUriOnly()}] device media capability changed") } } @@ -275,17 +282,26 @@ class ConferenceViewModel isPaused.postValue(!isIn) Log.i("$TAG We [${if (isIn) "are" else "aren't"}] in the conference") + val conferenceSecurityLevel = conference.currentParams.securityLevel + Log.i("$TAG Conference call security level is [$conferenceSecurityLevel]") + isEndToEndEncrypted.postValue(conferenceSecurityLevel == Conference.SecurityLevel.EndToEnd) + subject.postValue(conference.subjectUtf8.orEmpty()) computeParticipants(false) if (conference.participantList.size >= 1) { // we do not count Log.i("$TAG Joined conference already has at least another participant") firstParticipantOtherThanOurselvesJoinedEvent.postValue(Event(true)) } + + val chatEnabled = conference.currentParams.isChatEnabled + isConversationAvailable.postValue(chatEnabled) + Log.i("$TAG Chat is ${if (chatEnabled) "enabled" else "disabled"}") } } } init { + conferenceConfigured = false isPaused.value = false isConversationAvailable.value = false isMeParticipantSendingVideo.value = false @@ -294,6 +310,7 @@ class ConferenceViewModel @WorkerThread fun destroy() { + conferenceConfigured = false isCurrentCallInConference.postValue(false) if (::conference.isInitialized) { conference.removeListener(conferenceListener) @@ -309,8 +326,13 @@ class ConferenceViewModel } isCurrentCallInConference.postValue(true) + val conferenceSecurityLevel = conf.currentParams.securityLevel + Log.i("$TAG Conference call security level is [$conferenceSecurityLevel]") + isEndToEndEncrypted.postValue(conferenceSecurityLevel == Conference.SecurityLevel.EndToEnd) + conference = conf conference.addListener(conferenceListener) + conferenceConfigured = true val isIn = conference.isIn val state = conf.state @@ -326,6 +348,7 @@ class ConferenceViewModel val chatEnabled = conference.currentParams.isChatEnabled isConversationAvailable.postValue(chatEnabled) + Log.i("$TAG Chat is ${if (chatEnabled) "enabled" else "disabled"}") val confSubject = conference.subjectUtf8.orEmpty() Log.i( @@ -348,7 +371,16 @@ class ConferenceViewModel Log.w( "$TAG Conference has a participant sharing its screen, changing layout from mosaic to active speaker" ) - setNewLayout(ACTIVE_SPEAKER_LAYOUT) + setNewLayout(ACTIVE_SPEAKER_LAYOUT, call) + } else if (currentLayout == AUDIO_ONLY_LAYOUT) { + val defaultLayout = call.core.defaultConferenceLayout.toInt() + if (defaultLayout == Conference.Layout.ActiveSpeaker.toInt()) { + Log.w("$TAG Joined conference in audio only layout, switching to active speaker layout") + setNewLayout(ACTIVE_SPEAKER_LAYOUT, call) + } else { + Log.w("$TAG Joined conference in audio only layout, switching to grid layout") + setNewLayout(GRID_LAYOUT, call) + } } } @@ -360,6 +392,11 @@ class ConferenceViewModel return } + if (isPaused.value == true) { + // Do not allow turning full screen on while conference is paused + return + } + if (conferenceLayout.value == AUDIO_ONLY_LAYOUT) { // Do not allow turning full screen on for audio only conference return @@ -449,9 +486,9 @@ class ConferenceViewModel } @WorkerThread - fun setNewLayout(newLayout: Int) { + fun setNewLayout(newLayout: Int, currentCall: Call? = null) { if (::conference.isInitialized) { - val call = conference.call + val call = conference.call ?: currentCall if (call != null) { val params = call.core.createCallParams(call) if (params != null) { diff --git a/app/src/main/java/org/linphone/ui/call/fragment/ActiveCallFragment.kt b/app/src/main/java/org/linphone/ui/call/fragment/ActiveCallFragment.kt index 7cd1a91372..df65452c7b 100644 --- a/app/src/main/java/org/linphone/ui/call/fragment/ActiveCallFragment.kt +++ b/app/src/main/java/org/linphone/ui/call/fragment/ActiveCallFragment.kt @@ -234,6 +234,15 @@ class ActiveCallFragment : GenericCallFragment() { callMediaEncryptionStatsBottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN } + callViewModel.isPausedByRemote.observe(viewLifecycleOwner) { paused -> + if (paused) { + if (callViewModel.fullScreenMode.value == true) { + Log.i("$TAG Call is paused by remote, leaving full screen mode") + callViewModel.fullScreenMode.postValue(false) + } + } + } + callViewModel.showZrtpSasDialogEvent.observe(viewLifecycleOwner) { it.consume { pair -> callMediaEncryptionStatsBottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN @@ -258,7 +267,7 @@ class ActiveCallFragment : GenericCallFragment() { ) } else { // Only allow "trying again" once - showZrtpAlertDialog(false) + showZrtpAlertDialog() } } } @@ -304,6 +313,12 @@ class ActiveCallFragment : GenericCallFragment() { } } + callViewModel.clearPressedDtmfBarEvent.observe(viewLifecycleOwner) { + it.consume { + binding.callNumpad.digitsHistory.setText("") + } + } + callViewModel.appendDigitToSearchBarEvent.observe(viewLifecycleOwner) { it.consume { digit -> binding.callNumpad.digitsHistory.addCharacterAtPosition(digit) @@ -331,17 +346,6 @@ class ActiveCallFragment : GenericCallFragment() { } } - callViewModel.goToConferenceEvent.observe(viewLifecycleOwner) { - it.consume { - if (findNavController().currentDestination?.id == R.id.activeCallFragment) { - Log.i("$TAG Going to conference fragment") - val action = - ActiveCallFragmentDirections.actionActiveCallFragmentToActiveConferenceCallFragment() - findNavController().navigate(action) - } - } - } - callViewModel.isReceivingVideo.observe(viewLifecycleOwner) { receiving -> if (!receiving && callViewModel.fullScreenMode.value == true) { Log.i("$TAG We are no longer receiving video, leaving full screen mode") @@ -361,15 +365,6 @@ class ActiveCallFragment : GenericCallFragment() { } } - callViewModel.chatRoomCreationErrorEvent.observe(viewLifecycleOwner) { - it.consume { error -> - (requireActivity() as GenericActivity).showRedToast( - getString(error), - R.drawable.warning_circle - ) - } - } - callViewModel.goToConversationEvent.observe(viewLifecycleOwner) { it.consume { conversationId -> if (findNavController().currentDestination?.id == R.id.activeCallFragment) { @@ -408,7 +403,7 @@ class ActiveCallFragment : GenericCallFragment() { if (callViewModel.isZrtpAlertDialogVisible) { Log.i("$TAG Fragment resuming, showing ZRTP alert dialog") - showZrtpAlertDialog(false) + showZrtpAlertDialog() } else if (callViewModel.isZrtpDialogVisible) { Log.i("$TAG Fragment resuming, showing ZRTP SAS validation dialog") callViewModel.showZrtpSasDialogIfPossible() @@ -481,12 +476,12 @@ class ActiveCallFragment : GenericCallFragment() { callViewModel.isZrtpDialogVisible = true } - private fun showZrtpAlertDialog(allowTryAgain: Boolean = true) { + private fun showZrtpAlertDialog() { if (zrtpSasDialog != null) { zrtpSasDialog?.dismiss() } - val model = ZrtpAlertDialogModel(allowTryAgain) + val model = ZrtpAlertDialogModel(false) val dialog = DialogUtils.getZrtpAlertDialog(requireActivity(), model) model.tryAgainEvent.observe(viewLifecycleOwner) { event -> diff --git a/app/src/main/java/org/linphone/ui/call/fragment/CallsListFragment.kt b/app/src/main/java/org/linphone/ui/call/fragment/CallsListFragment.kt index 2fd2048ca9..fa8395cde7 100644 --- a/app/src/main/java/org/linphone/ui/call/fragment/CallsListFragment.kt +++ b/app/src/main/java/org/linphone/ui/call/fragment/CallsListFragment.kt @@ -85,6 +85,8 @@ class CallsListFragment : GenericCallFragment() { binding.callsList.setHasFixedSize(true) binding.callsList.layoutManager = LinearLayoutManager(requireContext()) + binding.callsList.outlineProvider = outlineProvider + binding.callsList.clipToOutline = true adapter.callLongClickedEvent.observe(viewLifecycleOwner) { it.consume { model -> diff --git a/app/src/main/java/org/linphone/ui/call/fragment/ConversationFragment.kt b/app/src/main/java/org/linphone/ui/call/fragment/ConversationFragment.kt index c18d716bad..4171344d18 100644 --- a/app/src/main/java/org/linphone/ui/call/fragment/ConversationFragment.kt +++ b/app/src/main/java/org/linphone/ui/call/fragment/ConversationFragment.kt @@ -19,12 +19,20 @@ */ package org.linphone.ui.call.fragment +import android.annotation.SuppressLint import android.content.Intent import android.os.Bundle +import android.view.MotionEvent import android.view.View +import android.view.ViewGroup +import androidx.core.view.doOnLayout +import androidx.lifecycle.ViewModelProvider import androidx.navigation.fragment.findNavController +import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.R import org.linphone.core.tools.Log +import org.linphone.ui.call.view.RoundCornersTextureView +import org.linphone.ui.call.viewmodel.CurrentCallViewModel import org.linphone.ui.fileviewer.FileViewerActivity import org.linphone.ui.fileviewer.MediaViewerActivity import org.linphone.ui.main.chat.fragment.ConversationFragment @@ -34,9 +42,48 @@ class ConversationFragment : ConversationFragment() { private const val TAG = "[In-call Conversation Fragment]" } + private lateinit var callViewModel: CurrentCallViewModel + + private lateinit var localPreviewVideoSurface: RoundCornersTextureView + + private var videoPreviewX: Float = 0f + private var videoPreviewY: Float = 0f + + // For moving video preview purposes + private val videoPreviewTouchListener = View.OnTouchListener { view, event -> + when (event.action) { + MotionEvent.ACTION_DOWN -> { + videoPreviewX = view.x - event.rawX + videoPreviewY = view.y - event.rawY + true + } + MotionEvent.ACTION_UP -> { + videoPreviewX = view.x + videoPreviewY = view.y + true + } + MotionEvent.ACTION_MOVE -> { + view.animate() + .x(event.rawX + videoPreviewX) + .y(event.rawY + videoPreviewY) + .setDuration(0) + .start() + true + } + else -> { + view.performClick() + false + } + } + } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + callViewModel = requireActivity().run { + ViewModelProvider(this)[CurrentCallViewModel::class.java] + } + Log.i("$TAG Creating an in-call ConversationFragment") sendMessageViewModel.isCallConversation.value = true viewModel.isCallConversation.value = true @@ -70,5 +117,50 @@ class ConversationFragment : ConversationFragment() { } } } + + val layout = layoutInflater.inflate(R.layout.call_video_local_preview_surface, binding.constraintLayout, false) + binding.constraintLayout.addView(layout) + localPreviewVideoSurface = layout.findViewById(R.id.local_preview_video_surface) + + callViewModel.isSendingVideo.observe(viewLifecycleOwner) { sending -> + coreContext.postOnCoreThread { core -> + core.nativePreviewWindowId = if (sending) { + Log.i("$TAG We are sending video, setting capture preview surface") + localPreviewVideoSurface + } else { + Log.i("$TAG We are not sending video, clearing capture preview surface") + null + } + } + } + } + + override fun onResume() { + super.onResume() + + (binding.root as? ViewGroup)?.doOnLayout { + setupVideoPreview(localPreviewVideoSurface) + } + } + + override fun onPause() { + super.onPause() + + cleanVideoPreview(localPreviewVideoSurface) + } + + @SuppressLint("ClickableViewAccessibility") + private fun setupVideoPreview(localPreviewVideoSurface: RoundCornersTextureView) { + if (requireActivity().isInPictureInPictureMode) { + Log.i("$TAG Activity is in PiP mode, do not move video preview") + return + } + + localPreviewVideoSurface.setOnTouchListener(videoPreviewTouchListener) + } + + @SuppressLint("ClickableViewAccessibility") + private fun cleanVideoPreview(localPreviewVideoSurface: RoundCornersTextureView) { + localPreviewVideoSurface.setOnTouchListener(null) } } diff --git a/app/src/main/java/org/linphone/ui/call/fragment/GenericCallFragment.kt b/app/src/main/java/org/linphone/ui/call/fragment/GenericCallFragment.kt index 010e13ca0c..d759085356 100644 --- a/app/src/main/java/org/linphone/ui/call/fragment/GenericCallFragment.kt +++ b/app/src/main/java/org/linphone/ui/call/fragment/GenericCallFragment.kt @@ -20,11 +20,15 @@ package org.linphone.ui.call.fragment import android.annotation.SuppressLint +import android.content.res.Configuration +import android.graphics.Outline import android.os.Bundle import android.view.MotionEvent import android.view.View +import android.view.ViewOutlineProvider import androidx.annotation.UiThread import androidx.lifecycle.ViewModelProvider +import org.linphone.R import org.linphone.core.tools.Log import org.linphone.ui.GenericFragment import org.linphone.ui.call.view.RoundCornersTextureView @@ -38,6 +42,14 @@ abstract class GenericCallFragment : GenericFragment() { protected lateinit var sharedViewModel: SharedCallViewModel + protected val outlineProvider = object : ViewOutlineProvider() { + override fun getOutline(view: View?, outline: Outline?) { + val radius = resources.getDimension(R.dimen.top_list_item_rounded_corner_radius) + view ?: return + outline?.setRoundRect(0, 0, view.width, (view.height + radius).toInt(), radius) + } + } + // For moving video preview purposes private val videoPreviewTouchListener = View.OnTouchListener { view, event -> when (event.action) { @@ -49,6 +61,7 @@ abstract class GenericCallFragment : GenericFragment() { MotionEvent.ACTION_UP -> { sharedViewModel.videoPreviewX = view.x sharedViewModel.videoPreviewY = view.y + sharedViewModel.videoPreviewOrientation = resources.configuration.orientation true } MotionEvent.ACTION_MOVE -> { @@ -82,10 +95,13 @@ abstract class GenericCallFragment : GenericFragment() { } // To restore video preview position if possible - if (sharedViewModel.videoPreviewX != 0f && sharedViewModel.videoPreviewY != 0f) { - Log.i("$TAG Restoring video preview position with position X [${sharedViewModel.videoPreviewX}] and Y [${sharedViewModel.videoPreviewY}]") - localPreviewVideoSurface.x = sharedViewModel.videoPreviewX - localPreviewVideoSurface.y = sharedViewModel.videoPreviewY + val currentOrientation = resources.configuration.orientation + if (sharedViewModel.videoPreviewOrientation == currentOrientation || sharedViewModel.videoPreviewOrientation == Configuration.ORIENTATION_UNDEFINED) { + if (sharedViewModel.videoPreviewX != 0f && sharedViewModel.videoPreviewY != 0f) { + Log.i("$TAG Restoring video preview position with position X [${sharedViewModel.videoPreviewX}] and Y [${sharedViewModel.videoPreviewY}]") + localPreviewVideoSurface.x = sharedViewModel.videoPreviewX + localPreviewVideoSurface.y = sharedViewModel.videoPreviewY + } } localPreviewVideoSurface.setOnTouchListener(videoPreviewTouchListener) diff --git a/app/src/main/java/org/linphone/ui/call/fragment/IncomingCallFragment.kt b/app/src/main/java/org/linphone/ui/call/fragment/IncomingCallFragment.kt index e9b9a2691d..7a9c5498f2 100644 --- a/app/src/main/java/org/linphone/ui/call/fragment/IncomingCallFragment.kt +++ b/app/src/main/java/org/linphone/ui/call/fragment/IncomingCallFragment.kt @@ -33,7 +33,6 @@ import org.linphone.core.tools.Log import org.linphone.databinding.CallIncomingFragmentBinding import org.linphone.ui.call.viewmodel.CurrentCallViewModel import org.linphone.utils.AppUtils -import kotlin.math.abs import kotlin.math.max import kotlin.math.min @@ -52,7 +51,7 @@ class IncomingCallFragment : GenericCallFragment() { private var initialX = 0f private var slidingButtonX = 0f private val slidingButtonTouchListener = View.OnTouchListener { view, event -> - val width = binding.bottomBar.root.width.toFloat() + val width = binding.bottomBar.lockedScreenBottomBar.root.width.toFloat() val aboveAnswer = view.x + view.width > width - areaSize val aboveDecline = view.x < areaSize @@ -81,19 +80,6 @@ class IncomingCallFragment : GenericCallFragment() { true } MotionEvent.ACTION_MOVE -> { - callViewModel.slidingButtonAboveAnswer.value = aboveAnswer - callViewModel.slidingButtonAboveDecline.value = aboveDecline - - val offset = view.x - initialX - val percent = abs(offset) / (width / 2) - if (offset > 0) { - callViewModel.answerAlpha.value = 1f - callViewModel.declineAlpha.value = 1f - percent - } else if (offset < 0) { - callViewModel.answerAlpha.value = 1f - percent - callViewModel.declineAlpha.value = 1f - } - view.animate() .x(min(max(marginSize, event.rawX + slidingButtonX), width - view.width - marginSize)) .setDuration(0) @@ -137,7 +123,7 @@ class IncomingCallFragment : GenericCallFragment() { } } - binding.bottomBar.slidingButton.setOnTouchListener(slidingButtonTouchListener) + binding.bottomBar.lockedScreenBottomBar.slidingButton.setOnTouchListener(slidingButtonTouchListener) } override fun onResume() { diff --git a/app/src/main/java/org/linphone/ui/call/fragment/NewCallFragment.kt b/app/src/main/java/org/linphone/ui/call/fragment/NewCallFragment.kt index 660fbee3fd..32fe0b6c2c 100644 --- a/app/src/main/java/org/linphone/ui/call/fragment/NewCallFragment.kt +++ b/app/src/main/java/org/linphone/ui/call/fragment/NewCallFragment.kt @@ -80,7 +80,7 @@ class NewCallFragment : GenericCallFragment() { val address = model.address if (address != null) { coreContext.postOnCoreThread { - action(address) + startCall(address) } } } @@ -228,6 +228,7 @@ class NewCallFragment : GenericCallFragment() { override fun onPause() { super.onPause() + viewModel.searchFilter.value = "" numberOrAddressPickerDialog?.dismiss() numberOrAddressPickerDialog = null } @@ -236,7 +237,7 @@ class NewCallFragment : GenericCallFragment() { coreContext.postOnCoreThread { val friend = model.friend if (friend == null) { - action(model.address) + startCall(model.address) return@postOnCoreThread } @@ -245,7 +246,7 @@ class NewCallFragment : GenericCallFragment() { Log.i( "$TAG Only 1 SIP address or phone number found for contact [${friend.name}], starting call directly" ) - action(singleAvailableAddress) + startCall(singleAvailableAddress) } else { val list = friend.getListOfSipAddressesAndPhoneNumbers(listener) Log.i( @@ -274,7 +275,7 @@ class NewCallFragment : GenericCallFragment() { } @WorkerThread - private fun action(address: Address) { + private fun startCall(address: Address) { Log.i("$TAG Calling [${address.asStringUriOnly()}]") coreContext.startAudioCall(address) diff --git a/app/src/main/java/org/linphone/ui/call/fragment/TransferCallFragment.kt b/app/src/main/java/org/linphone/ui/call/fragment/TransferCallFragment.kt index 9aa2ae560e..c03c621f84 100644 --- a/app/src/main/java/org/linphone/ui/call/fragment/TransferCallFragment.kt +++ b/app/src/main/java/org/linphone/ui/call/fragment/TransferCallFragment.kt @@ -25,6 +25,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.annotation.UiThread +import androidx.annotation.WorkerThread import androidx.core.view.doOnPreDraw import androidx.lifecycle.ViewModelProvider import androidx.navigation.fragment.findNavController @@ -35,6 +36,7 @@ import kotlin.getValue import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.LinphoneApplication.Companion.corePreferences import org.linphone.R +import org.linphone.contacts.getListOfSipAddressesAndPhoneNumbers import org.linphone.core.Address import org.linphone.core.tools.Log import org.linphone.databinding.CallTransferFragmentBinding @@ -43,10 +45,15 @@ import org.linphone.ui.call.model.CallModel import org.linphone.ui.call.viewmodel.CallsViewModel import org.linphone.ui.call.viewmodel.CurrentCallViewModel import org.linphone.ui.main.adapter.ConversationsContactsAndSuggestionsListAdapter +import org.linphone.ui.main.contacts.model.ContactNumberOrAddressClickListener +import org.linphone.ui.main.contacts.model.ContactNumberOrAddressModel +import org.linphone.ui.main.contacts.model.NumberOrAddressPickerDialogModel import org.linphone.ui.main.history.viewmodel.StartCallViewModel +import org.linphone.ui.main.model.ConversationContactOrSuggestionModel import org.linphone.utils.ConfirmationDialogModel import org.linphone.utils.AppUtils import org.linphone.utils.DialogUtils +import org.linphone.utils.LinphoneUtils import org.linphone.utils.RecyclerViewHeaderDecoration import org.linphone.utils.hideKeyboard import org.linphone.utils.setKeyboardInsetListener @@ -84,10 +91,26 @@ class TransferCallFragment : GenericCallFragment() { private var numberOrAddressPickerDialog: Dialog? = null + private val listener = object : ContactNumberOrAddressClickListener { + @UiThread + override fun onClicked(model: ContactNumberOrAddressModel) { + val address = model.address + if (address != null) { + coreContext.postOnCoreThread { + doCallTransfer(address, model.name) + } + } + } + + @UiThread + override fun onLongPress(model: ContactNumberOrAddressModel) { + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - callsAdapter = CallsListAdapter() + callsAdapter = CallsListAdapter(showTransferIconInsteadOfCallState = true) contactsAdapter = ConversationsContactsAndSuggestionsListAdapter() } @@ -144,7 +167,7 @@ class TransferCallFragment : GenericCallFragment() { contactsAdapter.onClickedEvent.observe(viewLifecycleOwner) { it.consume { model -> - showConfirmBlindTransferDialog(model.address, model.name) + startCallTransfer(model) } } @@ -249,6 +272,7 @@ class TransferCallFragment : GenericCallFragment() { override fun onPause() { super.onPause() + viewModel.searchFilter.value = "" numberOrAddressPickerDialog?.dismiss() numberOrAddressPickerDialog = null } @@ -268,6 +292,54 @@ class TransferCallFragment : GenericCallFragment() { } } + private fun startCallTransfer(model: ConversationContactOrSuggestionModel) { + coreContext.postOnCoreThread { + val friend = model.friend + if (friend == null) { + doCallTransfer(model.address, model.name) + return@postOnCoreThread + } + + val singleAvailableAddress = LinphoneUtils.getSingleAvailableAddressForFriend(friend) + if (singleAvailableAddress != null) { + Log.i( + "$TAG Only 1 SIP address or phone number found for contact [${friend.name}], starting call directly" + ) + doCallTransfer(singleAvailableAddress, model.name) + } else { + val list = friend.getListOfSipAddressesAndPhoneNumbers(listener) + Log.i( + "$TAG [${list.size}] numbers or addresses found for contact [${friend.name}], showing selection dialog" + ) + + coreContext.postOnMainThread { + val numberOrAddressModel = NumberOrAddressPickerDialogModel(list) + val dialog = + DialogUtils.getNumberOrAddressPickerDialog( + requireActivity(), + numberOrAddressModel + ) + numberOrAddressPickerDialog = dialog + + numberOrAddressModel.dismissEvent.observe(viewLifecycleOwner) { event -> + event.consume { + dialog.dismiss() + } + } + + dialog.show() + } + } + } + } + + @WorkerThread + private fun doCallTransfer(address: Address, name: String) { + coreContext.postOnMainThread { + showConfirmBlindTransferDialog(address, name) + } + } + private fun showConfirmAttendedTransferDialog(callModel: CallModel) { val from = callViewModel.displayedName.value.orEmpty() val to = callModel.displayName.value.orEmpty() @@ -283,7 +355,7 @@ class TransferCallFragment : GenericCallFragment() { model ) - model.cancelEvent.observe(viewLifecycleOwner) { + model.dismissEvent.observe(viewLifecycleOwner) { it.consume { Log.i("$TAG Attended transfer was cancelled by user") dialog.dismiss() @@ -322,7 +394,7 @@ class TransferCallFragment : GenericCallFragment() { model ) - model.cancelEvent.observe(viewLifecycleOwner) { + model.dismissEvent.observe(viewLifecycleOwner) { it.consume { Log.i("$TAG Blind transfer was cancelled by user") dialog.dismiss() @@ -332,9 +404,8 @@ class TransferCallFragment : GenericCallFragment() { model.confirmEvent.observe(viewLifecycleOwner) { it.consume { coreContext.postOnCoreThread { - val address = toAddress - Log.i("$TAG Transferring (blind) call to [${address.asStringUriOnly()}]") - callViewModel.blindTransferCallTo(address) + Log.i("$TAG Transferring (blind) call to [${toAddress.asStringUriOnly()}]") + callViewModel.blindTransferCallTo(toAddress) } dialog.dismiss() diff --git a/app/src/main/java/org/linphone/ui/call/model/CallStatsModel.kt b/app/src/main/java/org/linphone/ui/call/model/CallStatsModel.kt index f7a8d81bd5..d8e4372845 100644 --- a/app/src/main/java/org/linphone/ui/call/model/CallStatsModel.kt +++ b/app/src/main/java/org/linphone/ui/call/model/CallStatsModel.kt @@ -21,8 +21,10 @@ package org.linphone.ui.call.model import androidx.annotation.WorkerThread import androidx.lifecycle.MutableLiveData +import org.linphone.LinphoneApplication.Companion.corePreferences import kotlin.math.roundToInt import org.linphone.R +import org.linphone.core.Address import org.linphone.core.Call import org.linphone.core.CallStats import org.linphone.core.MediaDirection @@ -36,6 +38,8 @@ class CallStatsModel val audioBandwidth = MutableLiveData() val lossRate = MutableLiveData() val jitterBuffer = MutableLiveData() + val audioIce = MutableLiveData() + val audioIpFamily = MutableLiveData() val isVideoEnabled = MutableLiveData() val videoCodec = MutableLiveData() @@ -43,12 +47,20 @@ class CallStatsModel val videoLossRate = MutableLiveData() val videoResolution = MutableLiveData() val videoFps = MutableLiveData() + val videoIce = MutableLiveData() + val videoIpFamily = MutableLiveData() val fecEnabled = MutableLiveData() val lostPackets = MutableLiveData() val repairedPackets = MutableLiveData() val fecBandwidth = MutableLiveData() + val showAdvancedStats = MutableLiveData() + + init { + showAdvancedStats.postValue(corePreferences.showAdvancedCallStats) + } + @WorkerThread fun update(call: Call, stats: CallStats?) { stats ?: return @@ -96,6 +108,18 @@ class CallStatsModel "$jitterBufferSize ms" ) jitterBuffer.postValue(jitterBufferLabel) + + val iceLabel = AppUtils.getFormattedString( + R.string.call_stats_ice_label, + stats.iceState + ) + audioIce.postValue(iceLabel) + + val ipLabel = AppUtils.getFormattedString( + R.string.call_stats_ip_family_label, + if (stats.ipFamilyOfRemote == Address.Family.Inet6) "IPv6" else "IPv4" + ) + audioIpFamily.postValue(ipLabel) } StreamType.Video -> { val payloadType = call.currentParams.usedVideoPayloadType @@ -138,6 +162,18 @@ class CallStatsModel ) videoFps.postValue(fpsLabel) + val iceLabel = AppUtils.getFormattedString( + R.string.call_stats_ice_label, + stats.iceState + ) + videoIce.postValue(iceLabel) + + val ipLabel = AppUtils.getFormattedString( + R.string.call_stats_ip_family_label, + if (stats.ipFamilyOfRemote == Address.Family.Inet6) "IPv6" else "IPv4" + ) + videoIpFamily.postValue(ipLabel) + if (isFecEnabled) { val lostPacketsValue = stats.fecCumulativeLostPacketsNumber val lostPacketsLabel = AppUtils.getFormattedString( diff --git a/app/src/main/java/org/linphone/ui/call/view/RoundCornersTextureView.kt b/app/src/main/java/org/linphone/ui/call/view/RoundCornersTextureView.kt index 7dea7b1081..89484339da 100644 --- a/app/src/main/java/org/linphone/ui/call/view/RoundCornersTextureView.kt +++ b/app/src/main/java/org/linphone/ui/call/view/RoundCornersTextureView.kt @@ -25,7 +25,9 @@ import android.graphics.Rect import android.util.AttributeSet import android.view.View import android.view.ViewOutlineProvider +import androidx.annotation.MainThread import androidx.annotation.UiThread +import org.linphone.LinphoneApplication.Companion.coreContext import java.lang.NumberFormatException import org.linphone.R import org.linphone.mediastream.video.capture.CaptureTextureView @@ -86,11 +88,13 @@ class RoundCornersTextureView : CaptureTextureView { } } + @MainThread fun setRadius(radius: Float) { mRadius = radius setRoundCorners() } + @MainThread private fun setRoundCorners() { outlineProvider = object : ViewOutlineProvider() { override fun getOutline(view: View, outline: Outline) { @@ -123,7 +127,9 @@ class RoundCornersTextureView : CaptureTextureView { val previewSize = previewVideoSize if (previewSize.width > 0 && previewSize.height > 0) { - setRoundCorners() + coreContext.postOnMainThread { + setRoundCorners() + } } } } diff --git a/app/src/main/java/org/linphone/ui/call/view/VuMeterView.kt b/app/src/main/java/org/linphone/ui/call/view/VuMeterView.kt new file mode 100644 index 0000000000..7c761b6c99 --- /dev/null +++ b/app/src/main/java/org/linphone/ui/call/view/VuMeterView.kt @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2010-2025 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.ui.call.view + +import android.content.Context +import android.graphics.BitmapShader +import android.graphics.Canvas +import android.graphics.Matrix +import android.graphics.Paint +import android.graphics.Shader +import android.util.AttributeSet +import android.view.View +import androidx.core.content.ContextCompat +import androidx.core.graphics.createBitmap +import org.linphone.R + +class VuMeterView : View { + companion object { + private const val TAG = "[VuMeter View]" + } + + private lateinit var paint: Paint + private lateinit var matrix: Matrix + private lateinit var vuMeterPaint: Paint + + private val color = ContextCompat.getColor(context, R.color.vu_meter) + + private var vuMeterPercentage: Float = 0f + + constructor(context: Context?) : super(context) { + init() + } + + constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) { + init() + } + + constructor(context: Context?, attrs: AttributeSet?, defStyle: Int) : super( + context, + attrs, + defStyle + ) { + init() + } + + private fun init() { + paint = Paint() + paint.isAntiAlias = true + matrix = Matrix() + + vuMeterPaint = Paint() + vuMeterPaint.strokeWidth = 2f + vuMeterPaint.isAntiAlias = true + vuMeterPaint.color = color + } + + fun setVuMeterPercentage(percentage: Float) { + vuMeterPercentage = percentage + invalidate() + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + createShader() + } + + private fun createShader(): Shader { + val level = (height - height * vuMeterPercentage) + + val bitmap = createBitmap(width, height) + val canvas = Canvas(bitmap) + canvas.drawRect(0f, height.toFloat(), width.toFloat(), level, vuMeterPaint) + + val shader = BitmapShader(bitmap, Shader.TileMode.REPEAT, Shader.TileMode.CLAMP) + return shader + } + + override fun onDraw(canvas: Canvas) { + paint.shader = createShader() + canvas.drawCircle(width / 2f, height / 2f, width / 2f, paint) + } +} diff --git a/app/src/main/java/org/linphone/ui/call/viewmodel/CallsViewModel.kt b/app/src/main/java/org/linphone/ui/call/viewmodel/CallsViewModel.kt index f0480543ca..f90dc1532d 100644 --- a/app/src/main/java/org/linphone/ui/call/viewmodel/CallsViewModel.kt +++ b/app/src/main/java/org/linphone/ui/call/viewmodel/CallsViewModel.kt @@ -47,6 +47,10 @@ class CallsViewModel val callsCount = MutableLiveData() + val allCallsIntoConference = MutableLiveData() + + val canMergeCalls = MutableLiveData() + val showTopBar = MutableLiveData() val goToActiveCallEvent = MutableLiveData>() @@ -64,7 +68,7 @@ class CallsViewModel val callsTopBarStatus = MutableLiveData() val goToCallsListEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } private val coreListener = object : CoreListenerStub() { @@ -154,6 +158,7 @@ class CallsViewModel init { showTopBar.value = false + canMergeCalls.value = false coreContext.postOnCoreThread { core -> core.addListener(coreListener) @@ -218,6 +223,14 @@ class CallsViewModel @UiThread fun mergeCallsIntoConference() { coreContext.postOnCoreThread { core -> + if (!areAllCallsMergeable(core)) { + Log.w( + "$TAG Refusing to merge: not every call has established media, joining a connecting call to the mixer would abort liblinphone" + ) + showRedToast(R.string.conference_failed_to_merge_calls_into_conference_toast, R.drawable.warning_circle) + return@postOnCoreThread + } + val callsCount = core.callsNb val defaultAccount = LinphoneUtils.getDefaultAccount() val subject = if (defaultAccount != null && defaultAccount.params.audioVideoConferenceFactoryAddress != null) { @@ -234,6 +247,7 @@ class CallsViewModel showRedToast(R.string.conference_failed_to_merge_calls_into_conference_toast, R.drawable.warning_circle) } else { conference.addParticipants(core.calls) + allCallsIntoConference.postValue(true) } } } @@ -251,9 +265,16 @@ class CallsViewModel } callsExceptCurrentOne.postValue(list) - if (core.callsNb > 1) { - showTopBar.postValue(true) - if (core.callsNb == 2) { + val callsCount = core.callsNb + if (callsCount > 1) { + val callsNotInConference = core.calls.filter { + it.conference == null + } + val callsNotInConferenceCount = callsNotInConference.count() + Log.i("$TAG Found [$callsNotInConferenceCount] calls not in conference over [$callsCount] calls") + allCallsIntoConference.postValue(callsNotInConferenceCount == 0) + + if (callsNotInConferenceCount == 1) { val found = core.calls.find { it.state == Call.State.Paused } @@ -273,33 +294,61 @@ class CallsViewModel } callsTopBarStatus.postValue(LinphoneUtils.callStateToString(found.state)) } else { - Log.e("$TAG Failed to find a paused call") + Log.w("$TAG Failed to find a paused call") } - } else { + } else if (callsNotInConferenceCount > 1) { callsTopBarLabel.postValue( AppUtils.getFormattedString(R.string.calls_paused_count_label, core.callsNb - 1) ) callsTopBarStatus.postValue("") // TODO: improve ? + } else { + configureTopBarForSingleCallOrConference() } - } else { - if (core.callsNb == 1) { - callsTopBarIcon.postValue(R.drawable.phone) + } else if (core.callsNb == 1) { + configureTopBarForSingleCallOrConference() + } - val call = core.calls.first() - val conference = call.conference - if (conference != null) { - callsTopBarLabel.postValue(conference.subject) - } else { - val remoteAddress = call.callLog.remoteAddress - val contact = coreContext.contactsManager.findContactByAddress( - remoteAddress - ) - callsTopBarLabel.postValue( - contact?.name ?: LinphoneUtils.getDisplayName(remoteAddress) - ) - } - callsTopBarStatus.postValue(LinphoneUtils.callStateToString(call.state)) - } + canMergeCalls.postValue(areAllCallsMergeable(core)) + } + + // Merging builds a local conference by joining each call's media StreamsGroup to a mixer. + // A call still connecting/ringing has no stable stream, so the join aborts liblinphone with + // "StreamsGroup::joinMixerSession() already joined !" (native SIGABRT, kills the process). + // Only allow the merge once every call's media is established. + @WorkerThread + private fun areAllCallsMergeable(core: Core): Boolean { + return core.callsNb > 1 && core.calls.all { isCallMediaEstablished(it.state) } + } + + private fun isCallMediaEstablished(state: Call.State): Boolean { + return when (state) { + Call.State.StreamsRunning, + Call.State.Paused, + Call.State.Pausing, + Call.State.PausedByRemote, + Call.State.Resuming, + Call.State.Updating, + Call.State.UpdatedByRemote -> true + else -> false + } + } + + private fun configureTopBarForSingleCallOrConference() { + callsTopBarIcon.postValue(R.drawable.phone) + + val call = coreContext.core.calls.first() + val conference = call.conference + if (conference != null) { + callsTopBarLabel.postValue(conference.subject) + } else { + val remoteAddress = call.callLog.remoteAddress + val contact = coreContext.contactsManager.findContactByAddress( + remoteAddress + ) + callsTopBarLabel.postValue( + contact?.name ?: LinphoneUtils.getDisplayName(remoteAddress) + ) } + callsTopBarStatus.postValue(LinphoneUtils.callStateToString(call.state)) } } diff --git a/app/src/main/java/org/linphone/ui/call/viewmodel/CurrentCallViewModel.kt b/app/src/main/java/org/linphone/ui/call/viewmodel/CurrentCallViewModel.kt index 34987413cf..93ceb5504e 100644 --- a/app/src/main/java/org/linphone/ui/call/viewmodel/CurrentCallViewModel.kt +++ b/app/src/main/java/org/linphone/ui/call/viewmodel/CurrentCallViewModel.kt @@ -31,6 +31,9 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.linphone.LinphoneApplication.Companion.coreContext @@ -71,6 +74,8 @@ class CurrentCallViewModel constructor() : GenericViewModel() { companion object { private const val TAG = "[Current Call ViewModel]" + private const val VU_METER_MIN = -20f + private const val VU_METER_MAX = 0 } val contact = MutableLiveData() @@ -109,6 +114,10 @@ class CurrentCallViewModel val isMicrophoneMuted = MutableLiveData() + val microphoneRecordingVolume = MutableLiveData() + + val playbackVolume = MutableLiveData() + val isSpeakerEnabled = MutableLiveData() val isHeadsetEnabled = MutableLiveData() @@ -117,6 +126,8 @@ class CurrentCallViewModel val isBluetoothEnabled = MutableLiveData() + val isHdmiEnabled = MutableLiveData() + val fullScreenMode = MutableLiveData() val pipMode = MutableLiveData() @@ -140,33 +151,35 @@ class CurrentCallViewModel } val incomingCallTitle: MutableLiveData by lazy { - MutableLiveData() + MutableLiveData() } val qualityValue = MutableLiveData() val qualityIcon = MutableLiveData() + val hideSipAddresses = MutableLiveData() + var terminatedByUser = false val isRemoteRecordingEvent: MutableLiveData>> by lazy { - MutableLiveData>>() + MutableLiveData() } val goToEndedCallEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val finishActivityEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val requestRecordAudioPermission: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val requestCameraPermission: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val proximitySensorEnabled = MutableLiveData() @@ -175,21 +188,21 @@ class CurrentCallViewModel val callDuration = MutableLiveData() val showAudioDevicesListEvent: MutableLiveData>> by lazy { - MutableLiveData>>() + MutableLiveData() } // ZRTP related val showZrtpSasDialogEvent: MutableLiveData>>> by lazy { - MutableLiveData>>>() + MutableLiveData() } val showZrtpSasCacheMismatchDialogEvent: MutableLiveData>>> by lazy { - MutableLiveData>>>() + MutableLiveData() } val zrtpAuthTokenVerifiedEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } var isZrtpDialogVisible: Boolean = false @@ -204,11 +217,7 @@ class CurrentCallViewModel val operationInProgress = MutableLiveData() val goToConversationEvent: MutableLiveData> by lazy { - MutableLiveData>() - } - - val chatRoomCreationErrorEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } // Conference @@ -216,53 +225,49 @@ class CurrentCallViewModel val conferenceModel = ConferenceViewModel() val goToConferenceEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val goToCallEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } // Extras actions val toggleExtraActionsBottomSheetEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val showNumpadBottomSheetEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val transferInProgressEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val transferFailedEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val numpadModel: NumpadModel val appendDigitToSearchBarEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val removedCharacterAtCurrentPositionEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() + } + + val clearPressedDtmfBarEvent: MutableLiveData> by lazy { + MutableLiveData() } // Sliding answer/decline button val isScreenLocked = MutableLiveData() - val slidingButtonAboveAnswer = MutableLiveData() - - val slidingButtonAboveDecline = MutableLiveData() - - val answerAlpha = MutableLiveData() - - val declineAlpha = MutableLiveData() - lateinit var currentCall: Call private val contactsListener = object : ContactsListener { @@ -365,10 +370,8 @@ class CurrentCallViewModel updateCallDuration() if (call.conference != null) { Log.i( - "$TAG Call is in Connected state and conference isn't null, going to conference fragment" + "$TAG Call is in Connected state and conference isn't null, wait for StreamsRunning state to navigate to conference layout" ) - conferenceModel.configureFromCall(call) - goToConferenceEvent.postValue(Event(true)) } else { conferenceModel.destroy() } @@ -391,6 +394,13 @@ class CurrentCallViewModel } else -> {} } + + if (call.conference != null && !conferenceModel.conferenceConfigured) { + Log.i("$TAG Found conference on call but not conference model, initializing it now") + conferenceModel.configureFromCall(call) + updateMicrophoneMutedIcon() + goToConferenceEvent.postValue(Event(true)) + } } } @@ -413,7 +423,7 @@ class CurrentCallViewModel if (state == ChatRoom.State.Instantiated) return val id = LinphoneUtils.getConversationId(chatRoom) - Log.i("$TAG Conversation [$id] (${chatRoom.subject}) state changed: [$state]") + Log.i("$TAG Conversation [$id] (${chatRoom.subjectUtf8}) state changed: [$state]") if (state == ChatRoom.State.Created) { Log.i("$TAG Conversation [$id] successfully created") @@ -424,9 +434,7 @@ class CurrentCallViewModel Log.e("$TAG Conversation [$id] creation has failed!") chatRoom.removeListener(this) operationInProgress.postValue(false) - chatRoomCreationErrorEvent.postValue( - Event(R.string.conversation_failed_to_create_toast) - ) + showRedToast(R.string.conversation_failed_to_create_toast, R.drawable.warning_circle) } } } @@ -525,12 +533,13 @@ class CurrentCallViewModel operationInProgress.value = false proximitySensorEnabled.value = false videoUpdateInProgress.value = false + microphoneRecordingVolume.value = 0f + playbackVolume.value = 0f refreshKeyguardLockedStatus() - answerAlpha.value = 1f - declineAlpha.value = 1f coreContext.postOnCoreThread { core -> + hideSipAddresses.postValue(corePreferences.hideSipAddresses) coreContext.contactsManager.addListener(contactsListener) core.addListener(coreListener) @@ -570,6 +579,7 @@ class CurrentCallViewModel { // OnBlindTransferClicked }, { // OnClearInput + clearPressedDtmfBarEvent.value = Event(true) } ) @@ -695,21 +705,7 @@ class CurrentCallViewModel @UiThread fun refreshMicrophoneState() { coreContext.postOnCoreThread { - if (::currentCall.isInitialized) { - val micMuted = if (currentCall.conference != null) { - currentCall.conference?.microphoneMuted == true - } else { - currentCall.microphoneMuted - } - if (micMuted != isMicrophoneMuted.value) { - if (micMuted) { - Log.w("$TAG Microphone is muted, updating button state accordingly") - } else { - Log.i("$TAG Microphone is not muted, updating button state accordingly") - } - isMicrophoneMuted.postValue(micMuted) - } - } + updateMicrophoneMutedIcon() } } @@ -732,36 +728,17 @@ class CurrentCallViewModel for (device in audioDevices) { // Only list output audio devices if (!device.hasCapability(AudioDevice.Capabilities.CapabilityPlay)) continue - - val name = when (device.type) { + when (device.type) { AudioDevice.Type.Earpiece -> { earpieceFound = true - AppUtils.getString(R.string.call_audio_device_type_earpiece) } AudioDevice.Type.Speaker -> { speakerFound = true - AppUtils.getString(R.string.call_audio_device_type_speaker) - } - AudioDevice.Type.Headset -> { - AppUtils.getString(R.string.call_audio_device_type_headset) } - AudioDevice.Type.Headphones -> { - AppUtils.getString(R.string.call_audio_device_type_headphones) - } - AudioDevice.Type.Bluetooth -> { - AppUtils.getFormattedString( - R.string.call_audio_device_type_bluetooth, - device.deviceName - ) - } - AudioDevice.Type.HearingAid -> { - AppUtils.getFormattedString( - R.string.call_audio_device_type_hearing_aid, - device.deviceName - ) - } - else -> device.deviceName + else -> {} } + + val name = LinphoneUtils.getAudioDeviceName(device) val isCurrentlyInUse = device.type == currentDevice?.type && device.deviceName == currentDevice.deviceName val model = AudioDeviceModel(device, name, device.type, isCurrentlyInUse, true) { // onSelected @@ -781,6 +758,9 @@ class CurrentCallViewModel AudioDevice.Type.Speaker -> AudioUtils.routeAudioToSpeaker( currentCall ) + AudioDevice.Type.Hdmi -> AudioUtils.routeAudioToHdmi( + currentCall + ) else -> AudioUtils.routeAudioToEarpiece(currentCall) } } @@ -884,6 +864,8 @@ class CurrentCallViewModel isRecording.postValue(recording) if (recording) { showRecordingToast() + } else { + showGreenToast(R.string.call_has_been_recorded, R.drawable.record_fill) } } } @@ -1112,7 +1094,8 @@ class CurrentCallViewModel Log.i("$TAG Conference [$subject] found, going to conference fragment") conferenceModel.configureFromCall(call) goToConferenceEvent.postValue(Event(true)) - } else { + } else if (LinphoneUtils.isCallActive(call.state)) { + Log.i("$TAG No conference attached to this call, going to call fragment") conferenceModel.destroy() goToCallEvent.postValue(Event(true)) } @@ -1199,12 +1182,7 @@ class CurrentCallViewModel canBePaused.postValue(canCallBePaused()) val address = call.callLog.remoteAddress - val uri = if (corePreferences.onlyDisplaySipUriUsername) { - address.username ?: "" - } else { - LinphoneUtils.getAddressAsCleanStringUriOnly(address) - } - displayedAddress.postValue(uri) + displayedAddress.postValue(LinphoneUtils.getDisplayAddress(address)) val model = if (conferenceInfo != null) { coreContext.contactsManager.getContactAvatarModelForConferenceInfo(conferenceInfo) @@ -1256,6 +1234,19 @@ class CurrentCallViewModel } else { Log.i("$TAG Failed to find an existing 1-1 conversation for current call") } + + if (corePreferences.showMicrophoneAndSpeakerVuMeters) { + volumeVuMeterTickerFlow().onEach { + coreContext.postOnCoreThread { + val call = currentCall + val state = call.state + if (state == Call.State.End || state == Call.State.Released) return@postOnCoreThread + + microphoneRecordingVolume.postValue(computeVuMeterValue(call.recordVolume)) + playbackVolume.postValue(computeVuMeterValue(call.playVolume)) + } + }.launchIn(viewModelScope) + } } @WorkerThread @@ -1265,6 +1256,25 @@ class CurrentCallViewModel } } + @WorkerThread + private fun updateMicrophoneMutedIcon() { + if (::currentCall.isInitialized) { + val micMuted = if (currentCall.conference != null) { + currentCall.conference?.microphoneMuted == true + } else { + currentCall.microphoneMuted + } + if (micMuted != isMicrophoneMuted.value) { + if (micMuted) { + Log.w("$TAG Microphone is muted, updating button state accordingly") + } else { + Log.i("$TAG Microphone is not muted, updating button state accordingly") + } + isMicrophoneMuted.postValue(micMuted) + } + } + } + @WorkerThread private fun updateOutputAudioDevice(audioDevice: AudioDevice?) { Log.i("$TAG Output audio device updated to [${audioDevice?.deviceName} (${audioDevice?.type})]") @@ -1274,6 +1284,7 @@ class CurrentCallViewModel ) isHearingAidEnabled.postValue(audioDevice?.type == AudioDevice.Type.HearingAid) isBluetoothEnabled.postValue(audioDevice?.type == AudioDevice.Type.Bluetooth) + isHdmiEnabled.postValue(audioDevice?.type == AudioDevice.Type.Hdmi) updateProximitySensor() } @@ -1343,19 +1354,22 @@ class CurrentCallViewModel delay(1000) coreContext.postOnCoreThread { if (::currentCall.isInitialized) { - val quality = currentCall.currentQuality - val icon = when { - quality >= 4 -> R.drawable.cell_signal_full - quality >= 3 -> R.drawable.cell_signal_high - quality >= 2 -> R.drawable.cell_signal_medium - quality >= 1 -> R.drawable.cell_signal_low - else -> R.drawable.cell_signal_none + val state = currentCall.state + if (!LinphoneUtils.isCallEnding(state, true)) { + val quality = currentCall.currentQuality + val icon = when { + quality >= 4 -> R.drawable.cell_signal_full + quality >= 3 -> R.drawable.cell_signal_high + quality >= 2 -> R.drawable.cell_signal_medium + quality >= 1 -> R.drawable.cell_signal_low + else -> R.drawable.cell_signal_none + } + qualityValue.postValue(quality) + qualityIcon.postValue(icon) + + updateCallQualityIcon() } - qualityValue.postValue(quality) - qualityIcon.postValue(icon) } - - updateCallQualityIcon() } } } @@ -1438,9 +1452,7 @@ class CurrentCallViewModel "$TAG Failed to create 1-1 conversation with [${remoteAddress.asStringUriOnly()}]!" ) operationInProgress.postValue(false) - chatRoomCreationErrorEvent.postValue( - Event(R.string.conversation_failed_to_create_toast) - ) + showRedToast(R.string.conversation_failed_to_create_toast, R.drawable.warning_circle) } } @@ -1527,28 +1539,53 @@ class CurrentCallViewModel showGreenToast(R.string.call_is_being_recorded, R.drawable.record_fill) } + private fun volumeVuMeterTickerFlow() = flow { + while (::currentCall.isInitialized) { + emit(Unit) + delay(50) + } + } + + private fun computeVuMeterValue(volume: Float): Float { + if (volume < VU_METER_MIN) return 0f + if (volume > VU_METER_MAX) return 1f + return (volume - VU_METER_MIN) / (VU_METER_MAX - VU_METER_MIN) + } + @WorkerThread - private fun updateProximitySensor() { + fun updateProximitySensor() { if (::currentCall.isInitialized) { val callState = currentCall.state + Log.i("$TAG Call is in state [$callState], enabling/disabling proximity sensor if needed") + if (LinphoneUtils.isCallIncoming(callState)) { + Log.i("$TAG Call is incoming, disabling proximity sensor") proximitySensorEnabled.postValue(false) } else if (LinphoneUtils.isCallOutgoing(callState)) { val videoEnabled = currentCall.params.isVideoEnabled + if (videoEnabled) { + Log.i("$TAG Call is outgoing and video is enabled, disabling proximity sensor") + } else { + Log.i("$TAG Call is outgoing and video is disabled, enabling proximity sensor") + } proximitySensorEnabled.postValue(!videoEnabled) } else { if (isSendingVideo.value == true || isReceivingVideo.value == true) { + Log.i("$TAG Video is being sent and/or received, disabling proximity sensor") proximitySensorEnabled.postValue(false) } else { val outputAudioDevice = currentCall.outputAudioDevice ?: coreContext.core.outputAudioDevice if (outputAudioDevice != null && outputAudioDevice.type == AudioDevice.Type.Earpiece) { + Log.i("$TAG Audio device is earpiece, enabling proximity sensor") proximitySensorEnabled.postValue(true) } else { + Log.i("$TAG Audio device is [${outputAudioDevice?.type}], disabling proximity sensor") proximitySensorEnabled.postValue(false) } } } } else { + Log.w("$TAG No current call, disabling proximity sensor") proximitySensorEnabled.postValue(false) } } diff --git a/app/src/main/java/org/linphone/ui/call/viewmodel/SharedCallViewModel.kt b/app/src/main/java/org/linphone/ui/call/viewmodel/SharedCallViewModel.kt index a551b151fb..2124368530 100644 --- a/app/src/main/java/org/linphone/ui/call/viewmodel/SharedCallViewModel.kt +++ b/app/src/main/java/org/linphone/ui/call/viewmodel/SharedCallViewModel.kt @@ -19,6 +19,7 @@ */ package org.linphone.ui.call.viewmodel +import android.content.res.Configuration import androidx.annotation.UiThread import androidx.lifecycle.MutableLiveData import androidx.window.layout.FoldingFeature @@ -35,4 +36,5 @@ class SharedCallViewModel // For moving video preview purposes var videoPreviewX: Float = 0f var videoPreviewY: Float = 0f + var videoPreviewOrientation: Int = Configuration.ORIENTATION_UNDEFINED } diff --git a/app/src/main/java/org/linphone/ui/fileviewer/fragment/MediaViewerFragment.kt b/app/src/main/java/org/linphone/ui/fileviewer/fragment/MediaViewerFragment.kt index d3e8b3dd46..008fee9cab 100644 --- a/app/src/main/java/org/linphone/ui/fileviewer/fragment/MediaViewerFragment.kt +++ b/app/src/main/java/org/linphone/ui/fileviewer/fragment/MediaViewerFragment.kt @@ -26,6 +26,7 @@ import android.view.Surface import android.view.TextureView.SurfaceTextureListener import android.view.View import android.view.ViewGroup +import android.widget.SeekBar import androidx.annotation.UiThread import androidx.lifecycle.ViewModelProvider import org.linphone.core.tools.Log @@ -45,6 +46,24 @@ class MediaViewerFragment : GenericMainFragment() { private lateinit var viewModel: MediaViewModel + private var wasVideoPlayingBeforeTrackingTouch = false + + private val seekBarListener = object : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { + + } + + override fun onStartTrackingTouch(seekBar: SeekBar) { + wasVideoPlayingBeforeTrackingTouch = viewModel.isMediaPlaying.value == true + viewModel.pause() + } + + override fun onStopTrackingTouch(seekBar: SeekBar) { + val newPosition = seekBar.progress + viewModel.seekTo(newPosition, wasVideoPlayingBeforeTrackingTouch) + } + } + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -87,6 +106,8 @@ class MediaViewerFragment : GenericMainFragment() { sharedViewModel.mediaViewerFullScreenMode.value = fullScreenMode } + binding.setSeekBarListener(seekBarListener) + viewModel.videoSizeChangedEvent.observe(viewLifecycleOwner) { it.consume { pair -> val width = pair.first diff --git a/app/src/main/java/org/linphone/ui/fileviewer/viewmodel/FileViewModel.kt b/app/src/main/java/org/linphone/ui/fileviewer/viewmodel/FileViewModel.kt index a623ee8b39..9939ee22a7 100644 --- a/app/src/main/java/org/linphone/ui/fileviewer/viewmodel/FileViewModel.kt +++ b/app/src/main/java/org/linphone/ui/fileviewer/viewmodel/FileViewModel.kt @@ -72,15 +72,15 @@ class FileViewModel val isFromEphemeralMessage = MutableLiveData() val exportPlainTextFileEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val pdfRendererReadyEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val exportPdfEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } // Below are required for PDF viewer @@ -130,8 +130,7 @@ class FileViewModel val extension = FileUtils.getExtensionFromFileName(file) val mime = FileUtils.getMimeTypeFromExtension(extension) mimeType.postValue(mime) - val mimeType = FileUtils.getMimeType(mime) - when (mimeType) { + when (val mimeType = FileUtils.getMimeType(mime)) { FileUtils.MimeType.Pdf -> { Log.d("$TAG File [$file] seems to be a PDF") loadPdf() @@ -274,11 +273,11 @@ class FileViewModel viewModelScope.launch { withContext(Dispatchers.IO) { - val input = ParcelFileDescriptor.open( - File(filePath), - ParcelFileDescriptor.MODE_READ_ONLY - ) try { + val input = ParcelFileDescriptor.open( + File(filePath), + ParcelFileDescriptor.MODE_READ_ONLY + ) pdfRenderer = PdfRenderer(input) val count = pdfRenderer.pageCount Log.i("$TAG $count pages in file $filePath") @@ -291,7 +290,12 @@ class FileViewModel Log.e("$TAG Can't open PDF, probably protected by a password: $se") pdfCurrentPage.postValue("0") pdfPages.postValue("0") - showRedToast(R.string.conversation_pdf_file_cant_be_opened_error_toast, R.drawable.warning_circle) + showRedToast(R.string.conversation_pdf_password_protected_file_cant_be_opened_error_toast, R.drawable.warning_circle) + } catch (e: Exception) { + Log.e("$TAG Can't open PDF, it may be corrupted: $e") + pdfCurrentPage.postValue("0") + pdfPages.postValue("0") + showRedToast(R.string.conversation_pdf_file_error_toast, R.drawable.warning_circle) } } } diff --git a/app/src/main/java/org/linphone/ui/fileviewer/viewmodel/MediaViewModel.kt b/app/src/main/java/org/linphone/ui/fileviewer/viewmodel/MediaViewModel.kt index 39f524b9d9..08607f9128 100644 --- a/app/src/main/java/org/linphone/ui/fileviewer/viewmodel/MediaViewModel.kt +++ b/app/src/main/java/org/linphone/ui/fileviewer/viewmodel/MediaViewModel.kt @@ -65,11 +65,11 @@ class MediaViewModel val position = MutableLiveData() val videoSizeChangedEvent: MutableLiveData>> by lazy { - MutableLiveData>>() + MutableLiveData() } val changeFullScreenModeEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } lateinit var mediaPlayer: MediaPlayer @@ -161,6 +161,16 @@ class MediaViewModel } } + @UiThread + fun seekTo(position: Int, resumePlay: Boolean) { + if (::mediaPlayer.isInitialized) { + mediaPlayer.seekTo(position) + if (resumePlay) { + play() + } + } + } + @UiThread private fun initMediaPlayer() { isMediaPlaying.value = false diff --git a/app/src/main/java/org/linphone/ui/main/MainActivity.kt b/app/src/main/java/org/linphone/ui/main/MainActivity.kt index 9fb51a50c8..90c976603b 100644 --- a/app/src/main/java/org/linphone/ui/main/MainActivity.kt +++ b/app/src/main/java/org/linphone/ui/main/MainActivity.kt @@ -29,7 +29,6 @@ import android.net.Uri import android.os.Bundle import android.os.Parcelable import android.view.Gravity -import android.view.ViewGroup import android.view.ViewTreeObserver import android.view.WindowManager import androidx.activity.SystemBarStyle @@ -67,7 +66,6 @@ import org.linphone.ui.GenericActivity import org.linphone.ui.assistant.AssistantActivity import org.linphone.ui.main.chat.fragment.ConversationsListFragmentDirections import org.linphone.utils.PasswordDialogModel -import org.linphone.ui.main.sso.fragment.SingleSignOnFragmentDirections import org.linphone.ui.main.viewmodel.MainViewModel import org.linphone.ui.main.viewmodel.SharedMainViewModel import org.linphone.ui.welcome.WelcomeActivity @@ -76,6 +74,8 @@ import org.linphone.utils.DialogUtils import org.linphone.utils.Event import org.linphone.utils.FileUtils import org.linphone.utils.LinphoneUtils +import androidx.core.content.edit +import org.linphone.ui.sso.SingleSignOnActivity @UiThread class MainActivity : GenericActivity() { @@ -169,12 +169,7 @@ class MainActivity : GenericActivity() { ViewCompat.setOnApplyWindowInsetsListener(binding.drawerMenuContent) { v, windowInsets -> val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) - val mlp = v.layoutParams as ViewGroup.MarginLayoutParams - mlp.leftMargin = insets.left - mlp.topMargin = insets.top - mlp.rightMargin = insets.right - mlp.bottomMargin = insets.bottom - v.layoutParams = mlp + v.updatePadding(insets.left, insets.top, insets.right, insets.bottom) WindowInsetsCompat.CONSUMED } @@ -288,20 +283,18 @@ class MainActivity : GenericActivity() { } } }) - coreContext.bearerAuthenticationRequestedEvent.observe(this) { it.consume { pair -> val serverUrl = pair.first val username = pair.second Log.i( - "$TAG Navigating to Single Sign On Fragment with server URL [$serverUrl] and username [$username]" - ) - val action = SingleSignOnFragmentDirections.actionGlobalSingleSignOnFragment( - serverUrl, - username + "$TAG Bearer auth request, navigating to Single Sign On Fragment with server URL [$serverUrl] and username [$username]" ) - findNavController().navigate(action) + val intent = Intent(this, SingleSignOnActivity::class.java) + intent.putExtra(SingleSignOnActivity.INTENT_EXTRA_USERNAME, username) + intent.putExtra(SingleSignOnActivity.INTENT_EXTRA_SERVER_URL, serverUrl) + startActivity(intent) } } @@ -378,6 +371,29 @@ class MainActivity : GenericActivity() { val projection = it == CarConnection.CONNECTION_TYPE_PROJECTION coreContext.isConnectedToAndroidAuto = projection } + + coreContext.postOnCoreThread { core -> + if (corePreferences.firstLaunch) { + Log.i("$TAG First time Linphone 6.0 has been started, showing Welcome activity") + corePreferences.firstLaunch = false + coreContext.postOnMainThread { + try { + startActivity(Intent(this, WelcomeActivity::class.java)) + } catch (ise: IllegalStateException) { + Log.e("$TAG Can't start activity: $ise") + } + } + } else if (core.accountList.isEmpty()) { + Log.w("$TAG No account found, showing Assistant activity") + coreContext.postOnMainThread { + try { + startActivity(Intent(this, AssistantActivity::class.java)) + } catch (ise: IllegalStateException) { + Log.e("$TAG Can't start activity: $ise") + } + } + } + } } override fun onPostCreate(savedInstanceState: Bundle?) { @@ -415,9 +431,8 @@ class MainActivity : GenericActivity() { HISTORY_FRAGMENT_ID } } - with(getPreferences(MODE_PRIVATE).edit()) { + getPreferences(MODE_PRIVATE).edit { putInt(DEFAULT_FRAGMENT_KEY, defaultFragmentId) - apply() } Log.i("$TAG Stored [$defaultFragmentId] as default page") @@ -428,9 +443,8 @@ class MainActivity : GenericActivity() { super.onResume() viewModel.enableAccountMonitoring(true) - viewModel.checkForNewAccount() - viewModel.updateNetworkReachability() viewModel.updateMissingPermissionAlert() + viewModel.updateAccountsAndNetworkReachability() } override fun onNewIntent(intent: Intent) { @@ -579,62 +593,41 @@ class MainActivity : GenericActivity() { private fun handleMainIntent(intent: Intent) { coreContext.postOnCoreThread { core -> - if (corePreferences.firstLaunch) { - Log.i("$TAG First time Linphone 6.0 has been started, showing Welcome activity") - corePreferences.firstLaunch = false + if (intent.hasExtra(ARGUMENTS_CHAT)) { + Log.i("$TAG Intent has [Chat] extra") coreContext.postOnMainThread { try { - startActivity(Intent(this, WelcomeActivity::class.java)) - } catch (ise: IllegalStateException) { - Log.e("$TAG Can't start activity: $ise") - } - } - } else if (core.accountList.isEmpty()) { - Log.w("$TAG No account found, showing Assistant activity") - coreContext.postOnMainThread { - try { - startActivity(Intent(this, AssistantActivity::class.java)) - } catch (ise: IllegalStateException) { - Log.e("$TAG Can't start activity: $ise") - } - } - } else { - if (intent.hasExtra(ARGUMENTS_CHAT)) { - Log.i("$TAG Intent has [Chat] extra") - coreContext.postOnMainThread { - try { - Log.i("$TAG Trying to go to Conversations fragment") - val args = intent.extras - val conversationId = args?.getString(ARGUMENTS_CONVERSATION_ID, "") - if (conversationId.isNullOrEmpty()) { - Log.w("$TAG Found [Chat] extra but no conversation ID!") - } else { - Log.i("$TAG Found [Chat] extra with conversation ID [$conversationId]") - sharedViewModel.showConversationEvent.value = Event(conversationId) - } - args?.clear() - - if (findNavController().currentDestination?.id == R.id.conversationsListFragment) { - Log.w( - "$TAG Current destination is already conversations list, skipping navigation" - ) - } else { - val navOptionsBuilder = NavOptions.Builder() - navOptionsBuilder.setPopUpTo( - findNavController().currentDestination?.id ?: R.id.historyListFragment, - true - ) - navOptionsBuilder.setLaunchSingleTop(true) - val navOptions = navOptionsBuilder.build() - findNavController().navigate( - R.id.conversationsListFragment, - args, - navOptions - ) - } - } catch (ise: IllegalStateException) { - Log.e("$TAG Can't navigate to Conversations fragment: $ise") + Log.i("$TAG Trying to go to Conversations fragment") + val args = intent.extras + val conversationId = args?.getString(ARGUMENTS_CONVERSATION_ID, "") + if (conversationId.isNullOrEmpty()) { + Log.w("$TAG Found [Chat] extra but no conversation ID!") + } else { + Log.i("$TAG Found [Chat] extra with conversation ID [$conversationId]") + sharedViewModel.showConversationEvent.value = Event(conversationId) + } + args?.clear() + + if (findNavController().currentDestination?.id == R.id.conversationsListFragment) { + Log.w( + "$TAG Current destination is already conversations list, skipping navigation" + ) + } else { + val navOptionsBuilder = NavOptions.Builder() + navOptionsBuilder.setPopUpTo( + findNavController().currentDestination?.id ?: R.id.historyListFragment, + true + ) + navOptionsBuilder.setLaunchSingleTop(true) + val navOptions = navOptionsBuilder.build() + findNavController().navigate( + R.id.conversationsListFragment, + args, + navOptions + ) } + } catch (ise: IllegalStateException) { + Log.e("$TAG Can't navigate to Conversations fragment: $ise") } } } @@ -643,15 +636,6 @@ class MainActivity : GenericActivity() { private fun handleSendIntent(intent: Intent, multiple: Boolean) { val parcelablesUri = arrayListOf() - - if (intent.type == "text/plain") { - Log.i("$TAG Intent type is [${intent.type}], expecting text in Intent.EXTRA_TEXT") - intent.getStringExtra(Intent.EXTRA_TEXT)?.let { extraText -> - Log.i("$TAG Found extra text in intent, long of [${extraText.length}]") - sharedViewModel.textToShareFromIntent.value = extraText - } - } - if (multiple) { val parcelables = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM) @@ -699,11 +683,23 @@ class MainActivity : GenericActivity() { if (path != null) list.add(path) } + var textToShare = "" + if (intent.type == "text/plain") { + Log.i("$TAG Intent type is [${intent.type}], expecting text in Intent.EXTRA_TEXT") + textToShare = intent.getStringExtra(Intent.EXTRA_TEXT).orEmpty() + if (textToShare.isEmpty()) { + Log.e("$TAG Intent.EXTRA_TEXT not found in intent!") + } else { + Log.i("$TAG Found extra text in intent, long of [${textToShare.length}]") + } + } + if (list.isNotEmpty()) { - sharedViewModel.filesToShareFromIntent.value = list + sharedViewModel.filesToShareFromIntent.postValue(list) } else { - if (sharedViewModel.textToShareFromIntent.value.orEmpty().isNotEmpty()) { + if (textToShare.isNotEmpty()) { Log.i("$TAG Found plain text to share") + sharedViewModel.textToShareFromIntent.postValue(textToShare) } else { Log.w("$TAG Failed to find at least one file or text to share!") } @@ -718,7 +714,7 @@ class MainActivity : GenericActivity() { Log.i( "$TAG Navigating from debug to conversation with ID [$conversationId], computed from shortcut ID" ) - sharedViewModel.showConversationEvent.value = Event(conversationId) + sharedViewModel.showConversationEvent.postValue(Event(conversationId)) } val action = ConversationsListFragmentDirections.actionGlobalConversationsListFragment() @@ -734,7 +730,7 @@ class MainActivity : GenericActivity() { Log.i( "$TAG Navigating to conversation with conversation ID [$conversationId] addresses, computed from shortcut ID" ) - sharedViewModel.showConversationEvent.value = Event(conversationId) + sharedViewModel.showConversationEvent.postValue(Event(conversationId)) } if (findNavController().currentDestination?.id == R.id.conversationsListFragment) { @@ -785,7 +781,8 @@ class MainActivity : GenericActivity() { ) Log.i("$TAG Interpreted SIP URI is [${address?.asStringUriOnly()}]") if (address != null) { - coreContext.startAudioCall(address) + // Skip network reachability test, this code will be called too soon + coreContext.startAudioCall(address, skipNetworkReachabilityTest = true) } } } diff --git a/app/src/main/java/org/linphone/ui/main/adapter/ConversationsContactsAndSuggestionsListAdapter.kt b/app/src/main/java/org/linphone/ui/main/adapter/ConversationsContactsAndSuggestionsListAdapter.kt index 356fb10bbb..628c6debde 100644 --- a/app/src/main/java/org/linphone/ui/main/adapter/ConversationsContactsAndSuggestionsListAdapter.kt +++ b/app/src/main/java/org/linphone/ui/main/adapter/ConversationsContactsAndSuggestionsListAdapter.kt @@ -53,7 +53,7 @@ class ConversationsContactsAndSuggestionsListAdapter : } val onClickedEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } override fun displayHeaderForPosition(position: Int): Boolean { @@ -161,7 +161,7 @@ class ConversationsContactsAndSuggestionsListAdapter : } } - inner class ConversationViewHolder( + class ConversationViewHolder( val binding: GenericAddressPickerConversationListCellBinding ) : RecyclerView.ViewHolder(binding.root) { @UiThread @@ -198,7 +198,7 @@ class ConversationsContactsAndSuggestionsListAdapter : } } - inner class SuggestionViewHolder( + class SuggestionViewHolder( val binding: GenericAddressPickerSuggestionListCellBinding ) : RecyclerView.ViewHolder(binding.root) { @UiThread diff --git a/app/src/main/java/org/linphone/ui/main/chat/ConversationScrollListener.kt b/app/src/main/java/org/linphone/ui/main/chat/RecyclerViewScrollListener.kt similarity index 65% rename from app/src/main/java/org/linphone/ui/main/chat/ConversationScrollListener.kt rename to app/src/main/java/org/linphone/ui/main/chat/RecyclerViewScrollListener.kt index edf0b60973..bc7a8f8832 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/ConversationScrollListener.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/RecyclerViewScrollListener.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2010-2020 Belledonne Communications SARL. + * Copyright (c) 2010-2025 Belledonne Communications SARL. * * This file is part of linphone-android * (see https://www.linphone.org). @@ -21,13 +21,12 @@ package org.linphone.ui.main.chat import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import org.linphone.core.tools.Log -internal abstract class ConversationScrollListener(private val mLayoutManager: LinearLayoutManager) : +internal abstract class RecyclerViewScrollListener(private val layoutManager: LinearLayoutManager, private val visibleThreshold: Int, private val scrollingTopToBottom: Boolean) : RecyclerView.OnScrollListener() { companion object { - // The minimum amount of items to have below your current scroll position - // before loading more. - private const val VISIBLE_THRESHOLD = 5 + private const val TAG = "[RecyclerView Scroll Listener]" } // The total number of items in the data set after the last load @@ -40,9 +39,9 @@ internal abstract class ConversationScrollListener(private val mLayoutManager: L // We are given a few useful parameters to help us work out if we need to load some more data, // but first we check if we are waiting for the previous load to finish. override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) { - val totalItemCount = mLayoutManager.itemCount - val firstVisibleItemPosition: Int = mLayoutManager.findFirstVisibleItemPosition() - val lastVisibleItemPosition: Int = mLayoutManager.findLastVisibleItemPosition() + val totalItemCount = layoutManager.itemCount + val firstVisibleItemPosition: Int = layoutManager.findFirstVisibleItemPosition() + val lastVisibleItemPosition: Int = layoutManager.findLastVisibleItemPosition() // If the total item count is zero and the previous isn't, assume the // list is invalidated and should be reset back to initial state @@ -64,21 +63,34 @@ internal abstract class ConversationScrollListener(private val mLayoutManager: L val userHasScrolledUp = lastVisibleItemPosition != totalItemCount - 1 if (userHasScrolledUp) { onScrolledUp() + Log.d("$TAG Scrolled up") } else { onScrolledToEnd() + Log.d("$TAG Scrolled to end") } // If it isn’t currently loading, we check to see if we have breached - // the mVisibleThreshold and need to reload more data. + // the visibleThreshold and need to reload more data. // If we do need to reload some more data, we execute onLoadMore to fetch the data. // threshold should reflect how many total columns there are too - if (!loading && - firstVisibleItemPosition < VISIBLE_THRESHOLD && - firstVisibleItemPosition >= 0 && - lastVisibleItemPosition < totalItemCount - VISIBLE_THRESHOLD - ) { - onLoadMore(totalItemCount) - loading = true + if (!loading) { + if (scrollingTopToBottom) { + if (lastVisibleItemPosition >= totalItemCount - visibleThreshold) { + Log.d( + "$TAG Last visible item position [$lastVisibleItemPosition] reached [${totalItemCount - visibleThreshold}], loading more (current total items is [$totalItemCount])" + ) + loading = true + onLoadMore(totalItemCount) + } + } else { + if (firstVisibleItemPosition < visibleThreshold) { + Log.d( + "$TAG First visible item position [$firstVisibleItemPosition] < visibleThreshold [$visibleThreshold], loading more (current total items is [$totalItemCount])" + ) + loading = true + onLoadMore(totalItemCount) + } + } } } diff --git a/app/src/main/java/org/linphone/ui/main/chat/adapter/ConversationEventAdapter.kt b/app/src/main/java/org/linphone/ui/main/chat/adapter/ConversationEventAdapter.kt index f53551ec78..830b18be11 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/adapter/ConversationEventAdapter.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/adapter/ConversationEventAdapter.kt @@ -36,6 +36,7 @@ import org.linphone.databinding.ChatBubbleIncomingBinding import org.linphone.databinding.ChatBubbleOutgoingBinding import org.linphone.databinding.ChatConversationEventBinding import org.linphone.databinding.ChatConversationE2eEncryptedFirstEventBinding +import org.linphone.databinding.ChatConversationUnsafeFirstEventBinding import org.linphone.ui.main.chat.model.EventLogModel import org.linphone.ui.main.chat.model.EventModel import org.linphone.ui.main.chat.model.MessageModel @@ -59,15 +60,15 @@ class ConversationEventAdapter : val chatMessageLongPressEvent = MutableLiveData>() val showDeliveryForChatMessageModelEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val showReactionForChatMessageModelEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val scrollToRepliedMessageEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } private var isConversationSecured: Boolean = false @@ -82,7 +83,11 @@ class ConversationEventAdapter : } override fun getHeaderViewForPosition(context: Context, position: Int): View { - val binding = ChatConversationE2eEncryptedFirstEventBinding.inflate(LayoutInflater.from(context)) + val binding = if (isConversationSecured) { + ChatConversationE2eEncryptedFirstEventBinding.inflate(LayoutInflater.from(context)) + } else { + ChatConversationUnsafeFirstEventBinding.inflate(LayoutInflater.from(context)) + } return binding.root } @@ -197,7 +202,7 @@ class ConversationEventAdapter : } } - inner class IncomingBubbleViewHolder( + class IncomingBubbleViewHolder( val binding: ChatBubbleIncomingBinding ) : RecyclerView.ViewHolder(binding.root) { fun bind(message: MessageModel) { @@ -212,7 +217,7 @@ class ConversationEventAdapter : } } - inner class OutgoingBubbleViewHolder( + class OutgoingBubbleViewHolder( val binding: ChatBubbleOutgoingBinding ) : RecyclerView.ViewHolder(binding.root) { fun bind(message: MessageModel) { @@ -227,7 +232,7 @@ class ConversationEventAdapter : } } - inner class EventViewHolder( + class EventViewHolder( val binding: ChatConversationEventBinding ) : RecyclerView.ViewHolder(binding.root) { fun bind(event: EventModel) { diff --git a/app/src/main/java/org/linphone/ui/main/chat/adapter/ConversationParticipantsAdapter.kt b/app/src/main/java/org/linphone/ui/main/chat/adapter/ConversationParticipantsAdapter.kt index f72af30acd..95cca4bd31 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/adapter/ConversationParticipantsAdapter.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/adapter/ConversationParticipantsAdapter.kt @@ -50,7 +50,7 @@ class ConversationParticipantsAdapter : ListAdapter( +class ConversationsListAdapter : + ListAdapter( ChatRoomDiffCallback() -) { +), + HeaderAdapter { + companion object { + private const val CONVERSATION_TYPE = 0 + private const val CONTACT_TYPE = 1 + private const val SUGGESTION_TYPE = 2 + } + var selectedAdapterPosition = -1 val conversationClickedEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val conversationLongClickedEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - val binding: ChatListCellBinding = DataBindingUtil.inflate( - LayoutInflater.from(parent.context), - R.layout.chat_list_cell, - parent, - false + val createConversationWithFriendClickedEvent: MutableLiveData> by lazy { + MutableLiveData() + } + + val createConversationWithAddressClickedEvent: MutableLiveData> by lazy { + MutableLiveData() + } + + override fun displayHeaderForPosition(position: Int): Boolean { + // Don't show header for call history section + if (position == 0 && getItemViewType(0) == CONVERSATION_TYPE) { + return false + } + + return getItemViewType(position) != getItemViewType(position - 1) + } + + override fun getHeaderViewForPosition( + context: Context, + position: Int + ): View { + val binding = GenericAddressPickerListDecorationBinding.inflate( + LayoutInflater.from(context) ) - val viewHolder = ViewHolder(binding) - binding.apply { - lifecycleOwner = parent.findViewTreeLifecycleOwner() + binding.header.text = when (getItemViewType(position)) { + SUGGESTION_TYPE -> { + AppUtils.getString(R.string.generic_address_picker_suggestions_list_title) + } + else -> { + AppUtils.getString(R.string.generic_address_picker_contacts_list_title) + } + } + return binding.root + } - setOnClickListener { - conversationClickedEvent.value = Event(model!!) + override fun getItemViewType(position: Int): Int { + try { + val model = getItem(position) + return if (model.isConversation) { + CONVERSATION_TYPE + } else if (model.contactModel?.friend != null) { + CONTACT_TYPE + } else { + SUGGESTION_TYPE } + } catch (ioobe: IndexOutOfBoundsException) { - setOnLongClickListener { - selectedAdapterPosition = viewHolder.bindingAdapterPosition - root.isSelected = true - conversationLongClickedEvent.value = Event(model!!) - true + } + return CONVERSATION_TYPE + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return when (viewType) { + CONVERSATION_TYPE -> { + val binding: ChatListCellBinding = DataBindingUtil.inflate( + LayoutInflater.from(parent.context), + R.layout.chat_list_cell, + parent, + false + ) + val viewHolder = ConversationViewHolder(binding) + binding.apply { + lifecycleOwner = parent.findViewTreeLifecycleOwner() + + setOnClickListener { + conversationClickedEvent.value = Event(model!!) + } + + setOnLongClickListener { + selectedAdapterPosition = viewHolder.bindingAdapterPosition + root.isSelected = true + conversationLongClickedEvent.value = Event(model!!) + true + } + } + viewHolder + } + else -> { + val binding: ChatListContactSuggestionCellBinding = DataBindingUtil.inflate( + LayoutInflater.from(parent.context), + R.layout.chat_list_contact_suggestion_cell, + parent, + false + ) + binding.apply { + lifecycleOwner = parent.findViewTreeLifecycleOwner() + + setOnCreateConversationClickListener { + val friend = model?.friend + if (friend != null) { + createConversationWithFriendClickedEvent.value = Event(friend) + } else { + createConversationWithAddressClickedEvent.value = Event(model!!.address) + } + } + } + ContactSuggestionViewHolder(binding) } } - return viewHolder } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - (holder as ViewHolder).bind(getItem(position)) + when (getItemViewType(position)) { + CONVERSATION_TYPE -> (holder as ConversationViewHolder).bind(getItem(position).conversationModel!!) + else -> (holder as ContactSuggestionViewHolder).bind(getItem(position).contactModel!!) + } } fun resetSelection() { @@ -82,7 +179,7 @@ class ConversationsListAdapter : ListAdapter() { - override fun areItemsTheSame(oldItem: ConversationModel, newItem: ConversationModel): Boolean { - return oldItem.id == newItem.id && oldItem.lastUpdateTime == newItem.lastUpdateTime + class ContactSuggestionViewHolder( + val binding: ChatListContactSuggestionCellBinding + ) : RecyclerView.ViewHolder(binding.root) { + @UiThread + fun bind(conversationContactOrSuggestionModel: ConversationContactOrSuggestionModel) { + with(binding) { + model = conversationContactOrSuggestionModel + + executePendingBindings() + } + } + } + + private class ChatRoomDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: ConversationModelWrapper, newItem: ConversationModelWrapper): Boolean { + if (oldItem.isConversation && newItem.isConversation) { + return oldItem.conversationModel?.id == newItem.conversationModel?.id && oldItem.conversationModel?.lastUpdateTime == newItem.conversationModel?.lastUpdateTime + } else if (oldItem.isContactOrSuggestion && newItem.isContactOrSuggestion) { + return oldItem.contactModel?.id == newItem.contactModel?.id + } + return false } - override fun areContentsTheSame(oldItem: ConversationModel, newItem: ConversationModel): Boolean { - return oldItem.avatarModel.value?.id == newItem.avatarModel.value?.id + override fun areContentsTheSame(oldItem: ConversationModelWrapper, newItem: ConversationModelWrapper): Boolean { + if (oldItem.isConversation && newItem.isConversation) { + return newItem.conversationModel?.avatarModel?.value?.id == oldItem.conversationModel?.avatarModel?.value?.id + } + return false } } } diff --git a/app/src/main/java/org/linphone/ui/main/chat/adapter/MessageBottomSheetAdapter.kt b/app/src/main/java/org/linphone/ui/main/chat/adapter/MessageBottomSheetAdapter.kt index 95c0130459..b2a6f3a74c 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/adapter/MessageBottomSheetAdapter.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/adapter/MessageBottomSheetAdapter.kt @@ -50,7 +50,7 @@ class MessageBottomSheetAdapter : ListAdapter) { + private fun showNumberOrAddressPickerDialog(list: List) { val numberOrAddressModel = NumberOrAddressPickerDialogModel(list) val dialog = DialogUtils.getNumberOrAddressPickerDialog( diff --git a/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationFragment.kt b/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationFragment.kt index b4a05af0cf..5ab8ef8401 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationFragment.kt @@ -69,7 +69,7 @@ import org.linphone.core.tools.Log import org.linphone.databinding.ChatConversationFragmentBinding import org.linphone.databinding.ChatConversationPopupMenuBinding import org.linphone.ui.GenericActivity -import org.linphone.ui.main.chat.ConversationScrollListener +import org.linphone.ui.main.chat.RecyclerViewScrollListener import org.linphone.ui.main.chat.adapter.ConversationEventAdapter import org.linphone.ui.main.chat.adapter.MessageBottomSheetAdapter import org.linphone.ui.main.chat.model.FileModel @@ -94,6 +94,9 @@ import org.linphone.utils.hideKeyboard import org.linphone.utils.setKeyboardInsetListener import org.linphone.utils.showKeyboard import androidx.core.net.toUri +import org.linphone.ui.main.chat.adapter.ConversationParticipantsAdapter +import org.linphone.utils.ShortcutUtils +import kotlin.collections.arrayListOf @UiThread open class ConversationFragment : SlidingPaneChildFragment() { @@ -113,6 +116,8 @@ open class ConversationFragment : SlidingPaneChildFragment() { private lateinit var adapter: ConversationEventAdapter + private lateinit var participantsAdapter: ConversationParticipantsAdapter + private lateinit var bottomSheetAdapter: MessageBottomSheetAdapter private val args: ConversationFragmentArgs by navArgs() @@ -280,26 +285,33 @@ open class ConversationFragment : SlidingPaneChildFragment() { override fun onTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) { } - override fun afterTextChanged(p0: Editable?) { + override fun afterTextChanged(editable: Editable?) { if (viewModel.isGroup.value == true) { - sendMessageViewModel.closeParticipantsList() + val split = editable.toString().split(" ") + if (split.isNotEmpty()) { + val lastPart = split.last() + if (lastPart.isNotEmpty() && lastPart.startsWith("@")) { + coreContext.postOnCoreThread { + val filter = if (lastPart.length > 1) lastPart.substring(1) else "" + sendMessageViewModel.filterParticipantsList(filter) + } - val split = p0.toString().split(" ") - for (part in split) { - if (part == "@") { - Log.i("$TAG '@' found, opening participants list") - sendMessageViewModel.openParticipantsList() + if (sendMessageViewModel.isParticipantsListOpen.value == false) { + Log.i("$TAG '@' found, opening participants list") + sendMessageViewModel.openParticipantsList() + } + } else if (sendMessageViewModel.isParticipantsListOpen.value == true) { + Log.i("$TAG Closing participants list") + sendMessageViewModel.closeParticipantsList() } } } - if (p0.toString().isNotEmpty()) { - sendMessageViewModel.notifyChatMessageIsBeingComposed() - } + sendMessageViewModel.notifyComposing(editable.toString().isNotEmpty()) } } - private lateinit var scrollListener: ConversationScrollListener + private lateinit var scrollListener: RecyclerViewScrollListener private lateinit var headerItemDecoration: RecyclerViewHeaderDecoration @@ -315,6 +327,8 @@ open class ConversationFragment : SlidingPaneChildFragment() { if (e.y >= 0 && e.y <= headerItemDecoration.getDecorationHeight(0)) { if (viewModel.isEndToEndEncrypted.value == true) { showEndToEndEncryptionDetailsBottomSheet() + } else { + showUnsafeConversationDisabledDetailsBottomSheet() } return true } @@ -385,6 +399,7 @@ open class ConversationFragment : SlidingPaneChildFragment() { super.onCreate(savedInstanceState) adapter = ConversationEventAdapter() + participantsAdapter = ConversationParticipantsAdapter() headerItemDecoration = RecyclerViewHeaderDecoration( requireContext(), adapter, @@ -456,6 +471,10 @@ open class ConversationFragment : SlidingPaneChildFragment() { layoutManager.stackFromEnd = true binding.eventsList.layoutManager = layoutManager + binding.sendArea.participants.participantsList.setHasFixedSize(true) + val participantsLayoutManager = LinearLayoutManager(requireContext()) + binding.sendArea.participants.participantsList.layoutManager = participantsLayoutManager + val callbacks = RecyclerViewSwipeUtilsCallback( R.drawable.reply, ConversationEventAdapter.EventViewHolder::class.java @@ -473,9 +492,14 @@ open class ConversationFragment : SlidingPaneChildFragment() { val chatMessageEventLog = adapter.currentList[index] val chatMessageModel = (chatMessageEventLog.model as? MessageModel) if (chatMessageModel != null) { - sendMessageViewModel.replyToMessage(chatMessageModel) - // Open keyboard & focus edit text - binding.sendArea.messageToSend.showKeyboard() + if (chatMessageModel.hasBeenRetracted.value == true) { // Don't allow to reply to retracted messages + // TODO: notify user? + } else { + viewModel.closeSearchBar() + sendMessageViewModel.replyToMessage(chatMessageModel) + // Open keyboard & focus edit text + binding.sendArea.messageToSend.showKeyboard() + } } else { Log.e( "$TAG Can't reply, failed to get a ChatMessageModel from adapter item #[$index]" @@ -505,6 +529,7 @@ open class ConversationFragment : SlidingPaneChildFragment() { } } else { sharedViewModel.displayedChatRoom = viewModel.chatRoom + ShortcutUtils.reportChatRoomShortcutHasBeenUsed(requireContext(), viewModel.conversationId) sendMessageViewModel.configureChatRoom(viewModel.chatRoom) adapter.setIsConversationSecured(viewModel.isEndToEndEncrypted.value == true) @@ -523,6 +548,26 @@ open class ConversationFragment : SlidingPaneChildFragment() { } } } + + sharedViewModel.textToShareFromIntent.observe(viewLifecycleOwner) { text -> + if (text.isNotEmpty() && sharedViewModel.displayedChatRoom != null) { + Log.i("$TAG Found text to share from intent") + sendMessageViewModel.textToSend.value = text + + sharedViewModel.textToShareFromIntent.value = "" + } + } + + sharedViewModel.filesToShareFromIntent.observe(viewLifecycleOwner) { files -> + if (files.isNotEmpty() && sharedViewModel.displayedChatRoom != null) { + Log.i("$TAG Found [${files.size}] files to share from intent") + for (path in files) { + sendMessageViewModel.addAttachments(arrayListOf(path)) + } + + sharedViewModel.filesToShareFromIntent.value = arrayListOf() + } + } } } } @@ -590,8 +635,7 @@ open class ConversationFragment : SlidingPaneChildFragment() { viewModel.isEndToEndEncrypted.observe(viewLifecycleOwner) { encrypted -> adapter.setIsConversationSecured(encrypted) - - if (encrypted) { + if (encrypted || (!encrypted && viewModel.isEndToEndEncryptionAvailable.value == true)) { binding.eventsList.addItemDecoration(headerItemDecoration) binding.eventsList.addOnItemTouchListener(listItemTouchListener) } @@ -649,14 +693,22 @@ open class ConversationFragment : SlidingPaneChildFragment() { binding.setOpenFilePickerClickListener { Log.i("$TAG Opening file picker") - pickDocument.launch(arrayOf("*/*")) + try { + pickDocument.launch(arrayOf("*/*")) + } catch (anfe: ActivityNotFoundException) { + Log.e("$TAG Failed to start file picker: $anfe") + } } binding.setOpenMediaPickerClickListener { Log.i("$TAG Opening media picker") - pickMedia.launch( - PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo) - ) + try { + pickMedia.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo) + ) + } catch (anfe: ActivityNotFoundException) { + Log.e("$TAG Failed to start media picker: $anfe") + } } binding.setOpenCameraClickListener { @@ -718,6 +770,12 @@ open class ConversationFragment : SlidingPaneChildFragment() { false } + sendMessageViewModel.messageSentEvent.observe(viewLifecycleOwner) { + it.consume { message -> + viewModel.addSentMessageToEventsList(message) + } + } + sendMessageViewModel.emojiToAddEvent.observe(viewLifecycleOwner) { it.consume { emoji -> binding.sendArea.messageToSend.addCharacterAtPosition(emoji) @@ -751,6 +809,14 @@ open class ConversationFragment : SlidingPaneChildFragment() { } } + sendMessageViewModel.participants.observe(viewLifecycleOwner) { + participantsAdapter.submitList(it) + + if (binding.sendArea.participants.participantsList.adapter != participantsAdapter) { + binding.sendArea.participants.participantsList.adapter = participantsAdapter + } + } + viewModel.focusSearchBarEvent.observe(viewLifecycleOwner) { it.consume { show -> if (show) { @@ -847,7 +913,7 @@ open class ConversationFragment : SlidingPaneChildFragment() { "$TAG Scrolling to message/event at position [$position], " + "display show events between positions [$firstDisplayedItemPosition] and [$lastDisplayedItemPosition]" ) - if (firstDisplayedItemPosition > position && position > 0) { + if (position in 1.. - if (text.isNotEmpty()) { - Log.i("$TAG Found text to share from intent") - sendMessageViewModel.textToSend.value = text - - sharedViewModel.textToShareFromIntent.value = "" - } - } - - sharedViewModel.filesToShareFromIntent.observe(viewLifecycleOwner) { files -> - if (files.isNotEmpty()) { - Log.i("$TAG Found [${files.size}] files to share from intent") - for (path in files) { - sendMessageViewModel.addAttachments(arrayListOf(path)) - } - - sharedViewModel.filesToShareFromIntent.value = arrayListOf() - } - } - sharedViewModel.forceRefreshConversationInfoEvent.observe(viewLifecycleOwner) { it.consume { Log.i("$TAG Force refreshing conversation info") @@ -988,7 +1062,7 @@ open class ConversationFragment : SlidingPaneChildFragment() { binding.sendArea.messageToSend.addTextChangedListener(textObserver) - scrollListener = object : ConversationScrollListener(layoutManager) { + scrollListener = object : RecyclerViewScrollListener(layoutManager, 5, false) { @UiThread override fun onLoadMore(totalItemsCount: Int) { if (viewModel.searchInProgress.value == false) { @@ -1363,49 +1437,59 @@ open class ConversationFragment : SlidingPaneChildFragment() { private fun displayDeliveryStatuses(model: MessageDeliveryModel) { val tabs = binding.messageBottomSheet.tabs tabs.removeAllTabs() - tabs.addTab( - tabs.newTab().setText(model.readLabel.value).setId( - ChatMessage.State.Displayed.toInt() - ) + + val displayedTab = tabs.newTab().setText(model.readLabel.value).setId( + ChatMessage.State.Displayed.toInt() ) - tabs.addTab( - tabs.newTab().setText( - model.receivedLabel.value - ).setId( - ChatMessage.State.DeliveredToUser.toInt() - ) + val deliveredTab = tabs.newTab().setText(model.receivedLabel.value).setId( + ChatMessage.State.DeliveredToUser.toInt() ) - tabs.addTab( - tabs.newTab().setText(model.sentLabel.value).setId( - ChatMessage.State.Delivered.toInt() - ) + val sentTab = tabs.newTab().setText(model.sentLabel.value).setId( + ChatMessage.State.Delivered.toInt() ) - tabs.addTab( - tabs.newTab().setText( - model.errorLabel.value - ).setId( - ChatMessage.State.NotDelivered.toInt() - ) + val errorTab = tabs.newTab().setText(model.errorLabel.value).setId( + ChatMessage.State.NotDelivered.toInt() ) + // Tabs must be added first otherwise select() will do nothing + tabs.addTab(displayedTab) + tabs.addTab(deliveredTab) + tabs.addTab(sentTab) + tabs.addTab(errorTab) + + if (model.displayedModels.isNotEmpty()) { + bottomSheetAdapter.submitList(model.displayedModels) + displayedTab.select() + } else { + if (model.deliveredModels.isNotEmpty()) { + bottomSheetAdapter.submitList(model.deliveredModels) + deliveredTab.select() + } else { + if (model.sentModels.isNotEmpty()) { + bottomSheetAdapter.submitList(model.sentModels) + sentTab.select() + } else { + if (model.errorModels.isNotEmpty()) { + bottomSheetAdapter.submitList(model.errorModels) + errorTab.select() + } else { + // TODO FIXME: remove all tabs and show error message? + } + } + } + } tabs.setOnTabSelectedListener(object : OnTabSelectedListener { override fun onTabSelected(tab: TabLayout.Tab?) { val state = tab?.id ?: ChatMessage.State.Displayed.toInt() bottomSheetAdapter.submitList( - model.computeListForState(ChatMessage.State.fromInt(state)) + model.getListForState(ChatMessage.State.fromInt(state)) ) } - override fun onTabUnselected(tab: TabLayout.Tab?) { - } + override fun onTabUnselected(tab: TabLayout.Tab?) { } - override fun onTabReselected(tab: TabLayout.Tab?) { - } + override fun onTabReselected(tab: TabLayout.Tab?) { } }) - - val initialList = model.displayedModels - bottomSheetAdapter.submitList(initialList) - Log.i("$TAG Submitted [${initialList.size}] items for default delivery status list") } @UiThread @@ -1486,7 +1570,7 @@ open class ConversationFragment : SlidingPaneChildFragment() { } } - model.cancelEvent.observe(viewLifecycleOwner) { + model.alternativeChoiceEvent.observe(viewLifecycleOwner) { it.consume { openFileInAnotherApp(path, mime, bundle) dialog.dismiss() @@ -1554,12 +1638,6 @@ open class ConversationFragment : SlidingPaneChildFragment() { } } - model.cancelEvent.observe(viewLifecycleOwner) { - it.consume { - dialog.dismiss() - } - } - model.confirmEvent.observe(viewLifecycleOwner) { it.consume { sharedViewModel.displayFileEvent.value = Event(bundle) @@ -1586,4 +1664,26 @@ open class ConversationFragment : SlidingPaneChildFragment() { Log.e("$TAG No activity found to handle intent ACTION_CREATE_DOCUMENT: $exception") } } + + private fun showHowToDeleteMessageMenu(model: MessageModel) { + val canBeRetracted = messageLongPressViewModel.canBeRemotelyDeleted.value == true + val modalBottomSheet = MessageDialogFragment( + canBeRetracted, + { // onDismiss + messageLongPressViewModel.dismiss() + }, + { // onMarkConversationAsRead + Log.i("$TAG Deleting chat message locally") + viewModel.deleteChatMessage(model) + messageLongPressViewModel.dismiss() + }, + { // onToggleMute + Log.i("$TAG Deleting chat message (content) for everyone") + viewModel.deleteChatMessageForEveryone(model) + messageLongPressViewModel.dismiss() + } + ) + modalBottomSheet.show(parentFragmentManager, MessageDialogFragment.TAG) + bottomSheetDialog = modalBottomSheet + } } diff --git a/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationInfoFragment.kt b/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationInfoFragment.kt index e804acabd5..48764ecec3 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationInfoFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationInfoFragment.kt @@ -19,9 +19,6 @@ */ package org.linphone.ui.main.chat.fragment -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context import android.os.Bundle import android.view.Gravity import android.view.LayoutInflater @@ -34,6 +31,7 @@ import androidx.lifecycle.ViewModelProvider import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import androidx.recyclerview.widget.LinearLayoutManager +import org.linphone.LinphoneApplication.Companion.corePreferences import org.linphone.R import org.linphone.core.tools.Log import org.linphone.databinding.ChatInfoFragmentBinding @@ -45,6 +43,7 @@ import org.linphone.ui.main.chat.viewmodel.ConversationInfoViewModel import org.linphone.ui.main.fragment.SlidingPaneChildFragment import org.linphone.utils.ConfirmationDialogModel import org.linphone.ui.main.model.GroupSetOrEditSubjectDialogModel +import org.linphone.utils.AppUtils import org.linphone.utils.DialogUtils import org.linphone.utils.Event @@ -315,6 +314,10 @@ class ConversationInfoFragment : SlidingPaneChildFragment() { } } + binding.setLeaveGroupClickListener { + showLeaveConfirmationDialog() + } + binding.setDeleteHistoryClickListener { showDeleteHistoryConfirmationDialog() } @@ -367,10 +370,10 @@ class ConversationInfoFragment : SlidingPaneChildFragment() { popupView.isMeAdmin = participantModel.isMyselfAdmin val friendRefKey = participantModel.refKey popupView.isParticipantContact = participantModel.friendAvailable + popupView.disableAddContact = corePreferences.disableAddContact popupView.setRemoveParticipantClickListener { - Log.i("$TAG Trying to remove participant [$address]") - viewModel.removeParticipant(participantModel) + showConfirmParticipantRemovalPopup(participantModel) popupWindow.dismiss() } @@ -426,14 +429,13 @@ class ConversationInfoFragment : SlidingPaneChildFragment() { popupView.setCopySipUriClickListener { val sipUri = participantModel.sipUri - val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - clipboard.setPrimaryClip(ClipData.newPlainText("SIP address", sipUri)) - - val message = getString(R.string.sip_address_copied_to_clipboard_toast) - (requireActivity() as GenericActivity).showGreenToast( - message, - R.drawable.check - ) + if (AppUtils.copyToClipboard(requireContext(), "SIP address", sipUri)) { + val message = getString(R.string.sip_address_copied_to_clipboard_toast) + (requireActivity() as GenericActivity).showGreenToast( + message, + R.drawable.check + ) + } } // Elevation is for showing a shadow around the popup @@ -487,13 +489,59 @@ class ConversationInfoFragment : SlidingPaneChildFragment() { dialog.show() } - private fun copyAddressToClipboard(value: String) { - val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - clipboard.setPrimaryClip(ClipData.newPlainText("SIP address", value)) - val message = getString(R.string.sip_address_copied_to_clipboard_toast) - (requireActivity() as GenericActivity).showGreenToast( - message, - R.drawable.check + private fun showLeaveConfirmationDialog() { + val dialogModel = ConfirmationDialogModel() + val dialog = DialogUtils.getLeaveConversationConfirmationDialog( + requireActivity(), + dialogModel ) + + dialogModel.dismissEvent.observe(viewLifecycleOwner) { + it.consume { + dialog.dismiss() + } + } + + dialogModel.confirmEvent.observe(viewLifecycleOwner) { + it.consume { + + Log.i("$TAG Leaving group conversation") + viewModel.leaveGroup() + dialog.dismiss() + } + } + + dialog.show() + } + + private fun showConfirmParticipantRemovalPopup(participantModel: ParticipantModel) { + val dialogModel = ConfirmationDialogModel() + val dialog = DialogUtils.getConfirmRemoveParticipantDialog( + requireActivity(), + dialogModel + ) + + dialogModel.dismissEvent.observe(viewLifecycleOwner) { + it.consume { + dialog.dismiss() + } + } + + dialogModel.confirmEvent.observe(viewLifecycleOwner) { + it.consume { + Log.i("$TAG Trying to remove participant [${participantModel.sipUri}]") + viewModel.removeParticipant(participantModel) + dialog.dismiss() + } + } + + dialog.show() + } + + private fun copyAddressToClipboard(value: String) { + if (AppUtils.copyToClipboard(requireContext(), "SIP address", value)) { + val message = getString(R.string.sip_address_copied_to_clipboard_toast) + (requireActivity() as GenericActivity).showGreenToast(message, R.drawable.check) + } } } diff --git a/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationMediaListFragment.kt b/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationMediaListFragment.kt index 76a9c5e065..00e35abc42 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationMediaListFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationMediaListFragment.kt @@ -36,6 +36,7 @@ import org.linphone.R import org.linphone.core.tools.Log import org.linphone.databinding.ChatMediaFragmentBinding import org.linphone.ui.GenericActivity +import org.linphone.ui.main.chat.RecyclerViewScrollListener import org.linphone.ui.main.chat.adapter.ConversationsFilesAdapter import org.linphone.ui.main.chat.model.FileModel import org.linphone.ui.main.chat.viewmodel.ConversationMediaListViewModel @@ -58,6 +59,8 @@ class ConversationMediaListFragment : SlidingPaneChildFragment() { private val args: ConversationMediaListFragmentArgs by navArgs() + private lateinit var scrollListener: RecyclerViewScrollListener + override fun goBack(): Boolean { try { return findNavController().popBackStack() @@ -103,7 +106,7 @@ class ConversationMediaListFragment : SlidingPaneChildFragment() { binding.mediaList.addItemDecoration(headerItemDecoration) binding.mediaList.setHasFixedSize(true) - val spanCount = 4 + val spanCount = requireContext().resources.getInteger(R.integer.media_columns) val layoutManager = object : GridLayoutManager(requireContext(), spanCount) { override fun checkLayoutParams(lp: RecyclerView.LayoutParams): Boolean { lp.width = width / spanCount @@ -159,6 +162,48 @@ class ConversationMediaListFragment : SlidingPaneChildFragment() { goToFileViewer(model) } } + + sharedViewModel.hideConversationEvent.observe(viewLifecycleOwner) { + it.consume { + Log.w("$TAG We were asked to close conversation, going back") + goBack() + sharedViewModel.hideConversationEvent.postValue(Event(true)) + } + } + + scrollListener = object : RecyclerViewScrollListener(layoutManager, spanCount, true) { + @UiThread + override fun onLoadMore(totalItemsCount: Int) { + Log.i("$TAG Asking for more data to display, currently displayed items count is [$totalItemsCount]") + viewModel.loadMoreData(totalItemsCount) + } + + @UiThread + override fun onScrolledUp() { + + } + + @UiThread + override fun onScrolledToEnd() { + + } + } + } + + override fun onResume() { + super.onResume() + + if (::scrollListener.isInitialized) { + binding.mediaList.addOnScrollListener(scrollListener) + } + } + + override fun onPause() { + super.onPause() + + if (::scrollListener.isInitialized) { + binding.mediaList.removeOnScrollListener(scrollListener) + } } private fun goToFileViewer(fileModel: FileModel) { diff --git a/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationsListFragment.kt b/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationsListFragment.kt index da81cda6e7..c631d40e9b 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationsListFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationsListFragment.kt @@ -33,18 +33,26 @@ import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.R +import org.linphone.contacts.getListOfSipAddressesAndPhoneNumbers import org.linphone.core.tools.Log import org.linphone.databinding.ChatListFragmentBinding import org.linphone.ui.fileviewer.FileViewerActivity import org.linphone.ui.fileviewer.MediaViewerActivity import org.linphone.ui.main.MainActivity.Companion.ARGUMENTS_CONVERSATION_ID import org.linphone.ui.main.chat.adapter.ConversationsListAdapter +import org.linphone.ui.main.chat.model.ConversationModel import org.linphone.ui.main.chat.viewmodel.ConversationsListViewModel +import org.linphone.ui.main.contacts.model.ContactNumberOrAddressClickListener +import org.linphone.ui.main.contacts.model.ContactNumberOrAddressModel +import org.linphone.ui.main.contacts.model.NumberOrAddressPickerDialogModel import org.linphone.ui.main.fragment.AbstractMainFragment -import org.linphone.ui.main.history.fragment.HistoryMenuDialogFragment +import org.linphone.utils.ConfirmationDialogModel +import org.linphone.utils.DialogUtils import org.linphone.utils.Event import org.linphone.utils.LinphoneUtils +import org.linphone.utils.RecyclerViewHeaderDecoration @UiThread class ConversationsListFragment : AbstractMainFragment() { @@ -60,6 +68,21 @@ class ConversationsListFragment : AbstractMainFragment() { private var bottomSheetDialog: BottomSheetDialogFragment? = null + private val numberOrAddressClickListener = object : ContactNumberOrAddressClickListener { + @UiThread + override fun onClicked(model: ContactNumberOrAddressModel) { + coreContext.postOnCoreThread { + val address = model.address + if (address != null) { + Log.i("$TAG Creating 1-1 conversation with to [${address.asStringUriOnly()}]") + listViewModel.createOneToOneChatRoomWith(address) + } + } + } + + override fun onLongPress(model: ContactNumberOrAddressModel) { } + } + private val dataObserver = object : RecyclerView.AdapterDataObserver() { override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { Log.i("$TAG [$itemCount] added, scrolling to top") @@ -78,7 +101,7 @@ class ConversationsListFragment : AbstractMainFragment() { Log.i( "$TAG Default account changed, updating avatar in top bar & re-computing conversations" ) - listViewModel.applyFilter() + listViewModel.filter() } override fun onCreateAnimation(transit: Int, enter: Boolean, nextAnim: Int): Animation? { @@ -121,6 +144,9 @@ class ConversationsListFragment : AbstractMainFragment() { binding.conversationsList.outlineProvider = outlineProvider binding.conversationsList.clipToOutline = true + val headerItemDecoration = RecyclerViewHeaderDecoration(requireContext(), adapter) + binding.conversationsList.addItemDecoration(headerItemDecoration) + adapter.conversationLongClickedEvent.observe(viewLifecycleOwner) { it.consume { model -> val modalBottomSheet = ConversationDialogFragment( @@ -144,15 +170,13 @@ class ConversationsListFragment : AbstractMainFragment() { model.call() }, { // onDeleteConversation - Log.i("$TAG Deleting conversation [${model.id}]") - model.delete() + showDeleteConfirmationDialog(model) }, { // onLeaveGroup - Log.i("$TAG Leaving group conversation [${model.id}]") - model.leaveGroup() + showLeaveConfirmationDialog(model) } ) - modalBottomSheet.show(parentFragmentManager, HistoryMenuDialogFragment.TAG) + modalBottomSheet.show(parentFragmentManager, ConversationDialogFragment.TAG) bottomSheetDialog = modalBottomSheet } } @@ -165,6 +189,35 @@ class ConversationsListFragment : AbstractMainFragment() { } } + adapter.createConversationWithFriendClickedEvent.observe(viewLifecycleOwner) { + it.consume { friend -> + coreContext.postOnCoreThread { + val singleAvailableAddress = LinphoneUtils.getSingleAvailableAddressForFriend(friend) + if (singleAvailableAddress != null) { + Log.i( + "$TAG Only 1 SIP address or phone number found for contact [${friend.name}], using it" + ) + listViewModel.createOneToOneChatRoomWith(singleAvailableAddress) + } else { + val list = friend.getListOfSipAddressesAndPhoneNumbers(numberOrAddressClickListener) + Log.i( + "$TAG [${list.size}] numbers or addresses found for contact [${friend.name}], showing selection dialog" + ) + coreContext.postOnMainThread { + showNumbersOrAddressesDialog(list) + } + } + } + } + } + + adapter.createConversationWithAddressClickedEvent.observe(viewLifecycleOwner) { + it.consume { address -> + Log.i("$TAG Creating 1-1 conversation with to [${address.asStringUriOnly()}]") + listViewModel.createOneToOneChatRoomWith(address) + } + } + binding.setOnNewConversationClicked { if (findNavController().currentDestination?.id == R.id.conversationsListFragment) { Log.i("$TAG Navigating to start conversation fragment") @@ -187,6 +240,14 @@ class ConversationsListFragment : AbstractMainFragment() { listViewModel.fetchInProgress.value = false } + listViewModel.chatRoomCreatedEvent.observe(viewLifecycleOwner) { + it.consume { conversationId -> + Log.i("$TAG Conversation [$conversationId] has been created, navigating to it") + val action = ConversationFragmentDirections.actionGlobalConversationFragment(conversationId) + binding.chatNavContainer.findNavController().navigate(action) + } + } + sharedViewModel.showConversationEvent.observe(viewLifecycleOwner) { it.consume { conversationId -> Log.i("$TAG Navigating to conversation fragment with ID [$conversationId]") @@ -251,10 +312,10 @@ class ConversationsListFragment : AbstractMainFragment() { sharedViewModel.updateConversationLastMessageEvent.observe(viewLifecycleOwner) { it.consume { conversationId -> - val model = listViewModel.conversations.value.orEmpty().find { conversationModel -> - conversationModel.id == conversationId + val model = listViewModel.conversations.value.orEmpty().find { wrapperModel -> + wrapperModel.conversationModel?.id == conversationId } - model?.updateLastMessageInfo() + model?.conversationModel?.updateLastMessageInfo() } } @@ -262,10 +323,10 @@ class ConversationsListFragment : AbstractMainFragment() { it.consume { val displayChatRoom = sharedViewModel.displayedChatRoom if (displayChatRoom != null) { - val found = listViewModel.conversations.value.orEmpty().find { model -> - model.chatRoom == displayChatRoom + val found = listViewModel.conversations.value.orEmpty().find { wrapperModel -> + wrapperModel.conversationModel?.chatRoom == displayChatRoom } - found?.updateMuteState() + found?.conversationModel?.updateMuteState() } } } @@ -277,9 +338,9 @@ class ConversationsListFragment : AbstractMainFragment() { val displayChatRoom = sharedViewModel.displayedChatRoom if (displayChatRoom != null) { val found = listViewModel.conversations.value.orEmpty().find { model -> - model.chatRoom == displayChatRoom + model.conversationModel?.chatRoom == displayChatRoom } - found?.updateUnreadCount() + found?.conversationModel?.updateUnreadCount() } listViewModel.updateUnreadMessagesCount() } @@ -343,4 +404,70 @@ class ConversationsListFragment : AbstractMainFragment() { Log.e("$TAG Failed to unregister data observer to adapter: $e") } } + + private fun showNumbersOrAddressesDialog(list: List) { + val numberOrAddressModel = NumberOrAddressPickerDialogModel(list) + val dialog = + DialogUtils.getNumberOrAddressPickerDialog( + requireActivity(), + numberOrAddressModel + ) + + numberOrAddressModel.dismissEvent.observe(viewLifecycleOwner) { event -> + event.consume { + dialog.dismiss() + } + } + + dialog.show() + } + + private fun showDeleteConfirmationDialog(conversationModel: ConversationModel) { + val dialogModel = ConfirmationDialogModel() + val dialog = DialogUtils.getDeleteConversationConfirmationDialog( + requireActivity(), + dialogModel + ) + + dialogModel.dismissEvent.observe(viewLifecycleOwner) { + it.consume { + dialog.dismiss() + } + } + + dialogModel.confirmEvent.observe(viewLifecycleOwner) { + it.consume { + Log.i("$TAG Deleting conversation [${conversationModel.id}]") + conversationModel.delete() + dialog.dismiss() + } + } + + dialog.show() + } + + private fun showLeaveConfirmationDialog(conversationModel: ConversationModel) { + val dialogModel = ConfirmationDialogModel() + val dialog = DialogUtils.getLeaveConversationConfirmationDialog( + requireActivity(), + dialogModel + ) + + dialogModel.dismissEvent.observe(viewLifecycleOwner) { + it.consume { + dialog.dismiss() + } + } + + dialogModel.confirmEvent.observe(viewLifecycleOwner) { + it.consume { + + Log.i("$TAG Leaving group conversation [${conversationModel.id}]") + conversationModel.leaveGroup() + dialog.dismiss() + } + } + + dialog.show() + } } diff --git a/app/src/main/java/org/linphone/ui/main/chat/fragment/MessageDialogFragment.kt b/app/src/main/java/org/linphone/ui/main/chat/fragment/MessageDialogFragment.kt new file mode 100644 index 0000000000..125ca4c7e9 --- /dev/null +++ b/app/src/main/java/org/linphone/ui/main/chat/fragment/MessageDialogFragment.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2010-2026 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.ui.main.chat.fragment + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.UiThread +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import org.linphone.databinding.ChatMessageDeleteMenuBinding + +@UiThread +class MessageDialogFragment( + private val canDeleteForEveryone: Boolean, + private val onDismiss: (() -> Unit)? = null, + private val onDeleteLocally: (() -> Unit)? = null, + private val onDeleteForEveryone: (() -> Unit)? = null +) : BottomSheetDialogFragment() { + companion object { + const val TAG = "MessageDialogFragment" + } + + override fun onCancel(dialog: DialogInterface) { + onDismiss?.invoke() + super.onCancel(dialog) + } + + override fun onDismiss(dialog: DialogInterface) { + onDismiss?.invoke() + super.onDismiss(dialog) + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val dialog = super.onCreateDialog(savedInstanceState) as BottomSheetDialog + // Makes sure all menu entries are visible, + // required for landscape mode (otherwise only first item is visible) + dialog.behavior.state = BottomSheetBehavior.STATE_EXPANDED + return dialog + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val view = ChatMessageDeleteMenuBinding.inflate(layoutInflater) + view.canDeleteForEveryone = canDeleteForEveryone + + view.setDeleteForMeClickListener { + onDeleteLocally?.invoke() + dismiss() + } + + view.setDeleteForEveryoneClickListener { + onDeleteForEveryone?.invoke() + dismiss() + } + + return view.root + } +} diff --git a/app/src/main/java/org/linphone/ui/main/chat/fragment/StartConversationFragment.kt b/app/src/main/java/org/linphone/ui/main/chat/fragment/StartConversationFragment.kt index a0039ebe79..84faf42476 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/fragment/StartConversationFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/fragment/StartConversationFragment.kt @@ -24,12 +24,9 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.annotation.UiThread -import androidx.annotation.WorkerThread import androidx.core.view.doOnPreDraw import androidx.lifecycle.ViewModelProvider import org.linphone.R -import org.linphone.core.Address -import org.linphone.core.Friend import org.linphone.core.tools.Log import org.linphone.databinding.StartChatFragmentBinding import org.linphone.ui.GenericActivity @@ -102,16 +99,6 @@ class StartConversationFragment : GenericAddressPickerFragment() { } } - viewModel.chatRoomCreationErrorEvent.observe(viewLifecycleOwner) { - it.consume { error -> - Log.i("$TAG Conversation creation error, showing red toast") - (requireActivity() as GenericActivity).showRedToast( - getString(error), - R.drawable.warning_circle - ) - } - } - viewModel.defaultAccountChangedEvent.observe(viewLifecycleOwner) { it.consume { viewModel.updateGroupChatButtonVisibility() @@ -119,11 +106,6 @@ class StartConversationFragment : GenericAddressPickerFragment() { } } - @WorkerThread - override fun onSingleAddressSelected(address: Address, friend: Friend) { - viewModel.createOneToOneChatRoomWith(address) - } - private fun showGroupConversationSubjectDialog() { val model = GroupSetOrEditSubjectDialogModel("", isGroupConversation = true) diff --git a/app/src/main/java/org/linphone/ui/main/chat/model/ConversationModel.kt b/app/src/main/java/org/linphone/ui/main/chat/model/ConversationModel.kt index 63288eb0ff..b9f11eddb9 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/model/ConversationModel.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/model/ConversationModel.kt @@ -33,6 +33,7 @@ import org.linphone.core.ChatMessageListenerStub import org.linphone.core.ChatRoom import org.linphone.core.ChatRoom.Capabilities import org.linphone.core.ChatRoomListenerStub +import org.linphone.core.Content import org.linphone.core.EventLog import org.linphone.core.Friend import org.linphone.core.tools.Log @@ -82,6 +83,8 @@ class ConversationModel val lastMessageContentIcon = MutableLiveData() + val composingIcon = MutableLiveData() + val isLastMessageOutgoing = MutableLiveData() val dateTime = MutableLiveData() @@ -99,7 +102,7 @@ class ConversationModel override fun onStateChanged(chatRoom: ChatRoom, newState: ChatRoom.State?) { Log.i("$TAG Conversation state changed [${chatRoom.state}]") if (chatRoom.state == ChatRoom.State.Created) { - subject.postValue(chatRoom.subject) + subject.postValue(chatRoom.subjectUtf8) computeParticipants() } else if (chatRoom.state == ChatRoom.State.Deleted) { Log.i("$TAG Conversation [$id] has been deleted") @@ -111,13 +114,13 @@ class ConversationModel override fun onConferenceJoined(chatRoom: ChatRoom, eventLog: EventLog) { // This is required as a Created chat room may not have the participants list yet Log.i("$TAG Conversation has been joined") - subject.postValue(chatRoom.subject) + subject.postValue(chatRoom.subjectUtf8) computeParticipants() } @WorkerThread override fun onConferenceLeft(chatRoom: ChatRoom, eventLog: EventLog) { - Log.w("TAG Conversation has been left") + Log.w("$TAG Conversation has been left") isReadOnly.postValue(chatRoom.isReadOnly) } @@ -154,8 +157,9 @@ class ConversationModel @WorkerThread override fun onSubjectChanged(chatRoom: ChatRoom, eventLog: EventLog) { - Log.i("$TAG Conversation subject changed [${chatRoom.subject}]") - subject.postValue(chatRoom.subject) + Log.i("$TAG Conversation subject changed [${chatRoom.subjectUtf8}]") + subject.postValue(chatRoom.subjectUtf8) + computeParticipants() } @WorkerThread @@ -168,6 +172,23 @@ class ConversationModel Log.i("$TAG An ephemeral message lifetime has expired, updating last displayed message") updateLastMessage() } + + @WorkerThread + override fun onMessageRetracted(chatRoom: ChatRoom, message: ChatMessage) { + if (lastMessage == null || message.messageId == lastMessage?.messageId) { + Log.i("$TAG Last message [${message.messageId}] has been retracted") + updateLastMessage() + } + unreadMessageCount.postValue(chatRoom.unreadMessagesCount) + } + + @WorkerThread + override fun onMessageContentEdited(chatRoom: ChatRoom, message: ChatMessage) { + if (lastMessage == null || message.messageId == lastMessage?.messageId) { + Log.i("$TAG Last message [${message.messageId}] has been edited") + updateLastMessage() + } + } } private val chatMessageListener = object : ChatMessageListenerStub() { @@ -181,7 +202,7 @@ class ConversationModel chatRoom.addListener(chatRoomListener) computeComposingLabel() - subject.postValue(chatRoom.subject) + subject.postValue(chatRoom.subjectUtf8) computeParticipants() isMuted.postValue(chatRoom.muted) @@ -307,30 +328,15 @@ class ConversationModel lastMessageDeliveryIcon.postValue(LinphoneUtils.getChatIconResId(message.state)) } - if (message.isForward) { + if (message.isRetracted) { + lastMessageContentIcon.postValue(R.drawable.trash) + } else if (message.isForward) { lastMessageContentIcon.postValue(R.drawable.forward) } else { - val firstContent = message.contents.firstOrNull() - val icon = if (firstContent?.isIcalendar == true) { - R.drawable.calendar - } else if (firstContent?.isVoiceRecording == true) { - R.drawable.waveform - } else if (firstContent?.isFile == true) { - val mime = "${firstContent.type}/${firstContent.subtype}" - val mimeType = FileUtils.getMimeType(mime) - val drawable = when (mimeType) { - FileUtils.MimeType.Image -> R.drawable.file_image - FileUtils.MimeType.Video -> R.drawable.file_video - FileUtils.MimeType.Audio -> R.drawable.file_audio - FileUtils.MimeType.Pdf -> R.drawable.file_pdf - FileUtils.MimeType.PlainText -> R.drawable.file_text - else -> R.drawable.file - } - drawable - } else if (firstContent?.isFileTransfer == true) { - R.drawable.download_simple - } else { - 0 + var icon = 0 + for (content in message.contents) { + icon = getIconFromContent(content) + if (icon != 0) break } lastMessageContentIcon.postValue(icon) } @@ -348,7 +354,7 @@ class ConversationModel if (message.isOutgoing && message.state != ChatMessage.State.Displayed) { message.addListener(chatMessageListener) - } else if (message.contents.find { it.isFileTransfer == true } != null) { + } else if (message.contents.find { it.isFileTransfer } != null) { message.addListener(chatMessageListener) } @@ -376,6 +382,31 @@ class ConversationModel } } + @WorkerThread + private fun getIconFromContent(content: Content): Int { + return if (content.isIcalendar) { + R.drawable.calendar + } else if (content.isVoiceRecording) { + R.drawable.waveform + } else if (content.isFile) { + val mime = "${content.type}/${content.subtype}" + val mimeType = FileUtils.getMimeType(mime) + val drawable = when (mimeType) { + FileUtils.MimeType.Image -> R.drawable.file_image + FileUtils.MimeType.Video -> R.drawable.file_video + FileUtils.MimeType.Audio -> R.drawable.file_audio + FileUtils.MimeType.Pdf -> R.drawable.file_pdf + FileUtils.MimeType.PlainText -> R.drawable.file_text + else -> R.drawable.file + } + drawable + } else if (content.isFileTransfer) { + R.drawable.download_simple + } else { + 0 + } + } + @WorkerThread private fun updateLastUpdatedTime() { val timestamp = chatRoom.lastUpdateTime @@ -411,16 +442,20 @@ class ConversationModel } if (isGroup) { - val fakeFriend = coreContext.core.createFriend() - fakeFriend.name = chatRoom.subject - val model = ContactAvatarModel(fakeFriend) - model.defaultToConversationIcon.postValue(true) - model.updateSecurityLevelUsingConversation(chatRoom) - avatarModel.postValue(model) + if (avatarModel.value == null || avatarModel.value?.contactName != chatRoom.subjectUtf8) { + val fakeFriend = coreContext.core.createFriend() + fakeFriend.name = chatRoom.subjectUtf8 + val model = ContactAvatarModel(fakeFriend) + model.defaultToConversationIcon.postValue(true) + model.updateSecurityLevelUsingConversation(chatRoom) + avatarModel.postValue(model) + } } else { - avatarModel.postValue( - coreContext.contactsManager.getContactAvatarModelForAddress(address) - ) + val model = coreContext.contactsManager.getContactAvatarModelForAddress(address) + val oldModel = avatarModel.value + if (!model.compare(oldModel)) { + avatarModel.postValue(model) + } } } @@ -428,30 +463,10 @@ class ConversationModel private fun computeComposingLabel() { val composing = chatRoom.isRemoteComposing isComposing.postValue(composing) - if (!composing) { - composingLabel.postValue("") - return - } - - val composingFriends = arrayListOf() - var label = "" - for (address in chatRoom.composingAddresses) { - val avatar = coreContext.contactsManager.getContactAvatarModelForAddress(address) - val name = avatar.name.value ?: LinphoneUtils.getDisplayName(address) - composingFriends.add(name) - label += "$name, " - } - if (composingFriends.isNotEmpty()) { - label = label.dropLast(2) - - val format = AppUtils.getStringWithPlural( - R.plurals.conversation_composing_label, - composingFriends.size, - label - ) - composingLabel.postValue(format) - } else { - composingLabel.postValue("") - } + val pair = LinphoneUtils.getComposingIconAndText(chatRoom) + val icon = pair.first + composingIcon.postValue(icon) + val label = pair.second + composingLabel.postValue(label) } } diff --git a/app/src/main/java/org/linphone/ui/main/chat/model/ConversationModelWrapper.kt b/app/src/main/java/org/linphone/ui/main/chat/model/ConversationModelWrapper.kt new file mode 100644 index 0000000000..2739f80abf --- /dev/null +++ b/app/src/main/java/org/linphone/ui/main/chat/model/ConversationModelWrapper.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2010-2026 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.ui.main.chat.model + +import org.linphone.ui.main.model.ConversationContactOrSuggestionModel + +class ConversationModelWrapper(val conversationModel: ConversationModel?, val contactModel: ConversationContactOrSuggestionModel? = null) { + val isConversation = conversationModel != null + + val isContactOrSuggestion = contactModel != null + + fun destroy() { + conversationModel?.destroy() + } +} diff --git a/app/src/main/java/org/linphone/ui/main/chat/model/EventLogModel.kt b/app/src/main/java/org/linphone/ui/main/chat/model/EventLogModel.kt index b0da4ae9d1..cbe5f1fcec 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/model/EventLogModel.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/model/EventLogModel.kt @@ -51,7 +51,6 @@ class EventLogModel EventModel(eventLog) } else { val chatMessage = eventLog.chatMessage!! - MessageModel( chatMessage, isFromGroup, diff --git a/app/src/main/java/org/linphone/ui/main/chat/model/FileModel.kt b/app/src/main/java/org/linphone/ui/main/chat/model/FileModel.kt index 1b4c2d315c..8b1dd0d788 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/model/FileModel.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/model/FileModel.kt @@ -19,9 +19,11 @@ */ package org.linphone.ui.main.chat.model +import android.graphics.pdf.PdfRenderer import android.media.MediaMetadataRetriever import android.media.MediaMetadataRetriever.METADATA_KEY_DURATION import android.media.ThumbnailUtils +import android.os.ParcelFileDescriptor import android.provider.MediaStore import androidx.annotation.AnyThread import androidx.annotation.UiThread @@ -35,6 +37,10 @@ import org.linphone.core.tools.Log import org.linphone.utils.FileUtils import org.linphone.utils.TimestampUtils import androidx.core.net.toUri +import androidx.core.graphics.createBitmap +import kotlinx.coroutines.withContext +import org.linphone.utils.FileUtils.Companion.getFileStorageCacheDir +import java.io.File class FileModel @AnyThread @@ -97,6 +103,9 @@ class FileModel if (!isWaitingToBeDownloaded) { val extension = FileUtils.getExtensionFromFileName(path) isPdf = extension == "pdf" + if (isPdf) { + loadPdfPreview() + } val mime = FileUtils.getMimeTypeFromExtension(extension) mimeTypeString = mime @@ -149,7 +158,7 @@ class FileModel @AnyThread fun updateTransferProgress(percent: Int) { transferProgress.postValue(percent) - if (percent < 0 || percent > 100) { + if (percent !in 0..100) { transferProgressLabel.postValue("") } else { transferProgressLabel.postValue("$percent%") @@ -167,22 +176,68 @@ class FileModel FileUtils.deleteFile(path) } + @AnyThread + private fun loadPdfPreview() { + scope.launch { + withContext(Dispatchers.IO) { + try { + val pdfFileDescriptor = ParcelFileDescriptor.open( + File(path), + ParcelFileDescriptor.MODE_READ_ONLY + ) + if (pdfFileDescriptor == null) { + Log.e("$TAG Failed to get a file descriptor for PDF at [$path]") + return@withContext + } + + val pdfRenderer = PdfRenderer(pdfFileDescriptor) + val pdfFirstPage = pdfRenderer.openPage(0) + val previewBitmap = createBitmap(pdfFirstPage.width, pdfFirstPage.height) + pdfFirstPage.render( + previewBitmap, + null, + null, + PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY + ) + + val file = getFileStorageCacheDir("$fileName.jpg", true) + val previewPath = FileUtils.storeBitmap(previewBitmap, file) + Log.i("$TAG Preview of PDF file [$path] available at [$previewPath]") + mediaPreview.postValue(previewPath) + mediaPreviewAvailable.postValue(true) + + previewBitmap.recycle() + pdfFirstPage.close() + pdfRenderer.close() + pdfFileDescriptor.close() + } catch (e: Exception) { + Log.e("$TAG Failed to get image preview for PDF file [$path]: $e") + } + } + } + } + @AnyThread private fun loadVideoPreview() { - try { - Log.i("$TAG Try to create an image preview of video file [$path]") - val previewBitmap = ThumbnailUtils.createVideoThumbnail( - path, - MediaStore.Images.Thumbnails.MINI_KIND - ) - if (previewBitmap != null) { - val previewPath = FileUtils.storeBitmap(previewBitmap, fileName) - Log.i("$TAG Preview of video file [$path] available at [$previewPath]") - mediaPreview.postValue(previewPath) - mediaPreviewAvailable.postValue(true) + scope.launch { + withContext(Dispatchers.IO) { + try { + Log.i("$TAG Try to create an image preview of video file [$path]") + val previewBitmap = ThumbnailUtils.createVideoThumbnail( + path, + MediaStore.Images.Thumbnails.MINI_KIND + ) + if (previewBitmap != null) { + val file = getFileStorageCacheDir("$fileName.jpg", true) + val previewPath = FileUtils.storeBitmap(previewBitmap, file) + Log.i("$TAG Preview of video file [$path] available at [$previewPath]") + mediaPreview.postValue(previewPath) + mediaPreviewAvailable.postValue(true) + } + } catch (e: Exception) { + Log.e("$TAG Failed to get image preview for file [$path]: $e") + } } - } catch (e: Exception) { - Log.e("$TAG Failed to get image preview for file [$path]: $e") } } diff --git a/app/src/main/java/org/linphone/ui/main/chat/model/MessageBottomSheetParticipantModel.kt b/app/src/main/java/org/linphone/ui/main/chat/model/MessageBottomSheetParticipantModel.kt index c1c7f69eaa..0480f20aca 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/model/MessageBottomSheetParticipantModel.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/model/MessageBottomSheetParticipantModel.kt @@ -47,7 +47,7 @@ class MessageBottomSheetParticipantModel @UiThread fun clicked() { - if (!isOurOwnReaction && !corePreferences.onlyDisplaySipUriUsername) { + if (!isOurOwnReaction && !corePreferences.onlyDisplaySipUriUsername && !corePreferences.hideSipAddresses) { showSipUri.postValue(showSipUri.value == false) } else { onClick?.invoke() diff --git a/app/src/main/java/org/linphone/ui/main/chat/model/MessageDeliveryModel.kt b/app/src/main/java/org/linphone/ui/main/chat/model/MessageDeliveryModel.kt index 0721366e9b..3d197e366d 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/model/MessageDeliveryModel.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/model/MessageDeliveryModel.kt @@ -51,11 +51,11 @@ class MessageDeliveryModel val displayedModels = arrayListOf() - private val deliveredModels = arrayListOf() + val deliveredModels = arrayListOf() - private val sentModels = arrayListOf() + val sentModels = arrayListOf() - private val errorModels = arrayListOf() + val errorModels = arrayListOf() private val chatMessageListener = object : ChatMessageListenerStub() { @WorkerThread @@ -63,7 +63,7 @@ class MessageDeliveryModel message: ChatMessage, state: ParticipantImdnState ) { - Log.i("$TAG Participant IMDN state changed [${state.state}], updating delivery status") + Log.i("$TAG Participant IMDN state changed [${state.state}] for message with ID [${message.messageId}], updating delivery status") computeDeliveryStatus() } } @@ -79,7 +79,7 @@ class MessageDeliveryModel } @UiThread - fun computeListForState(state: State): ArrayList { + fun getListForState(state: State): ArrayList { return when (state) { State.DeliveredToUser -> { deliveredModels @@ -98,6 +98,8 @@ class MessageDeliveryModel @WorkerThread private fun computeDeliveryStatus() { + Log.i("$TAG Message ID [${chatMessage.messageId}] is in state [${chatMessage.state}]") + displayedModels.clear() deliveredModels.clear() sentModels.clear() @@ -175,12 +177,15 @@ class MessageDeliveryModel ) ) + if (displayedModels.isEmpty() && deliveredModels.isEmpty() && sentModels.isEmpty() && errorModels.isEmpty()) { + Log.e("$TAG No participant found in state Displayed, DeliveredToUser, Delivered or Error for message ID [${chatMessage.messageId}]") + } + displayedModels.sortBy { it.timestamp } deliveredModels.sortBy { it.timestamp } sentModels.sortBy { it.timestamp } errorModels.sortBy { it.timestamp } - Log.i("$TAG Message ID [${chatMessage.messageId}] is in state [${chatMessage.state}]") Log.i( "$TAG There are [$readCount] that have read this message, [$receivedCount] that have received it, [$sentCount] that haven't received it yet and [$errorCount] that probably won't receive it due to an error" ) diff --git a/app/src/main/java/org/linphone/ui/main/chat/model/MessageModel.kt b/app/src/main/java/org/linphone/ui/main/chat/model/MessageModel.kt index 66e1389f6c..f339ed274c 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/model/MessageModel.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/model/MessageModel.kt @@ -24,7 +24,9 @@ import android.os.CountDownTimer import android.text.Spannable import android.text.SpannableStringBuilder import android.text.Spanned +import android.text.style.ForegroundColorSpan import android.text.style.StyleSpan +import androidx.annotation.AnyThread import androidx.annotation.UiThread import androidx.annotation.WorkerThread import androidx.lifecycle.MediatorLiveData @@ -111,7 +113,7 @@ class MessageModel )?.params?.instantMessagingEncryptionMandatory == true ) - val isReply = chatMessage.isReply + val isReply = MutableLiveData() val replyToMessageId = chatMessage.replyMessageId @@ -135,6 +137,8 @@ class MessageModel val text = MutableLiveData() + val isTextEmoji = MutableLiveData() + val reactions = MutableLiveData() val ourReactionIndex = MutableLiveData() @@ -143,8 +147,14 @@ class MessageModel val firstFileModel = MediatorLiveData() + val hasBeenEdited = MutableLiveData() + + val hasBeenRetracted = MutableLiveData() + val isSelected = MutableLiveData() + private var rawTextContent: String = "" + // Below are for conferences info val meetingFound = MutableLiveData() @@ -181,7 +191,7 @@ class MessageModel val formattedVoiceRecordingDuration = MutableLiveData() val dismissLongPressMenuEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } var isTextHighlighted = false @@ -210,27 +220,13 @@ class MessageModel private val chatMessageListener = object : ChatMessageListenerStub() { @WorkerThread override fun onMsgStateChanged(message: ChatMessage, messageState: ChatMessage.State?) { + Log.i("$TAG Chat message [${message.messageId}] state changed to [$messageState]") if (messageState != ChatMessage.State.FileTransferDone && messageState != ChatMessage.State.FileTransferInProgress) { statusIcon.postValue(LinphoneUtils.getChatIconResId(chatMessage.state)) if (messageState == ChatMessage.State.Displayed) { isRead = chatMessage.isRead } - } else if (messageState == ChatMessage.State.FileTransferDone) { - Log.i("$TAG File transfer is done") - transferringFileModel?.updateTransferProgress(-1) - transferringFileModel = null - if (!allFilesDownloaded) { - computeContentsList() - } else { - for (content in message.contents) { - if (content.isVoiceRecording) { - Log.i("$TAG File transfer done, updating voice record info") - computeVoiceRecordContent(content) - break - } - } - } } isInError.postValue(messageState == ChatMessage.State.NotDelivered) } @@ -238,22 +234,7 @@ class MessageModel @WorkerThread override fun onFileTransferTerminated(message: ChatMessage, content: Content) { Log.i("$TAG File [${content.name}] from message [${message.messageId}] transfer terminated") - - // Never do auto media export for ephemeral messages! - if (corePreferences.makePublicMediaFilesDownloaded && !message.isEphemeral) { - val path = content.filePath - if (path.isNullOrEmpty()) return - - val mime = "${content.type}/${content.subtype}" - val mimeType = FileUtils.getMimeType(mime) - when (mimeType) { - FileUtils.MimeType.Image, FileUtils.MimeType.Video, FileUtils.MimeType.Audio -> { - Log.i("$TAG Exporting file path [$path] to the native media gallery") - onFileToExportToNativeGallery?.invoke(path) - } - else -> {} - } - } + fileTransferTerminated(message, content) } @WorkerThread @@ -266,7 +247,7 @@ class MessageModel @WorkerThread override fun onReactionRemoved(message: ChatMessage, address: Address) { - Log.i("$TAG A reaction was removed for message with ID [$id]") + Log.i("$TAG A reaction from [${address.asStringUriOnly()}] was removed for message with ID [$id]") updateReactionsList() } @@ -301,6 +282,21 @@ class MessageModel Log.d("$TAG Ephemeral timer started") updateEphemeralTimer() } + + @WorkerThread + override fun onContentEdited(message: ChatMessage) { + Log.i("$TAG Message [${message.messageId}] has been edited") + hasBeenEdited.postValue(true) + computeContentsList() + } + + @WorkerThread + override fun onRetracted(message: ChatMessage) { + Log.i("$TAG Content(s) of the message have been deleted by it's sender") + hasBeenEdited.postValue(false) + hasBeenRetracted.postValue(true) + computeContentsList() + } } init { @@ -318,9 +314,14 @@ class MessageModel statusIcon.postValue(LinphoneUtils.getChatIconResId(chatMessage.state)) updateReactionsList() + hasBeenEdited.postValue(chatMessage.isEdited && !chatMessage.isRetracted) + hasBeenRetracted.postValue(chatMessage.isRetracted) computeContentsList() - if (isReply) { + if (chatMessage.isReply) { + // Wait to see if original message is found before setting isReply to true computeReplyInfo() + } else { + isReply.postValue(false) } coreContext.postOnMainThread { @@ -359,6 +360,7 @@ class MessageModel val reaction = chatMessage.createReaction(emoji) reaction.send() } + updateReactionsList() dismissLongPressMenuEvent.postValue(Event(true)) } } @@ -410,19 +412,32 @@ class MessageModel avatarModel.postValue(avatar) } + @AnyThread + fun getRawTextContent(): String { + return rawTextContent + } + @WorkerThread private fun computeContentsList() { Log.d("$TAG Computing message contents list") text.postValue(Spannable.Factory.getInstance().newSpannable("")) filesList.value.orEmpty().forEach(FileModel::destroy) + if (chatMessage.isRetracted) { + meetingFound.postValue(false) + isVoiceRecord.postValue(false) + isTextEmoji.postValue(false) + } + var displayableContentFound = false var contentIndex = 0 val filesPath = arrayListOf() val contents = chatMessage.contents allFilesDownloaded = true - val exactly4Contents = contents.size == 4 + val exactly4Contents = contents.count { + it.isFile || it.isFileTransfer + } == 4 for (content in contents) { val isFileEncrypted = content.isFileEncrypted @@ -561,9 +576,10 @@ class MessageModel @WorkerThread private fun downloadContent(model: FileModel, content: Content) { - Log.d("$TAG Starting downloading content for file [${model.fileName}]") + Log.i("$TAG Start downloading content for file [${model.fileName}]") - if (content.filePath.orEmpty().isEmpty()) { + val path = content.filePath.orEmpty() + if (path.isEmpty()) { val contentName = content.name if (contentName != null) { val isImage = FileUtils.isExtensionImage(contentName) @@ -579,6 +595,8 @@ class MessageModel } else { Log.e("$TAG Content name is null, can't download it!") } + } else { + Log.e("$TAG We already have a file path [$path] for this content, doing nothing") } } @@ -620,7 +638,9 @@ class MessageModel ourReactionIndex.postValue(-1) } - reactions.postValue(reactionsList) + if (reactionsList != reactions.value) { + reactions.postValue(reactionsList) + } } @WorkerThread @@ -646,39 +666,33 @@ class MessageModel val avatarModel = coreContext.contactsManager.getContactAvatarModelForAddress(from) replyTo.postValue(avatarModel.contactName ?: LinphoneUtils.getDisplayName(from)) replyText.postValue(LinphoneUtils.getFormattedTextDescribingMessage(replyMessage)) + isReply.postValue(true) } else { - Log.e("$TAG Failed to find the reply message from ID [${chatMessage.replyMessageId}]") + Log.w("$TAG Failed to find the reply message from ID [${chatMessage.replyMessageId}]") + isReply.postValue(false) } } @WorkerThread private fun computeTextContent(content: Content, highlight: String) { - val textContent = content.utf8Text.orEmpty().trim() - val spannableBuilder = SpannableStringBuilder(textContent) + rawTextContent = content.utf8Text.orEmpty().trim() + val spannableBuilder = SpannableStringBuilder(rawTextContent) - // Check for search - if (highlight.isNotEmpty()) { - val indexStart = textContent.indexOf(highlight, 0, ignoreCase = true) - if (indexStart >= 0) { - isTextHighlighted = true - val indexEnd = indexStart + highlight.length - spannableBuilder.setSpan( - StyleSpan(Typeface.BOLD), - indexStart, - indexEnd, - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE - ) - } + val emojiOnly = AppUtils.isTextOnlyContainsEmoji(rawTextContent) + isTextEmoji.postValue(emojiOnly) + if (emojiOnly) { + text.postValue(spannableBuilder) + return } // Check for mentions val chatRoom = chatMessage.chatRoom - val matcher = Pattern.compile(MENTION_REGEXP).matcher(textContent) + val matcher = Pattern.compile(MENTION_REGEXP).matcher(rawTextContent) var offset = 0 while (matcher.find()) { val start = matcher.start() val end = matcher.end() - val source = textContent.subSequence(start + 1, end) // +1 to remove @ + val source = rawTextContent.subSequence(start + 1, end) // +1 to remove @ Log.d("$TAG Found mention [$source]") // Find address matching username @@ -725,6 +739,13 @@ class MessageModel start + offset + displayName.length + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE ) + // Change color + spannableBuilder.setSpan( + ForegroundColorSpan(AppUtils.getColorInt(R.color.orange_main_500)), + start + offset, + start + offset + displayName.length + 1, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) offset += displayName.length - source.length } } @@ -760,6 +781,21 @@ class MessageModel ) .build(spannableBuilder) ) + + // Check for search + if (highlight.isNotEmpty()) { + val indexStart = rawTextContent.indexOf(highlight, 0, ignoreCase = true) + if (indexStart >= 0) { + isTextHighlighted = true + val indexEnd = indexStart + highlight.length + spannableBuilder.setSpan( + StyleSpan(Typeface.BOLD), + indexStart, + indexEnd, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + } } @WorkerThread @@ -1019,4 +1055,37 @@ class MessageModel "$TAG Found voice record with path [$voiceRecordPath] and duration [$formattedDuration]" ) } + + @WorkerThread + private fun fileTransferTerminated(message: ChatMessage, content: Content) { + // Never do auto media export for ephemeral messages! + if (corePreferences.makePublicMediaFilesDownloaded && !message.isEphemeral) { + val path = content.filePath + if (path.isNullOrEmpty()) return + + val mime = "${content.type}/${content.subtype}" + val mimeType = FileUtils.getMimeType(mime) + when (mimeType) { + FileUtils.MimeType.Image, FileUtils.MimeType.Video, FileUtils.MimeType.Audio -> { + Log.i("$TAG Exporting file path [$path] to the native media gallery") + onFileToExportToNativeGallery?.invoke(path) + } + else -> {} + } + } + + transferringFileModel?.updateTransferProgress(-1) + transferringFileModel = null + if (!allFilesDownloaded) { + computeContentsList() + } else { + for (content in message.contents) { + if (content.isVoiceRecording) { + Log.i("$TAG File transfer done, updating voice record info") + computeVoiceRecordContent(content) + break + } + } + } + } } diff --git a/app/src/main/java/org/linphone/ui/main/chat/model/ParticipantModel.kt b/app/src/main/java/org/linphone/ui/main/chat/model/ParticipantModel.kt index c7f171ae61..7367f197cc 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/model/ParticipantModel.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/model/ParticipantModel.kt @@ -59,7 +59,7 @@ class ParticipantModel @UiThread fun onClicked() { - if (onClicked == null && !corePreferences.onlyDisplaySipUriUsername) { + if (onClicked == null && !corePreferences.onlyDisplaySipUriUsername && !corePreferences.hideSipAddresses) { showSipUri.postValue(showSipUri.value == false) } else { onClicked?.invoke(this) diff --git a/app/src/main/java/org/linphone/ui/main/chat/viewmodel/AbstractConversationViewModel.kt b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/AbstractConversationViewModel.kt index fb1d6ee41b..0425a86fc6 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/viewmodel/AbstractConversationViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/AbstractConversationViewModel.kt @@ -42,11 +42,11 @@ abstract class AbstractConversationViewModel : GenericViewModel() { } val chatRoomFoundEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val confirmGroupCallEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } lateinit var chatRoom: ChatRoom @@ -169,7 +169,7 @@ abstract class AbstractConversationViewModel : GenericViewModel() { return@postOnCoreThread } - val conference = LinphoneUtils.createGroupCall(account, chatRoom.subject.orEmpty()) + val conference = LinphoneUtils.createGroupCall(account, chatRoom.subjectUtf8.orEmpty()) if (conference == null) { Log.e("$TAG Failed to create group call!") showRedToast(R.string.conference_failed_to_create_group_call_toast, R.drawable.warning_circle) diff --git a/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ChatMessageLongPressViewModel.kt b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ChatMessageLongPressViewModel.kt index 32e65c0aad..afbbeccc3a 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ChatMessageLongPressViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ChatMessageLongPressViewModel.kt @@ -19,9 +19,6 @@ */ package org.linphone.ui.main.chat.viewmodel -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context import android.view.View import androidx.annotation.UiThread import androidx.lifecycle.MutableLiveData @@ -31,6 +28,7 @@ import org.linphone.core.tools.Log import org.linphone.databinding.ChatBubbleEmojiPickerBottomSheetBinding import org.linphone.ui.GenericViewModel import org.linphone.ui.main.chat.model.MessageModel +import org.linphone.utils.AppUtils import org.linphone.utils.Event class ChatMessageLongPressViewModel : GenericViewModel() { @@ -48,26 +46,36 @@ class ChatMessageLongPressViewModel : GenericViewModel() { val isChatRoomReadOnly = MutableLiveData() + val canBeEdited = MutableLiveData() + + val canBeRemotelyDeleted = MutableLiveData() + val messageModel = MutableLiveData() val isMessageOutgoing = MutableLiveData() val isMessageInError = MutableLiveData() + val hasBeenRetracted = MutableLiveData() + val showImdnInfoEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() + } + + val editMessageEvent: MutableLiveData> by lazy { + MutableLiveData() } val replyToMessageEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val forwardMessageEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val deleteMessageEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val onDismissedEvent = MutableLiveData>() @@ -76,6 +84,8 @@ class ChatMessageLongPressViewModel : GenericViewModel() { init { visible.value = false + canBeEdited.value = false + canBeRemotelyDeleted.value = false } @UiThread @@ -92,6 +102,9 @@ class ChatMessageLongPressViewModel : GenericViewModel() { isMessageOutgoing.value = model.isOutgoing isMessageInError.value = model.isInError.value == true horizontalBias.value = if (model.isOutgoing) 1f else 0f + canBeEdited.value = model.chatMessage.isEditable + canBeRemotelyDeleted.value = model.chatMessage.isRetractable + hasBeenRetracted.value = model.hasBeenRetracted.value == true messageModel.value = model emojiBottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN @@ -125,13 +138,20 @@ class ChatMessageLongPressViewModel : GenericViewModel() { } @UiThread - fun copyClickListener() { - Log.i("$TAG Copying message text into clipboard") + fun edit() { + Log.i("$TAG Editing message") + editMessageEvent.value = Event(true) + dismiss() + } - val text = messageModel.value?.text?.value?.toString() - val clipboard = coreContext.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val label = "Message" - clipboard.setPrimaryClip(ClipData.newPlainText(label, text)) + @UiThread + fun copyClickListener() { + val text = messageModel.value?.getRawTextContent().orEmpty() + if (text.isNotEmpty()) { + Log.i("$TAG Copying message text into clipboard") + val label = "Message" + AppUtils.copyToClipboard(coreContext.context, label, text) + } dismiss() } @@ -147,7 +167,6 @@ class ChatMessageLongPressViewModel : GenericViewModel() { fun deleteClickListener() { Log.i("$TAG Deleting message") deleteMessageEvent.value = Event(true) - dismiss() } @UiThread diff --git a/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationDocumentsListViewModel.kt b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationDocumentsListViewModel.kt index 82ceaab0bc..5e5ae19a12 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationDocumentsListViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationDocumentsListViewModel.kt @@ -22,16 +22,21 @@ package org.linphone.ui.main.chat.viewmodel import androidx.annotation.UiThread import androidx.annotation.WorkerThread import androidx.lifecycle.MutableLiveData +import org.linphone.LinphoneApplication.Companion.coreContext +import org.linphone.core.Content import org.linphone.core.tools.Log import org.linphone.ui.main.chat.model.FileModel import org.linphone.utils.Event import org.linphone.utils.LinphoneUtils +import kotlin.math.min class ConversationDocumentsListViewModel @UiThread constructor() : AbstractConversationViewModel() { companion object { private const val TAG = "[Conversation Documents List ViewModel]" + + private const val CONTENTS_PER_PAGE = 20 } val documentsList = MutableLiveData>() @@ -39,9 +44,11 @@ class ConversationDocumentsListViewModel val operationInProgress = MutableLiveData() val openDocumentEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } + private var totalDocumentsCount: Int = -1 + @WorkerThread override fun afterNotifyingChatRoomFound(sameOne: Boolean) { loadDocumentsList() @@ -56,16 +63,48 @@ class ConversationDocumentsListViewModel @WorkerThread private fun loadDocumentsList() { operationInProgress.postValue(true) - - val list = arrayListOf() Log.i( "$TAG Loading document contents for conversation [${LinphoneUtils.getConversationId( chatRoom )}]" ) - val documents = chatRoom.documentContents - Log.i("$TAG [${documents.size}] documents have been fetched") - for (documentContent in documents) { + + totalDocumentsCount = chatRoom.documentContentsSize + Log.i("$TAG Document contents size is [$totalDocumentsCount]") + + val contentsToLoad = min(totalDocumentsCount, CONTENTS_PER_PAGE) + val contents = chatRoom.getDocumentContentsRange(0, contentsToLoad) + Log.i("$TAG [${contents.size}] documents have been fetched") + + documentsList.postValue(getFileModelsListFromContents(contents)) + operationInProgress.postValue(false) + } + + @UiThread + fun loadMoreData(totalItemsCount: Int) { + coreContext.postOnCoreThread { + Log.i("$TAG Loading more data, current total is $totalItemsCount, max size is $totalDocumentsCount") + + if (totalItemsCount < totalDocumentsCount) { + var upperBound: Int = totalItemsCount + CONTENTS_PER_PAGE + if (upperBound > totalDocumentsCount) { + upperBound = totalDocumentsCount + } + val contents = chatRoom.getDocumentContentsRange(totalItemsCount, upperBound) + Log.i("$TAG [${contents.size}] contents loaded, adding them to list") + + val list = arrayListOf() + list.addAll(documentsList.value.orEmpty()) + list.addAll(getFileModelsListFromContents(contents)) + documentsList.postValue(list) + } + } + } + + @WorkerThread + private fun getFileModelsListFromContents(contents: Array): ArrayList { + val list = arrayListOf() + for (documentContent in contents) { val isEncrypted = documentContent.isFileEncrypted val originalPath = documentContent.filePath.orEmpty() val path = if (isEncrypted) { @@ -94,14 +133,11 @@ class ConversationDocumentsListViewModel val model = FileModel(path, name, size, timestamp, isEncrypted, originalPath, ephemeral) { - openDocumentEvent.postValue(Event(it)) - } + openDocumentEvent.postValue(Event(it)) + } list.add(model) } } - - Log.i("$TAG [${documents.size}] documents have been processed") - documentsList.postValue(list) - operationInProgress.postValue(false) + return list } } diff --git a/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationForwardMessageViewModel.kt b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationForwardMessageViewModel.kt index 569359e306..ea6481c3f9 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationForwardMessageViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationForwardMessageViewModel.kt @@ -30,9 +30,8 @@ import org.linphone.core.Address import org.linphone.core.ChatRoom import org.linphone.core.ChatRoomListenerStub import org.linphone.core.Conference +import org.linphone.core.Friend import org.linphone.core.tools.Log -import org.linphone.ui.main.contacts.model.ContactNumberOrAddressClickListener -import org.linphone.ui.main.contacts.model.ContactNumberOrAddressModel import org.linphone.ui.main.model.ConversationContactOrSuggestionModel import org.linphone.ui.main.viewmodel.AddressSelectionViewModel import org.linphone.utils.AppUtils @@ -49,32 +48,7 @@ class ConversationForwardMessageViewModel val operationInProgress = MutableLiveData() val chatRoomCreatedEvent: MutableLiveData> by lazy { - MutableLiveData>() - } - - val showNumberOrAddressPickerDialogEvent: MutableLiveData>> by lazy { - MutableLiveData>>() - } - - val hideNumberOrAddressPickerDialogEvent: MutableLiveData> by lazy { - MutableLiveData>() - } - - private val listener = object : ContactNumberOrAddressClickListener { - @UiThread - override fun onClicked(model: ContactNumberOrAddressModel) { - val address = model.address - coreContext.postOnCoreThread { - if (address != null) { - Log.i("$TAG Selected address is [${model.address.asStringUriOnly()}]") - onAddressSelected(model.address) - } - } - } - - @UiThread - override fun onLongPress(model: ContactNumberOrAddressModel) { - } + MutableLiveData() } private val chatRoomListener = object : ChatRoomListenerStub() { @@ -84,7 +58,7 @@ class ConversationForwardMessageViewModel if (state == ChatRoom.State.Instantiated) return val id = LinphoneUtils.getConversationId(chatRoom) - Log.i("$TAG Conversation [$id] (${chatRoom.subject}) state changed: [$state]") + Log.i("$TAG Conversation [$id] (${chatRoom.subjectUtf8}) state changed: [$state]") if (state == ChatRoom.State.Created) { Log.i("$TAG Conversation [$id] successfully created") @@ -105,8 +79,8 @@ class ConversationForwardMessageViewModel } @WorkerThread - private fun onAddressSelected(address: Address) { - hideNumberOrAddressPickerDialogEvent.postValue(Event(true)) + override fun onSingleAddressSelected(address: Address, friend: Friend?) { + dismissNumberOrAddressPickerDialogEvent.postValue(Event(true)) createOneToOneChatRoomWith(address) @@ -136,7 +110,7 @@ class ConversationForwardMessageViewModel val friend = model.friend if (friend == null) { Log.i("$TAG Friend is null, using address [${model.address.asStringUriOnly()}]") - onAddressSelected(model.address) + onSingleAddressSelected(model.address, null) return@postOnCoreThread } @@ -145,9 +119,9 @@ class ConversationForwardMessageViewModel Log.i( "$TAG Only 1 SIP address or phone number found for contact [${friend.name}], using it" ) - onAddressSelected(singleAvailableAddress) + onSingleAddressSelected(singleAvailableAddress, friend) } else { - val list = friend.getListOfSipAddressesAndPhoneNumbers(listener) + val list = friend.getListOfSipAddressesAndPhoneNumbers(numberOrAddressClickListener) Log.i( "$TAG [${list.size}] numbers or addresses found for contact [${friend.name}], showing selection dialog" ) diff --git a/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationInfoViewModel.kt b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationInfoViewModel.kt index 493d6833f0..0ef333ac89 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationInfoViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationInfoViewModel.kt @@ -56,6 +56,8 @@ class ConversationInfoViewModel val isGroup = MutableLiveData() + val hideSipAddresses = MutableLiveData() + val isEndToEndEncrypted = MutableLiveData() val subject = MutableLiveData() @@ -80,30 +82,32 @@ class ConversationInfoViewModel val friendAvailable = MutableLiveData() + val disableAddContact = MutableLiveData() + val groupLeftEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val historyDeletedEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val infoChangedEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val showParticipantAdminPopupMenuEvent: MutableLiveData>> by lazy { - MutableLiveData>>() + MutableLiveData() } val goToScheduleMeetingEvent: MutableLiveData>>> by lazy { - MutableLiveData>>>() + MutableLiveData() } private val chatRoomListener = object : ChatRoomListenerStub() { @WorkerThread override fun onParticipantAdded(chatRoom: ChatRoom, eventLog: EventLog) { - Log.i("$TAG A participant has been added to the group [${chatRoom.subject}]") + Log.i("$TAG A participant has been added to the group [${chatRoom.subjectUtf8}]") val message = AppUtils.getFormattedString( R.string.conversation_info_participant_added_to_conversation_toast, getParticipant(eventLog) @@ -116,7 +120,7 @@ class ConversationInfoViewModel @WorkerThread override fun onParticipantRemoved(chatRoom: ChatRoom, eventLog: EventLog) { - Log.i("$TAG A participant has been removed from the group [${chatRoom.subject}]") + Log.i("$TAG A participant has been removed from the group [${chatRoom.subjectUtf8}]") val message = AppUtils.getFormattedString( R.string.conversation_info_participant_removed_from_conversation_toast, getParticipant(eventLog) @@ -130,7 +134,7 @@ class ConversationInfoViewModel @WorkerThread override fun onParticipantAdminStatusChanged(chatRoom: ChatRoom, eventLog: EventLog) { Log.i( - "$TAG A participant has been given/removed administration rights for group [${chatRoom.subject}]" + "$TAG A participant has been given/removed administration rights for group [${chatRoom.subjectUtf8}]" ) if (eventLog.type == EventLog.Type.ConferenceParticipantSetAdmin) { val message = AppUtils.getFormattedString( @@ -147,16 +151,18 @@ class ConversationInfoViewModel } computeParticipantsList() + isMyselfAdmin.postValue(chatRoom.me?.isAdmin) } @WorkerThread override fun onSubjectChanged(chatRoom: ChatRoom, eventLog: EventLog) { Log.i( - "$TAG Conversation [${LinphoneUtils.getConversationId(chatRoom)}] has a new subject [${chatRoom.subject}]" + "$TAG Conversation [${LinphoneUtils.getConversationId(chatRoom)}] has a new subject [${chatRoom.subjectUtf8}]" ) showGreenToast(R.string.conversation_subject_changed_toast, R.drawable.check) - subject.postValue(chatRoom.subject) + subject.postValue(chatRoom.subjectUtf8) + computeParticipantsList() infoChangedEvent.postValue(Event(true)) } @@ -191,8 +197,10 @@ class ConversationInfoViewModel init { expandParticipants.value = true showPeerSipUri.value = false + disableAddContact.value = corePreferences.disableAddContact coreContext.postOnCoreThread { + hideSipAddresses.postValue(corePreferences.hideSipAddresses) coreContext.contactsManager.addListener(contactsListener) } } @@ -256,7 +264,7 @@ class ConversationInfoViewModel participantsList.add(participant.address.asStringUriOnly()) } goToScheduleMeetingEvent.postValue( - Event(Pair(chatRoom.subject.orEmpty(), participantsList)) + Event(Pair(chatRoom.subjectUtf8.orEmpty(), participantsList)) ) } else { val firstParticipant = chatRoom.participants.firstOrNull() @@ -457,21 +465,16 @@ class ConversationInfoViewModel val readOnly = chatRoom.isReadOnly isReadOnly.postValue(readOnly) if (readOnly) { - Log.w("$TAG Conversation with subject [${chatRoom.subject}] is read only!") + Log.w("$TAG Conversation with subject [${chatRoom.subjectUtf8}] is read only!") } - subject.postValue(chatRoom.subject) + subject.postValue(chatRoom.subjectUtf8) peerSipUri.postValue(chatRoom.peerAddress.asStringUriOnly()) val firstParticipant = chatRoom.participants.firstOrNull() if (firstParticipant != null) { val address = firstParticipant.address - val uri = if (corePreferences.onlyDisplaySipUriUsername) { - address.username ?: "" - } else { - LinphoneUtils.getAddressAsCleanStringUriOnly(address) - } - sipUri.postValue(uri) + sipUri.postValue(LinphoneUtils.getDisplayAddress(address)) val friend = coreContext.contactsManager.findContactByAddress(address) if (friend == null) { @@ -551,7 +554,7 @@ class ConversationInfoViewModel val avatar = if (groupChatRoom) { val fakeFriend = coreContext.core.createFriend() - fakeFriend.name = chatRoom.subject + fakeFriend.name = chatRoom.subjectUtf8 val model = ContactAvatarModel(fakeFriend) model.defaultToConversationIcon.postValue(true) model.updateSecurityLevelUsingConversation(chatRoom) @@ -559,7 +562,9 @@ class ConversationInfoViewModel } else { participantsList.first().avatarModel } - avatarModel.postValue(avatar) + if (!avatar.compare(avatarModel.value)) { + avatarModel.postValue(avatar) + } participants.postValue(participantsList) participantsLabel.postValue( diff --git a/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationMediaListViewModel.kt b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationMediaListViewModel.kt index bad0ea6724..dd17e4e7eb 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationMediaListViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationMediaListViewModel.kt @@ -22,16 +22,21 @@ package org.linphone.ui.main.chat.viewmodel import androidx.annotation.UiThread import androidx.annotation.WorkerThread import androidx.lifecycle.MutableLiveData +import org.linphone.LinphoneApplication.Companion.coreContext +import org.linphone.core.Content import org.linphone.core.tools.Log import org.linphone.ui.main.chat.model.FileModel import org.linphone.utils.Event import org.linphone.utils.LinphoneUtils +import kotlin.math.min class ConversationMediaListViewModel @UiThread constructor() : AbstractConversationViewModel() { companion object { private const val TAG = "[Conversation Media List ViewModel]" + + private const val CONTENTS_PER_PAGE = 50 } val mediaList = MutableLiveData>() @@ -39,9 +44,11 @@ class ConversationMediaListViewModel val operationInProgress = MutableLiveData() val openMediaEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } + private var totalMediaCount: Int = -1 + @WorkerThread override fun afterNotifyingChatRoomFound(sameOne: Boolean) { loadMediaList() @@ -56,19 +63,48 @@ class ConversationMediaListViewModel @WorkerThread private fun loadMediaList() { operationInProgress.postValue(true) - - val list = arrayListOf() Log.i( "$TAG Loading media contents for conversation [${LinphoneUtils.getConversationId( chatRoom )}]" ) - val media = chatRoom.mediaContents - Log.i("$TAG [${media.size}] media have been fetched") - for (mediaContent in media) { - // Do not display voice recordings here, even if they are media file - if (mediaContent.isVoiceRecording) continue + totalMediaCount = chatRoom.mediaContentsSize + Log.i("$TAG Media contents size is [$totalMediaCount]") + + val contentsToLoad = min(totalMediaCount, CONTENTS_PER_PAGE) + val contents = chatRoom.getMediaContentsRange(0, contentsToLoad) + Log.i("$TAG [${contents.size}] media have been fetched") + + mediaList.postValue(getFileModelsListFromContents(contents)) + operationInProgress.postValue(false) + } + + @UiThread + fun loadMoreData(totalItemsCount: Int) { + coreContext.postOnCoreThread { + Log.i("$TAG Loading more data, current total is $totalItemsCount, max size is $totalMediaCount") + + if (totalItemsCount < totalMediaCount) { + var upperBound: Int = totalItemsCount + CONTENTS_PER_PAGE + if (upperBound > totalMediaCount) { + upperBound = totalMediaCount + } + val contents = chatRoom.getMediaContentsRange(totalItemsCount, upperBound) + Log.i("$TAG [${contents.size}] contents loaded, adding them to list") + + val list = arrayListOf() + list.addAll(mediaList.value.orEmpty()) + list.addAll(getFileModelsListFromContents(contents)) + mediaList.postValue(list) + } + } + } + + @WorkerThread + private fun getFileModelsListFromContents(contents: Array): ArrayList { + val list = arrayListOf() + for (mediaContent in contents) { val isEncrypted = mediaContent.isFileEncrypted val originalPath = mediaContent.filePath.orEmpty() val path = if (isEncrypted) { @@ -85,14 +121,11 @@ class ConversationMediaListViewModel if (path.isNotEmpty() && name.isNotEmpty()) { val model = FileModel(path, name, size, timestamp, isEncrypted, originalPath, chatRoom.isEphemeralEnabled) { - openMediaEvent.postValue(Event(it)) - } + openMediaEvent.postValue(Event(it)) + } list.add(model) } } - - Log.i("$TAG [${media.size}] media have been processed") - mediaList.postValue(list) - operationInProgress.postValue(false) + return list } } diff --git a/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationViewModel.kt b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationViewModel.kt index 886d2b2d1a..18f0188e41 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationViewModel.kt @@ -44,7 +44,6 @@ import org.linphone.ui.main.chat.model.EventLogModel import org.linphone.ui.main.chat.model.FileModel import org.linphone.ui.main.chat.model.MessageModel import org.linphone.ui.main.contacts.model.ContactAvatarModel -import org.linphone.utils.AppUtils import org.linphone.utils.Event import org.linphone.utils.FileUtils import org.linphone.utils.LinphoneUtils @@ -89,6 +88,8 @@ class ConversationViewModel val composingLabel = MutableLiveData() + val composingIcon = MutableLiveData() + val searchBarVisible = MutableLiveData() val searchFilter = MutableLiveData() @@ -97,6 +98,8 @@ class ConversationViewModel val canSearchDown = MutableLiveData() + val canSearchUp = MutableLiveData() + val itemToScrollTo = MutableLiveData() val isUserScrollingUp = MutableLiveData() @@ -104,43 +107,43 @@ class ConversationViewModel val unreadMessagesCount = MutableLiveData() val focusSearchBarEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val fileToDisplayEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val sipUriToCallEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val conferenceToJoinEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val openWebBrowserEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val contactToDisplayEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val messageDeletedEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val updateEvents: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val forwardMessageEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val voiceRecordPlaybackEndedEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } var eventsList = arrayListOf() @@ -149,6 +152,8 @@ class ConversationViewModel private var latestMatch: EventLog? = null + private var latestMatchModel: MessageModel? = null + private val chatRoomListener = object : ChatRoomListenerStub() { @WorkerThread override fun onConferenceJoined(chatRoom: ChatRoom, eventLog: EventLog) { @@ -193,17 +198,6 @@ class ConversationViewModel Log.i("$TAG Conversation was marked as read") } - @WorkerThread - override fun onChatMessageSending(chatRoom: ChatRoom, eventLog: EventLog) { - val message = eventLog.chatMessage - Log.i("$TAG Message [$message] is being sent, marking conversation as read") - - // Prevents auto scroll to go to latest received message - chatRoom.markAsRead() - - addEvents(arrayOf(eventLog)) - } - @WorkerThread override fun onChatMessagesReceived(chatRoom: ChatRoom, eventLogs: Array) { Log.i("$TAG Received [${eventLogs.size}] new message(s)") @@ -256,7 +250,7 @@ class ConversationViewModel @WorkerThread override fun onSubjectChanged(chatRoom: ChatRoom, eventLog: EventLog) { - Log.i("$TAG Conversation subject changed [${chatRoom.subject}]") + Log.i("$TAG Conversation subject changed [${chatRoom.subjectUtf8}]") addEvents(arrayOf(eventLog)) } @@ -304,6 +298,22 @@ class ConversationViewModel Log.e("$TAG Failed to find matching message in conversation events list") } } + + @WorkerThread + override fun onMessageRetracted(chatRoom: ChatRoom, message: ChatMessage) { + updateRepliesUpTo(message) + + if (message.isOutgoing) { + messageDeletedEvent.postValue(Event(true)) + } + + unreadMessagesCount.postValue(chatRoom.unreadMessagesCount) + } + + @WorkerThread + override fun onMessageContentEdited(chatRoom: ChatRoom, message: ChatMessage) { + updateRepliesUpTo(message) + } } private val contactsListener = object : ContactsManager.ContactsListener { @@ -334,6 +344,7 @@ class ConversationViewModel isDisabledBecauseNotSecured.value = false searchInProgress.value = false canSearchDown.value = false + canSearchUp.value = false itemToScrollTo.value = -1 } @@ -365,38 +376,41 @@ class ConversationViewModel @UiThread fun openSearchBar() { + canSearchUp.value = true searchBarVisible.value = true focusSearchBarEvent.value = Event(true) } @UiThread fun closeSearchBar() { + coreContext.postOnCoreThread { + latestMatchModel?.highlightText("") + latestMatchModel = null + } + searchFilter.value = "" searchBarVisible.value = false focusSearchBarEvent.value = Event(false) latestMatch = null canSearchDown.value = false - - coreContext.postOnCoreThread { - for (eventLog in eventsList) { - if ((eventLog.model as? MessageModel)?.isTextHighlighted == true) { - eventLog.model.highlightText("") - } - } - } + canSearchUp.value = false } @UiThread fun searchUp() { - coreContext.postOnCoreThread { - searchChatMessage(SearchDirection.Up) + if (canSearchUp.value == true) { + coreContext.postOnCoreThread { + searchChatMessage(SearchDirection.Up) + } } } @UiThread fun searchDown() { - coreContext.postOnCoreThread { - searchChatMessage(SearchDirection.Down) + if (canSearchDown.value == true) { + coreContext.postOnCoreThread { + searchChatMessage(SearchDirection.Down) + } } } @@ -439,7 +453,9 @@ class ConversationViewModel Log.i("$TAG Removing chat message id [${chatMessageModel.id}] from events list") list.remove(found) + eventsList = list + updateEvents.postValue(Event(true)) isEmpty.postValue(eventsList.isEmpty()) } else { @@ -451,6 +467,17 @@ class ConversationViewModel Log.i("$TAG Deleting message id [${chatMessageModel.id}] from database") chatRoom.deleteMessage(chatMessageModel.chatMessage) messageDeletedEvent.postValue(Event(true)) + + updateRepliesUpTo(chatMessageModel.chatMessage) + } + } + + @UiThread + fun deleteChatMessageForEveryone(chatMessageModel: MessageModel) { + coreContext.postOnCoreThread { + val message = chatMessageModel.chatMessage + Log.i("$TAG Sending order to delete contents of message [${message.messageId}] to every participant of the conversation") + chatRoom.retractMessage(message) } } @@ -553,7 +580,7 @@ class ConversationViewModel if (!chatRoom.hasCapability(ChatRoom.Capabilities.Encrypted.toInt())) { if (LinphoneUtils.getAccountForAddress(chatRoom.localAddress)?.params?.instantMessagingEncryptionMandatory == true) { Log.w( - "$TAG Conversation with subject [${chatRoom.subject}] is considered as read-only because it isn't encrypted and default account is in secure mode" + "$TAG Conversation with subject [${chatRoom.subjectUtf8}] is considered as read-only because it isn't encrypted and default account is in secure mode" ) isDisabledBecauseNotSecured.postValue(true) } else { @@ -583,6 +610,19 @@ class ConversationViewModel } } + @UiThread + fun addSentMessageToEventsList(message: ChatMessage) { + coreContext.postOnCoreThread { + val eventLog = message.eventLog + if (eventLog != null) { + Log.i("$TAG Adding sent message with ID [${message.messageId}] to events list") + addEvents(arrayOf(eventLog)) + } else { + Log.e("$TAG Failed to get event log for sent message with ID [${message.messageId}]") + } + } + } + @WorkerThread private fun configureChatRoom() { if (!isChatRoomInitialized()) return @@ -615,12 +655,12 @@ class ConversationViewModel val readOnly = chatRoom.isReadOnly isReadOnly.postValue(readOnly) if (readOnly) { - Log.w("$TAG Conversation with subject [${chatRoom.subject}] is read only!") + Log.w("$TAG Conversation with subject [${chatRoom.subjectUtf8}] is read only!") } checkIfConversationShouldBeDisabledForSecurityReasons() - subject.postValue(chatRoom.subject) + subject.postValue(chatRoom.subjectUtf8) computeParticipantsInfo() @@ -653,7 +693,7 @@ class ConversationViewModel val avatar = if (LinphoneUtils.isChatRoomAGroup(chatRoom)) { val fakeFriend = coreContext.core.createFriend() - fakeFriend.name = chatRoom.subject + fakeFriend.name = chatRoom.subjectUtf8 val model = ContactAvatarModel(fakeFriend) model.updateSecurityLevelUsingConversation(chatRoom) model @@ -679,7 +719,7 @@ class ConversationViewModel @WorkerThread private fun addEvents(eventLogs: Array) { - Log.i("$TAG Adding [${eventLogs.size}] events") + Log.i("$TAG Adding [${eventLogs.size}] event(s)") // Need to use a new list, otherwise ConversationFragment's dataObserver isn't triggered... val list = arrayListOf() list.addAll(eventsList) @@ -903,29 +943,30 @@ class ConversationViewModel } } + @WorkerThread + private fun updateRepliesUpTo(chatMessage: ChatMessage) { + for (model in eventsList.reversed()) { + if (model.model is MessageModel) { + if (model.model.replyToMessageId == chatMessage.messageId) { + model.model.computeReplyInfo() + } + + if (model.model.timestamp < chatMessage.time) { + break + } + } + } + } + @WorkerThread private fun computeComposingLabel() { if (!isChatRoomInitialized()) return - val composingFriends = arrayListOf() - var label = "" - for (address in chatRoom.composingAddresses) { - val avatar = coreContext.contactsManager.getContactAvatarModelForAddress(address) - val name = avatar.name.value ?: LinphoneUtils.getDisplayName(address) - composingFriends.add(name) - label += "$name, " - } - if (composingFriends.isNotEmpty()) { - label = label.dropLast(2) - val format = AppUtils.getStringWithPlural( - R.plurals.conversation_composing_label, - composingFriends.size, - label - ) - composingLabel.postValue(format) - } else { - composingLabel.postValue("") - } + val pair = LinphoneUtils.getComposingIconAndText(chatRoom) + val icon = pair.first + composingIcon.postValue(icon) + val label = pair.second + composingLabel.postValue(label) } @WorkerThread @@ -976,15 +1017,26 @@ class ConversationViewModel val index = eventsList.indexOf(found) itemToScrollTo.postValue(index) } + // Disable button as latest result has been reached + if (direction == SearchDirection.Down) { + canSearchDown.postValue(false) + } else { + canSearchUp.postValue(false) + } R.string.conversation_search_no_more_match } showRedToast(message, R.drawable.magnifying_glass) } else { + canSearchDown.postValue(true) + canSearchUp.postValue(true) + + // Clear highlight from previous match + latestMatchModel?.highlightText("") + Log.i( "$TAG Found result [${match.chatMessage?.messageId}] while looking up for message with text [$textToSearch] in direction [$direction] starting from message [${latestMatch?.chatMessage?.messageId}]" ) latestMatch = match - val found = eventsList.find { it.eventLog == match } @@ -993,13 +1045,12 @@ class ConversationViewModel loadMessagesUpTo(match) } else { Log.i("$TAG Found result is already in history, no need to load more history") - (found.model as? MessageModel)?.highlightText(textToSearch) + latestMatchModel = (found.model as? MessageModel) + latestMatchModel?.highlightText(textToSearch) val index = eventsList.indexOf(found) itemToScrollTo.postValue(index) searchInProgress.postValue(false) } - - canSearchDown.postValue(true) } } diff --git a/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationsListViewModel.kt b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationsListViewModel.kt index 72bf6f3494..0ad7410216 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationsListViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationsListViewModel.kt @@ -23,17 +23,31 @@ import androidx.annotation.UiThread import androidx.annotation.WorkerThread import androidx.lifecycle.MutableLiveData import org.linphone.LinphoneApplication.Companion.coreContext +import org.linphone.LinphoneApplication.Companion.corePreferences import org.linphone.R import org.linphone.contacts.ContactsManager +import org.linphone.core.Address import org.linphone.core.ChatMessage import org.linphone.core.ChatRoom +import org.linphone.core.ChatRoomListenerStub +import org.linphone.core.Conference import org.linphone.core.Core import org.linphone.core.CoreListenerStub import org.linphone.core.Friend +import org.linphone.core.MagicSearch +import org.linphone.core.MagicSearchListenerStub +import org.linphone.core.SearchResult import org.linphone.core.tools.Log import org.linphone.ui.main.chat.model.ConversationModel +import org.linphone.ui.main.chat.model.ConversationModelWrapper +import org.linphone.ui.main.contacts.model.ContactAvatarModel +import org.linphone.ui.main.model.ConversationContactOrSuggestionModel import org.linphone.ui.main.viewmodel.AbstractMainViewModel +import org.linphone.utils.AppUtils +import org.linphone.utils.Event import org.linphone.utils.LinphoneUtils +import java.text.Collator +import java.util.Locale class ConversationsListViewModel @UiThread @@ -42,10 +56,51 @@ class ConversationsListViewModel private const val TAG = "[Conversations List ViewModel]" } - val conversations = MutableLiveData>() + val conversations = MutableLiveData>() val fetchInProgress = MutableLiveData() + val chatRoomCreatedEvent: MutableLiveData> by lazy { + MutableLiveData() + } + + private val tempConversationsList = ArrayList() + + private val magicSearch = coreContext.core.createMagicSearch() + + private val magicSearchListener = object : MagicSearchListenerStub() { + @WorkerThread + override fun onSearchResultsReceived(magicSearch: MagicSearch) { + Log.i("$TAG Magic search contacts available") + val results = magicSearch.lastSearch + processMagicSearchResults(results) + fetchInProgress.postValue(false) + } + } + + private val chatRoomListener = object : ChatRoomListenerStub() { + @WorkerThread + override fun onStateChanged(chatRoom: ChatRoom, newState: ChatRoom.State?) { + val state = chatRoom.state + if (state == ChatRoom.State.Instantiated) return + + val id = LinphoneUtils.getConversationId(chatRoom) + Log.i("$TAG Conversation [$id] (${chatRoom.subjectUtf8}) state changed: [$state]") + + if (state == ChatRoom.State.Created) { + Log.i("$TAG Conversation [$id] successfully created") + chatRoom.removeListener(this) + fetchInProgress.postValue(false) + chatRoomCreatedEvent.postValue(Event(LinphoneUtils.getConversationId(chatRoom))) + } else if (state == ChatRoom.State.CreationFailed) { + Log.e("$TAG Conversation [$id] creation has failed!") + chatRoom.removeListener(this) + fetchInProgress.postValue(false) + showRedToast(R.string.conversation_failed_to_create_toast, R.drawable.warning_circle) + } + } + } + private val coreListener = object : CoreListenerStub() { @WorkerThread override fun onChatRoomStateChanged( @@ -68,7 +123,7 @@ class ConversationsListViewModel override fun onMessageSent(core: Core, chatRoom: ChatRoom, message: ChatMessage) { val id = LinphoneUtils.getConversationId(chatRoom) val found = conversations.value.orEmpty().find { - it.id == id + it.conversationModel?.id == id } if (found == null) { Log.i("$TAG Message sent for a conversation not yet in the list (probably was empty), adding it") @@ -87,7 +142,7 @@ class ConversationsListViewModel ) { val id = LinphoneUtils.getConversationId(chatRoom) val found = conversations.value.orEmpty().find { - it.id == id + it.conversationModel?.id == id } if (found == null) { Log.i("$TAG Message(s) received for a conversation not yet in the list (probably was empty), adding it") @@ -104,8 +159,10 @@ class ConversationsListViewModel override fun onContactsLoaded() { Log.i("$TAG Contacts have been (re)loaded, updating list") for (model in conversations.value.orEmpty()) { - model.computeParticipants() - model.updateLastMessage() + if (model.isConversation) { + model.conversationModel?.computeParticipants() + model.conversationModel?.updateLastMessage() + } } } @@ -119,6 +176,7 @@ class ConversationsListViewModel coreContext.postOnCoreThread { core -> coreContext.contactsManager.addListener(contactsListener) core.addListener(coreListener) + magicSearch.addListener(magicSearchListener) computeChatRoomsList(currentFilter) } @@ -129,9 +187,10 @@ class ConversationsListViewModel super.onCleared() coreContext.postOnCoreThread { core -> - conversations.value.orEmpty().forEach(ConversationModel::destroy) + conversations.value.orEmpty().forEach(ConversationModelWrapper::destroy) coreContext.contactsManager.removeListener(contactsListener) core.removeListener(coreListener) + magicSearch.removeListener(magicSearchListener) } } @@ -144,14 +203,23 @@ class ConversationsListViewModel @WorkerThread private fun computeChatRoomsList(filter: String) { - conversations.value.orEmpty().forEach(ConversationModel::destroy) + conversations.value.orEmpty().forEach(ConversationModelWrapper::destroy) if (conversations.value.orEmpty().isEmpty()) { fetchInProgress.postValue(true) } - val list = arrayListOf() - var count = 0 + val isFilterEmpty = filter.isEmpty() + if (!isFilterEmpty) { + magicSearch.getContactsListAsync( + filter, + corePreferences.contactsFilter, + MagicSearch.Source.All.toInt(), + MagicSearch.Aggregation.Friend + ) + } + + val list = arrayListOf() val account = LinphoneUtils.getDefaultAccount() val chatRooms = if (filter.isEmpty()) { @@ -161,15 +229,16 @@ class ConversationsListViewModel } for (chatRoom in chatRooms.orEmpty()) { val model = ConversationModel(chatRoom) - list.add(model) - count += 1 - - if (count == 15) { - conversations.postValue(list) - } + list.add(ConversationModelWrapper(model)) } - conversations.postValue(list) + if (isFilterEmpty) { + conversations.postValue(list) + } else { + fetchInProgress.postValue(true) + tempConversationsList.clear() + tempConversationsList.addAll(list) + } } @WorkerThread @@ -184,6 +253,14 @@ class ConversationsListViewModel return } + val conferenceInfo = chatRoom.conferenceInfo + if (conferenceInfo != null) { + Log.w( + "$TAG Chat room with identifier [$identifier] was created but not displaying it because it is related to a conference" + ) + return + } + val hideEmptyChatRooms = coreContext.core.config.getBool("misc", "hide_empty_chat_rooms", true) // Hide empty chat rooms only applies to 1-1 conversations if (hideEmptyChatRooms && !LinphoneUtils.isChatRoomAGroup(chatRoom) && chatRoom.lastMessageInHistory == null) { @@ -193,7 +270,7 @@ class ConversationsListViewModel val currentList = conversations.value.orEmpty() val found = currentList.find { - it.chatRoom.identifier == identifier + it.conversationModel?.chatRoom?.identifier == identifier } if (found != null) { Log.w("$TAG Created chat room with identifier [$identifier] is already in the list, skipping") @@ -208,9 +285,9 @@ class ConversationsListViewModel if (found == null) return } - val newList = arrayListOf() + val newList = arrayListOf() val model = ConversationModel(chatRoom) - newList.add(model) + newList.add(ConversationModelWrapper(model)) newList.addAll(currentList) Log.i("$TAG Adding chat room with identifier [$identifier] to list") conversations.postValue(newList) @@ -221,10 +298,10 @@ class ConversationsListViewModel val currentList = conversations.value.orEmpty() val identifier = chatRoom.identifier val found = currentList.find { - it.chatRoom.identifier == identifier + it.conversationModel?.chatRoom?.identifier == identifier } if (found != null) { - val newList = arrayListOf() + val newList = arrayListOf() newList.addAll(currentList) newList.remove(found) found.destroy() @@ -241,12 +318,186 @@ class ConversationsListViewModel @WorkerThread private fun reorderChatRooms() { + if (currentFilter.isNotEmpty()) { + Log.w("$TAG List filter isn't empty, do not re-order list") + return + } + Log.i("$TAG Re-ordering conversations") - val sortedList = arrayListOf() + val sortedList = arrayListOf() sortedList.addAll(conversations.value.orEmpty()) sortedList.sortByDescending { - it.chatRoom.lastUpdateTime + it.conversationModel?.chatRoom?.lastUpdateTime } conversations.postValue(sortedList) } + + @WorkerThread + private fun processMagicSearchResults(results: Array) { + Log.i("$TAG Processing [${results.size}] results") + + val contactsList = arrayListOf() + val suggestionsList = arrayListOf() + val requestList = arrayListOf() + + for (result in results) { + val address = result.address + val friend = result.friend + if (friend != null) { + val found = contactsList.find { it.contactModel?.friend == friend } + if (found != null) continue + + val mainAddress = address ?: LinphoneUtils.getFirstAvailableAddressForFriend(friend) + if (mainAddress != null) { + val model = ConversationContactOrSuggestionModel(mainAddress, friend = friend) + val avatarModel = coreContext.contactsManager.getContactAvatarModelForFriend( + friend + ) + model.avatarModel.postValue(avatarModel) + contactsList.add(ConversationModelWrapper(null, model)) + } else { + Log.w("$TAG Found friend [${friend.name}] in search results but no Address could be found, skipping it") + } + } else if (address != null) { + if (result.sourceFlags == MagicSearch.Source.Request.toInt()) { + val model = ConversationContactOrSuggestionModel(address) { + coreContext.startAudioCall(address) + } + val avatarModel = getContactAvatarModelForAddress(address) + model.avatarModel.postValue(avatarModel) + requestList.add(ConversationModelWrapper(null, model)) + continue + } + + val defaultAccountAddress = coreContext.core.defaultAccount?.params?.identityAddress + if (defaultAccountAddress != null && address.weakEqual(defaultAccountAddress)) { + Log.i("$TAG Removing from suggestions current default account address") + continue + } + + val model = ConversationContactOrSuggestionModel(address) { + coreContext.startAudioCall(address) + } + val avatarModel = getContactAvatarModelForAddress(address) + model.avatarModel.postValue(avatarModel) + suggestionsList.add(ConversationModelWrapper(null, model)) + } + } + + val collator = Collator.getInstance(Locale.getDefault()) + contactsList.sortWith { model1, model2 -> + collator.compare(model1.contactModel?.name, model2.contactModel?.name) + } + suggestionsList.sortWith { model1, model2 -> + collator.compare(model1.contactModel?.name, model2.contactModel?.name) + } + + val list = arrayListOf() + list.addAll(tempConversationsList) + list.addAll(contactsList) + list.addAll(suggestionsList) + list.addAll(requestList) + conversations.postValue(list) + Log.i( + "$TAG Processed [${results.size}] results: [${contactsList.size}] contacts and [${suggestionsList.size}] suggestions" + ) + } + + @WorkerThread + private fun getContactAvatarModelForAddress(address: Address): ContactAvatarModel { + val fakeFriend = coreContext.core.createFriend() + fakeFriend.name = LinphoneUtils.getDisplayName(address) + fakeFriend.address = address + return ContactAvatarModel(fakeFriend) + } + + @WorkerThread + fun createOneToOneChatRoomWith(remote: Address) { + val core = coreContext.core + val account = core.defaultAccount + if (account == null) { + Log.e( + "$TAG No default account found, can't create conversation with [${remote.asStringUriOnly()}]!" + ) + return + } + + fetchInProgress.postValue(true) + + val params = coreContext.core.createConferenceParams(null) + params.isChatEnabled = true + params.isGroupEnabled = false + params.subject = AppUtils.getString(R.string.conversation_one_to_one_hidden_subject) + params.account = account + + val chatParams = params.chatParams ?: return + chatParams.ephemeralLifetime = 0 // Make sure ephemeral is disabled by default + + val sameDomain = remote.domain == corePreferences.defaultDomain && remote.domain == account.params.domain + if (account.params.instantMessagingEncryptionMandatory && sameDomain) { + Log.i("$TAG Account is in secure mode & domain matches, creating an E2E encrypted conversation") + chatParams.backend = ChatRoom.Backend.FlexisipChat + params.securityLevel = Conference.SecurityLevel.EndToEnd + } else if (!account.params.instantMessagingEncryptionMandatory) { + if (LinphoneUtils.isEndToEndEncryptedChatAvailable(core)) { + Log.i( + "$TAG Account is in interop mode but LIME is available, creating an E2E encrypted conversation" + ) + chatParams.backend = ChatRoom.Backend.FlexisipChat + params.securityLevel = Conference.SecurityLevel.EndToEnd + } else { + Log.i( + "$TAG Account is in interop mode but LIME isn't available, creating a SIP simple conversation" + ) + chatParams.backend = ChatRoom.Backend.Basic + params.securityLevel = Conference.SecurityLevel.None + } + } else { + Log.e( + "$TAG Account is in secure mode, can't chat with SIP address of different domain [${remote.asStringUriOnly()}]" + ) + fetchInProgress.postValue(false) + showRedToast(R.string.conversation_invalid_participant_due_to_security_mode_toast, R.drawable.warning_circle) + return + } + + val participants = arrayOf(remote) + val localAddress = account.params.identityAddress + val existingChatRoom = core.searchChatRoom(params, localAddress, null, participants) + if (existingChatRoom == null) { + Log.i( + "$TAG No existing 1-1 conversation between local account [${localAddress?.asStringUriOnly()}] and remote [${remote.asStringUriOnly()}] was found for given parameters, let's create it" + ) + val chatRoom = core.createChatRoom(params, participants) + if (chatRoom != null) { + if (chatParams.backend == ChatRoom.Backend.FlexisipChat) { + val state = chatRoom.state + if (state == ChatRoom.State.Created) { + val id = LinphoneUtils.getConversationId(chatRoom) + Log.i("$TAG 1-1 conversation [$id] has been created") + fetchInProgress.postValue(false) + chatRoomCreatedEvent.postValue(Event(LinphoneUtils.getConversationId(chatRoom))) + } else { + Log.i("$TAG Conversation isn't in Created state yet (state is [$state]), wait for it") + chatRoom.addListener(chatRoomListener) + } + } else { + val id = LinphoneUtils.getConversationId(chatRoom) + Log.i("$TAG Conversation successfully created [$id]") + fetchInProgress.postValue(false) + chatRoomCreatedEvent.postValue(Event(LinphoneUtils.getConversationId(chatRoom))) + } + } else { + Log.e("$TAG Failed to create 1-1 conversation with [${remote.asStringUriOnly()}]!") + fetchInProgress.postValue(false) + showRedToast(R.string.conversation_failed_to_create_toast, R.drawable.warning_circle) + } + } else { + Log.w( + "$TAG A 1-1 conversation between local account [${localAddress?.asStringUriOnly()}] and remote [${remote.asStringUriOnly()}] for given parameters already exists!" + ) + fetchInProgress.postValue(false) + chatRoomCreatedEvent.postValue(Event(LinphoneUtils.getConversationId(existingChatRoom))) + } + } } diff --git a/app/src/main/java/org/linphone/ui/main/chat/viewmodel/SendMessageInConversationViewModel.kt b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/SendMessageInConversationViewModel.kt index 6f8d062a8a..284bf8943b 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/viewmodel/SendMessageInConversationViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/SendMessageInConversationViewModel.kt @@ -84,6 +84,10 @@ class SendMessageInConversationViewModel val attachments = MutableLiveData>() + val isEditing = MutableLiveData() + + val isEditingMessage = MutableLiveData() + val isReplying = MutableLiveData() val isReplyingTo = MutableLiveData() @@ -106,6 +110,8 @@ class SendMessageInConversationViewModel val voiceRecordPlayerPosition = MutableLiveData() + val isComputingParticipantsList = MutableLiveData() + private lateinit var voiceRecordPlayer: Player private val playerListener = PlayerListener { @@ -114,29 +120,37 @@ class SendMessageInConversationViewModel } val requestKeyboardHidingEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val emojiToAddEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val participantUsernameToAddEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val askRecordAudioPermissionEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() + } + + val messageSentEvent: MutableLiveData> by lazy { + MutableLiveData() } lateinit var chatRoom: ChatRoom private var chatMessageToReplyTo: ChatMessage? = null + private var chatMessageToEdit: ChatMessage? = null + private lateinit var voiceMessageRecorder: Recorder private var voiceRecordAudioFocusRequest: AudioFocusRequestCompat? = null + private var participantsListFilter = "" + private val chatRoomListener = object : ChatRoomListenerStub() { @WorkerThread override fun onParticipantAdded(chatRoom: ChatRoom, eventLog: EventLog) { @@ -157,6 +171,7 @@ class SendMessageInConversationViewModel isKeyboardOpen.value = false isEmojiPickerOpen.value = false areFilePickersOpen.value = false + isParticipantsListOpen.value = false isVoiceRecording.value = false isPlayingVoiceRecord.value = false isCallConversation.value = false @@ -228,8 +243,41 @@ class SendMessageInConversationViewModel areFilePickersOpen.value = false } + @UiThread + fun editMessage(model: MessageModel) { + if (isReplying.value == true) { + cancelReply() + } + if (isVoiceRecording.value == true) { + cancelVoiceMessageRecording() + } + + val newValue = model.text.value?.toString() ?: "" + textToSend.value = newValue + + coreContext.postOnCoreThread { + val message = model.chatMessage + Log.i("$TAG Pending message edit [${message.messageId}]") + chatMessageToEdit = message + isEditingMessage.postValue(LinphoneUtils.getFormattedTextDescribingMessage(message)) + isEditing.postValue(true) + } + } + + @UiThread + fun cancelEdit() { + Log.i("$TAG Cancelling edit") + isEditing.value = false + chatMessageToEdit = null + textToSend.value = "" + } + @UiThread fun replyToMessage(model: MessageModel) { + if (isEditing.value == true) { + cancelEdit() + } + coreContext.postOnCoreThread { val message = model.chatMessage Log.i("$TAG Pending reply to message [${message.messageId}]") @@ -253,9 +301,12 @@ class SendMessageInConversationViewModel val isBasicChatRoom: Boolean = chatRoom.hasCapability(ChatRoom.Capabilities.Basic.toInt()) val messageToReplyTo = chatMessageToReplyTo + val messageToEdit = chatMessageToEdit val message = if (messageToReplyTo != null) { Log.i("$TAG Sending message as reply to [${messageToReplyTo.messageId}]") chatRoom.createReplyMessage(messageToReplyTo) + } else if (messageToEdit != null) { + chatRoom.createReplacesMessage(messageToEdit) } else { chatRoom.createEmptyMessage() } @@ -278,6 +329,7 @@ class SendMessageInConversationViewModel val voiceMessage = chatRoom.createEmptyMessage() voiceMessage.addContent(content) voiceMessage.send() + messageSentEvent.postValue(Event(voiceMessage)) } else { message.addContent(content) } @@ -309,6 +361,7 @@ class SendMessageInConversationViewModel val fileMessage = chatRoom.createEmptyMessage() fileMessage.addFileContent(content) fileMessage.send() + messageSentEvent.postValue(Event(fileMessage)) } else { message.addFileContent(content) contentAdded = true @@ -319,11 +372,13 @@ class SendMessageInConversationViewModel if (message.contents.isNotEmpty()) { Log.i("$TAG Sending message") message.send() + messageSentEvent.postValue(Event(message)) } Log.i("$TAG Message sent, re-setting defaults") textToSend.postValue("") isReplying.postValue(false) + isEditing.postValue(false) isFileAttachmentsListOpen.postValue(false) isParticipantsListOpen.postValue(false) isEmojiPickerOpen.postValue(false) @@ -338,15 +393,20 @@ class SendMessageInConversationViewModel attachments.postValue(attachmentsList) chatMessageToReplyTo = null + chatMessageToEdit = null maxNumberOfAttachmentsReached.postValue(false) } } @UiThread - fun notifyChatMessageIsBeingComposed() { + fun notifyComposing(composing: Boolean) { coreContext.postOnCoreThread { if (::chatRoom.isInitialized) { - chatRoom.compose() + if (composing) { + chatRoom.composeTextMessage() + } else { + chatRoom.stopComposing() + } } } } @@ -361,6 +421,10 @@ class SendMessageInConversationViewModel @UiThread fun closeParticipantsList() { isParticipantsListOpen.value = false + coreContext.postOnCoreThread { + participantsListFilter = "" + computeParticipantsList() + } } @UiThread @@ -451,6 +515,7 @@ class SendMessageInConversationViewModel val forwardedMessage = chatRoom.createForwardMessage(messageToForward) Log.i("$TAG Sending forwarded message") forwardedMessage.send() + messageSentEvent.postValue(Event(forwardedMessage)) showGreenToast(R.string.conversation_message_forwarded_toast, R.drawable.forward) } @@ -488,6 +553,7 @@ class SendMessageInConversationViewModel @UiThread fun stopVoiceMessageRecording() { coreContext.postOnCoreThread { + chatRoom.stopComposing() stopVoiceRecorder() } } @@ -495,6 +561,7 @@ class SendMessageInConversationViewModel @UiThread fun cancelVoiceMessageRecording() { coreContext.postOnCoreThread { + chatRoom.stopComposing() stopVoiceRecorder() val path = voiceMessageRecorder.file @@ -521,23 +588,58 @@ class SendMessageInConversationViewModel } @WorkerThread - private fun computeParticipantsList() { + fun filterParticipantsList(filter: String) { + Log.i("$TAG Filtering participants list using user-input [$filter]") + if (filter.isEmpty() && participantsListFilter.isNotEmpty()) { + participantsListFilter = "" + computeParticipantsList() + return + } + + if (filter.length >= participantsListFilter.length) { + isComputingParticipantsList.postValue(true) + participantsListFilter = filter + val currentList = participants.value.orEmpty() + val newList = currentList.filter { + it.address.username.orEmpty().contains(filter) || it.avatarModel.contactName?.contains(filter) == true + } + participants.postValue(newList as ArrayList) + isComputingParticipantsList.postValue(false) + } else { + participantsListFilter = filter + computeParticipantsList(filter) + } + } + + @WorkerThread + private fun computeParticipantsList(filter: String = "") { + isComputingParticipantsList.postValue(true) val participantsList = arrayListOf() - for (participant in chatRoom.participants) { - val model = ParticipantModel(participant.address, onClicked = { clicked -> - Log.i("$TAG Clicked on participant [${clicked.sipUri}]") - coreContext.postOnCoreThread { - val username = clicked.address.username - if (!username.isNullOrEmpty()) { - participantUsernameToAddEvent.postValue(Event(username)) + if (::chatRoom.isInitialized) { + for (participant in chatRoom.participants) { + val model = ParticipantModel(participant.address, onClicked = { clicked -> + Log.i("$TAG Clicked on participant [${clicked.sipUri}]") + coreContext.postOnCoreThread { + val username = clicked.address.username + if (!username.isNullOrEmpty()) { + participantUsernameToAddEvent.postValue(Event(username.substring(participantsListFilter.length))) + } } + }) + + if ( + filter.isEmpty() || + participant.address.asStringUriOnly().contains(filter) || + model.avatarModel.contactName?.contains(filter) == true + ) { + participantsList.add(model) } - }) - participantsList.add(model) + } } participants.postValue(participantsList) + isComputingParticipantsList.postValue(false) } @WorkerThread @@ -588,6 +690,7 @@ class SendMessageInConversationViewModel } else -> {} } + chatRoom.composeVoiceMessage() val duration = voiceMessageRecorder.duration val formattedDuration = SimpleDateFormat("mm:ss", Locale.getDefault()).format(duration) // duration is in ms diff --git a/app/src/main/java/org/linphone/ui/main/chat/viewmodel/StartConversationViewModel.kt b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/StartConversationViewModel.kt index 4523e96d9b..3df35525b9 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/viewmodel/StartConversationViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/StartConversationViewModel.kt @@ -30,6 +30,7 @@ import org.linphone.core.Address import org.linphone.core.ChatRoom import org.linphone.core.ChatRoomListenerStub import org.linphone.core.Conference +import org.linphone.core.Friend import org.linphone.core.tools.Log import org.linphone.ui.main.viewmodel.AddressSelectionViewModel import org.linphone.utils.AppUtils @@ -51,12 +52,8 @@ class StartConversationViewModel val operationInProgress = MutableLiveData() - val chatRoomCreationErrorEvent: MutableLiveData> by lazy { - MutableLiveData>() - } - val chatRoomCreatedEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } private val chatRoomListener = object : ChatRoomListenerStub() { @@ -66,7 +63,7 @@ class StartConversationViewModel if (state == ChatRoom.State.Instantiated) return val id = LinphoneUtils.getConversationId(chatRoom) - Log.i("$TAG Conversation [$id] (${chatRoom.subject}) state changed: [$state]") + Log.i("$TAG Conversation [$id] (${chatRoom.subjectUtf8}) state changed: [$state]") if (state == ChatRoom.State.Created) { Log.i("$TAG Conversation [$id] successfully created") @@ -77,9 +74,7 @@ class StartConversationViewModel Log.e("$TAG Conversation [$id] creation has failed!") chatRoom.removeListener(this) operationInProgress.postValue(false) - chatRoomCreationErrorEvent.postValue( - Event(R.string.conversation_failed_to_create_toast) - ) + showRedToast(R.string.conversation_failed_to_create_toast, R.drawable.warning_circle) } } } @@ -93,6 +88,11 @@ class StartConversationViewModel updateGroupChatButtonVisibility() } + @WorkerThread + override fun onSingleAddressSelected(address: Address, friend: Friend?) { + createOneToOneChatRoomWith(address) + } + @UiThread fun createGroupChatRoom() { coreContext.postOnCoreThread { core -> @@ -153,9 +153,7 @@ class StartConversationViewModel } else { Log.e("$TAG Failed to create group conversation [$groupChatRoomSubject]!") operationInProgress.postValue(false) - chatRoomCreationErrorEvent.postValue( - Event(R.string.conversation_failed_to_create_toast) - ) + showRedToast(R.string.conversation_failed_to_create_toast, R.drawable.warning_circle) } } } @@ -206,9 +204,7 @@ class StartConversationViewModel "$TAG Account is in secure mode, can't chat with SIP address of different domain [${remote.asStringUriOnly()}]" ) operationInProgress.postValue(false) - chatRoomCreationErrorEvent.postValue( - Event(R.string.conversation_invalid_participant_due_to_security_mode_toast) - ) + showRedToast(R.string.conversation_invalid_participant_due_to_security_mode_toast, R.drawable.warning_circle) return } @@ -241,9 +237,7 @@ class StartConversationViewModel } else { Log.e("$TAG Failed to create 1-1 conversation with [${remote.asStringUriOnly()}]!") operationInProgress.postValue(false) - chatRoomCreationErrorEvent.postValue( - Event(R.string.conversation_failed_to_create_toast) - ) + showRedToast(R.string.conversation_failed_to_create_toast, R.drawable.warning_circle) } } else { Log.w( diff --git a/app/src/main/java/org/linphone/ui/main/contacts/adapter/ContactsListAdapter.kt b/app/src/main/java/org/linphone/ui/main/contacts/adapter/ContactsListAdapter.kt index 28004dc027..fce8ee7352 100644 --- a/app/src/main/java/org/linphone/ui/main/contacts/adapter/ContactsListAdapter.kt +++ b/app/src/main/java/org/linphone/ui/main/contacts/adapter/ContactsListAdapter.kt @@ -41,11 +41,11 @@ class ContactsListAdapter( var selectedAdapterPosition = -1 val contactClickedEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val contactLongClickedEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { @@ -125,12 +125,12 @@ class ContactsListAdapter( val previousItem = bindingAdapterPosition - 1 val previousLetter = if (previousItem >= 0) { - getItem(previousItem).contactName?.get(0).toString() + getItem(previousItem).sortingName?.get(0).toString() } else { "" } - val currentLetter = contactModel.contactName?.get(0).toString() + val currentLetter = contactModel.sortingName?.get(0).toString() val displayLetter = previousLetter.isEmpty() || currentLetter != previousLetter firstContactStartingByThatLetter = displayLetter @@ -160,7 +160,7 @@ class ContactsListAdapter( } override fun areContentsTheSame(oldItem: ContactAvatarModel, newItem: ContactAvatarModel): Boolean { - return false // oldItem & newItem are always the same because fetched from cache, so return false to force refresh + return newItem.compare(oldItem) } } } diff --git a/app/src/main/java/org/linphone/ui/main/contacts/fragment/ContactFragment.kt b/app/src/main/java/org/linphone/ui/main/contacts/fragment/ContactFragment.kt index da03bc3334..b6f0df787f 100644 --- a/app/src/main/java/org/linphone/ui/main/contacts/fragment/ContactFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/contacts/fragment/ContactFragment.kt @@ -21,9 +21,6 @@ package org.linphone.ui.main.contacts.fragment import android.app.Dialog import android.content.ActivityNotFoundException -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context import android.content.Intent import android.os.Bundle import android.provider.ContactsContract @@ -118,6 +115,24 @@ class ContactFragment : SlidingPaneChildFragment() { showDeleteConfirmationDialog() } + binding.setGoToSharedMediaClickListener { + if (findNavController().currentDestination?.id == R.id.contactFragment) { + val conversationId = viewModel.existingConversationId.value.orEmpty() + Log.i("$TAG Going to shared media fragment for conversation [$conversationId]") + val action = ContactFragmentDirections.actionContactFragmentToConversationMediaListFragment(conversationId) + findNavController().navigate(action) + } + } + + binding.setGoToSharedDocumentsClickListener { + if (findNavController().currentDestination?.id == R.id.contactFragment) { + val conversationId = viewModel.existingConversationId.value.orEmpty() + Log.i("$TAG Going to shared documents fragment for conversation [$conversationId]") + val action = ContactFragmentDirections.actionContactFragmentToConversationDocumentsListFragment(conversationId) + findNavController().navigate(action) + } + } + sharedViewModel.isSlidingPaneSlideable.observe(viewLifecycleOwner) { slideable -> viewModel.showBackButton.value = slideable } @@ -221,8 +236,8 @@ class ContactFragment : SlidingPaneChildFragment() { } viewModel.startCallToDeviceToIncreaseTrustEvent.observe(viewLifecycleOwner) { - it.consume { pair -> - callDirectlyOrShowConfirmTrustCallDialog(pair.first, pair.second) + it.consume { triple -> + callDirectlyOrShowConfirmTrustCallDialog(triple.first, triple.second, triple.third) } } @@ -252,19 +267,15 @@ class ContactFragment : SlidingPaneChildFragment() { } private fun copyNumberOrAddressToClipboard(value: String, isSip: Boolean) { - val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val label = if (isSip) "SIP address" else "Phone number" - clipboard.setPrimaryClip(ClipData.newPlainText(label, value)) - - val message = if (isSip) { - getString(R.string.sip_address_copied_to_clipboard_toast) - } else { - getString(R.string.contact_details_phone_number_copied_to_clipboard_toast) + if (AppUtils.copyToClipboard(requireContext(), label, value)) { + val message = if (isSip) { + getString(R.string.sip_address_copied_to_clipboard_toast) + } else { + getString(R.string.contact_details_phone_number_copied_to_clipboard_toast) + } + (requireActivity() as GenericActivity).showGreenToast(message, R.drawable.check) } - (requireActivity() as GenericActivity).showGreenToast( - message, - R.drawable.check - ) } private fun shareContact(name: String, file: File) { @@ -311,18 +322,18 @@ class ContactFragment : SlidingPaneChildFragment() { } private fun showTrustProcessDialog() { - val initials = viewModel.contact.value?.initials?.value ?: "JD" + val initials = viewModel.contact.value?.initials?.value.orEmpty() val picture = viewModel.contact.value?.picturePath?.value.orEmpty() val model = ContactTrustDialogModel(initials, picture) val dialog = DialogUtils.getContactTrustProcessExplanationDialog(requireActivity(), model) dialog.show() } - private fun callDirectlyOrShowConfirmTrustCallDialog(contactName: String, deviceSipUri: String) { + private fun callDirectlyOrShowConfirmTrustCallDialog(contactName: String, deviceName: String, deviceSipUri: String) { coreContext.postOnCoreThread { if (corePreferences.showDialogWhenCallingDeviceUuidDirectly) { coreContext.postOnMainThread { - showConfirmTrustCallDialog(contactName, deviceSipUri) + showConfirmTrustCallDialog(contactName, deviceName, deviceSipUri) } } else { val address = Factory.instance().createAddress(deviceSipUri) @@ -333,11 +344,11 @@ class ContactFragment : SlidingPaneChildFragment() { } } - private fun showConfirmTrustCallDialog(contactName: String, deviceSipUri: String) { + private fun showConfirmTrustCallDialog(contactName: String, deviceName: String, deviceSipUri: String) { val label = AppUtils.getFormattedString( R.string.contact_dialog_increase_trust_level_message, contactName, - deviceSipUri + deviceName ) val model = ConfirmationDialogModel(label) val dialog = DialogUtils.getContactTrustCallConfirmationDialog(requireActivity(), model) diff --git a/app/src/main/java/org/linphone/ui/main/contacts/fragment/ContactsListFragment.kt b/app/src/main/java/org/linphone/ui/main/contacts/fragment/ContactsListFragment.kt index 63058f6ffb..83529b6b43 100644 --- a/app/src/main/java/org/linphone/ui/main/contacts/fragment/ContactsListFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/contacts/fragment/ContactsListFragment.kt @@ -41,12 +41,17 @@ import androidx.lifecycle.ViewModelProvider import androidx.navigation.findNavController import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.LinearLayoutManager +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import org.linphone.LinphoneApplication.Companion.coreContext import java.io.File import org.linphone.R +import org.linphone.core.FriendList import org.linphone.core.tools.Log import org.linphone.databinding.ContactsListFilterPopupMenuBinding import org.linphone.databinding.ContactsListFragmentBinding +import org.linphone.ui.fileviewer.FileViewerActivity +import org.linphone.ui.fileviewer.MediaViewerActivity import org.linphone.ui.main.MainActivity import org.linphone.ui.main.contacts.adapter.ContactsListAdapter import org.linphone.ui.main.contacts.model.ContactAvatarModel @@ -82,6 +87,11 @@ class ContactsListFragment : AbstractMainFragment() { } } + private val swipeToRefreshListener = SwipeRefreshLayout.OnRefreshListener { + Log.i("$TAG Swipe to refresh triggered, updating CardDAV friend lists") + listViewModel.refreshCardDavContacts() + } + override fun onDefaultAccountChanged() { Log.i( "$TAG Default account changed, updating avatar in top bar & refreshing contacts list" @@ -123,8 +133,13 @@ class ContactsListFragment : AbstractMainFragment() { binding.viewModel = listViewModel observeToastEvents(listViewModel) + // Disabled by default, may be enabled in onResume() + binding.contactsListSwipeRefresh.isEnabled = false + binding.contactsListSwipeRefresh.setOnRefreshListener(swipeToRefreshListener) + binding.contactsList.setHasFixedSize(true) binding.contactsList.layoutManager = LinearLayoutManager(requireContext()) + binding.contactsList.outlineProvider = outlineProvider binding.favouritesContactsList.setHasFixedSize(true) val favouritesLayoutManager = LinearLayoutManager(requireContext()) @@ -134,6 +149,10 @@ class ContactsListFragment : AbstractMainFragment() { configureAdapter(adapter) configureAdapter(favouritesAdapter) + listViewModel.isListFiltered.observe(viewLifecycleOwner) { filtered -> + binding.contactsList.clipToOutline = filtered + } + listViewModel.contactsList.observe( viewLifecycleOwner ) { @@ -174,6 +193,13 @@ class ContactsListFragment : AbstractMainFragment() { } } + listViewModel.cardDavSynchronizationCompletedEvent.observe(viewLifecycleOwner) { + it.consume { + Log.i("$TAG CardDAV synchronization has completed") + binding.contactsListSwipeRefresh.isRefreshing = false + } + } + binding.setOnNewContactClicked { sharedViewModel.showNewContactEvent.value = Event(true) } @@ -182,9 +208,7 @@ class ContactsListFragment : AbstractMainFragment() { showFilterPopupMenu(binding.topBar.extraAction) } - sharedViewModel.showContactEvent.observe( - viewLifecycleOwner - ) { + sharedViewModel.showContactEvent.observe(viewLifecycleOwner) { it.consume { refKey -> Log.i("$TAG Displaying contact with ref key [$refKey]") val navController = binding.contactsNavContainer.findNavController() @@ -195,9 +219,7 @@ class ContactsListFragment : AbstractMainFragment() { } } - sharedViewModel.showNewContactEvent.observe( - viewLifecycleOwner - ) { + sharedViewModel.showNewContactEvent.observe(viewLifecycleOwner) { it.consume { if (findNavController().currentDestination?.id == R.id.contactsListFragment) { Log.i("$TAG Opening contact editor for creating new contact") @@ -208,6 +230,38 @@ class ContactsListFragment : AbstractMainFragment() { } } + sharedViewModel.forceRefreshContactsList.observe(viewLifecycleOwner) { + it.consume { + listViewModel.filter() + } + } + + sharedViewModel.displayFileEvent.observe(viewLifecycleOwner) { + it.consume { bundle -> + if (findNavController().currentDestination?.id == R.id.contactsListFragment) { + val path = bundle.getString("path", "") + val isMedia = bundle.getBoolean("isMedia", false) + if (path.isEmpty()) { + Log.e("$TAG Can't navigate to file viewer for empty path!") + return@consume + } + + Log.i( + "$TAG Navigating to [${if (isMedia) "media" else "file"}] viewer fragment with path [$path]" + ) + if (isMedia) { + val intent = Intent(requireActivity(), MediaViewerActivity::class.java) + intent.putExtras(bundle) + startActivity(intent) + } else { + val intent = Intent(requireActivity(), FileViewerActivity::class.java) + intent.putExtras(bundle) + startActivity(intent) + } + } + } + } + // AbstractMainFragment related listViewModel.title.value = getString(R.string.bottom_navigation_contacts_label) @@ -236,12 +290,33 @@ class ContactsListFragment : AbstractMainFragment() { bottomSheetDialog = null } + override fun onResume() { + super.onResume() + + coreContext.postOnCoreThread { core -> + val cardDavFriendList = core.friendsLists.find { + it.type == FriendList.Type.CardDAV + } + val cardDavFriendListFound = cardDavFriendList != null + if (cardDavFriendListFound) { + Log.i("$TAG CardDAV friend list [${cardDavFriendList.displayName}] found, enabling swipe to refresh") + } else { + Log.i("$TAG No CardDAV friend list was found, disabling swipe to refresh") + } + coreContext.postOnMainThread { + binding.contactsListSwipeRefresh.isEnabled = cardDavFriendListFound + } + } + } + private fun configureAdapter(adapter: ContactsListAdapter) { adapter.contactLongClickedEvent.observe(viewLifecycleOwner) { it.consume { model -> val modalBottomSheet = ContactsListMenuDialogFragment( model.isFavourite.value == true, model.isStored, + isReadOnly = model.isReadOnly, + isNative = model.isNative, { // onDismiss adapter.resetSelection() }, diff --git a/app/src/main/java/org/linphone/ui/main/contacts/fragment/ContactsListMenuDialogFragment.kt b/app/src/main/java/org/linphone/ui/main/contacts/fragment/ContactsListMenuDialogFragment.kt index 15ab357c3e..8824d6c3d4 100644 --- a/app/src/main/java/org/linphone/ui/main/contacts/fragment/ContactsListMenuDialogFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/contacts/fragment/ContactsListMenuDialogFragment.kt @@ -35,6 +35,8 @@ import org.linphone.databinding.ContactsListLongPressMenuBinding class ContactsListMenuDialogFragment( private val isFavourite: Boolean, private val isStored: Boolean, + private val isReadOnly: Boolean, + private val isNative: Boolean, private val onDismiss: (() -> Unit)? = null, private val onFavourite: (() -> Unit)? = null, private val onShare: (() -> Unit)? = null, @@ -70,6 +72,8 @@ class ContactsListMenuDialogFragment( val view = ContactsListLongPressMenuBinding.inflate(layoutInflater) view.isFavourite = isFavourite view.isStored = isStored + view.isReadOnly = isReadOnly + view.isNative = isNative view.setFavoriteClickListener { onFavourite?.invoke() diff --git a/app/src/main/java/org/linphone/ui/main/contacts/fragment/EditContactFragment.kt b/app/src/main/java/org/linphone/ui/main/contacts/fragment/EditContactFragment.kt index df4edcfc36..bfd9c9efe4 100644 --- a/app/src/main/java/org/linphone/ui/main/contacts/fragment/EditContactFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/contacts/fragment/EditContactFragment.kt @@ -19,6 +19,7 @@ */ package org.linphone.ui.main.contacts.fragment +import android.content.ActivityNotFoundException import android.content.Context import android.os.Bundle import android.view.LayoutInflater @@ -64,7 +65,7 @@ class EditContactFragment : SlidingPaneChildFragment() { private val backPressedCallback = object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { - showAbortConfirmationDialog() + showAbortConfirmationDialogIfPendingChanges() } } @@ -121,7 +122,7 @@ class EditContactFragment : SlidingPaneChildFragment() { viewModel.findFriendByRefKey(refKey) binding.setBackClickListener { - showAbortConfirmationDialog() + showAbortConfirmationDialogIfPendingChanges() } binding.setPickImageClickListener { @@ -212,10 +213,14 @@ class EditContactFragment : SlidingPaneChildFragment() { } private fun pickImage() { - pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) + try { + pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) + } catch (anfe: ActivityNotFoundException) { + Log.e("$TAG Failed to start media picker: $anfe") + } } - private fun showAbortConfirmationDialog() { + private fun showAbortConfirmationDialogIfPendingChanges() { if (!viewModel.isPendingChanges()) { Log.i("$TAG No changes detected, do not show confirmation dialog") backPressedCallback.isEnabled = false diff --git a/app/src/main/java/org/linphone/ui/main/contacts/fragment/NewContactFragment.kt b/app/src/main/java/org/linphone/ui/main/contacts/fragment/NewContactFragment.kt index 9209816b22..7e357cc1b0 100644 --- a/app/src/main/java/org/linphone/ui/main/contacts/fragment/NewContactFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/contacts/fragment/NewContactFragment.kt @@ -19,6 +19,7 @@ */ package org.linphone.ui.main.contacts.fragment +import android.content.ActivityNotFoundException import android.content.Context import android.os.Bundle import android.view.LayoutInflater @@ -61,7 +62,7 @@ class NewContactFragment : GenericMainFragment() { private val backPressedCallback = object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { - showAbortConfirmationDialogIfNeededOrGoBack() + showAbortConfirmationDialogIfPendingChanges() } } @@ -126,7 +127,7 @@ class NewContactFragment : GenericMainFragment() { viewModel.findFriendByRefKey("") binding.setBackClickListener { - showAbortConfirmationDialogIfNeededOrGoBack() + showAbortConfirmationDialogIfPendingChanges() } binding.setPickImageClickListener { @@ -214,10 +215,14 @@ class NewContactFragment : GenericMainFragment() { } private fun pickImage() { - pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) + try { + pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) + } catch (anfe: ActivityNotFoundException) { + Log.e("$TAG Failed to start media picker: $anfe") + } } - private fun showAbortConfirmationDialogIfNeededOrGoBack() { + private fun showAbortConfirmationDialogIfPendingChanges() { if (!viewModel.isPendingChanges()) { Log.i("$TAG No changes detected, do not show confirmation dialog") backPressedCallback.isEnabled = false diff --git a/app/src/main/java/org/linphone/ui/main/contacts/model/ContactAvatarModel.kt b/app/src/main/java/org/linphone/ui/main/contacts/model/ContactAvatarModel.kt index f835624e07..240e2f3c35 100644 --- a/app/src/main/java/org/linphone/ui/main/contacts/model/ContactAvatarModel.kt +++ b/app/src/main/java/org/linphone/ui/main/contacts/model/ContactAvatarModel.kt @@ -20,6 +20,7 @@ package org.linphone.ui.main.contacts.model import android.net.Uri +import androidx.annotation.AnyThread import androidx.annotation.WorkerThread import androidx.lifecycle.MutableLiveData import org.linphone.LinphoneApplication.Companion.coreContext @@ -36,6 +37,7 @@ import org.linphone.core.tools.Log import org.linphone.utils.AppUtils import org.linphone.utils.TimestampUtils import androidx.core.net.toUri +import org.linphone.LinphoneApplication.Companion.corePreferences class ContactAvatarModel @WorkerThread @@ -50,13 +52,19 @@ class ContactAvatarModel val isStored = friend.inList() + val isReadOnly = friend.isReadOnly + + val isNative = !friend.nativeUri.isNullOrEmpty() + val isFavourite = MutableLiveData() val lastPresenceInfo = MutableLiveData() val name = MutableLiveData() - val firstLetter: String = AppUtils.getFirstLetter(friend.name.orEmpty()) + var sortingName: String? = null + + var firstLetter: String? = null private val friendListener = object : FriendListenerStub() { @WorkerThread @@ -76,6 +84,32 @@ class ContactAvatarModel } update(address) + refreshSortingName() + } + + @AnyThread + fun compare(other: ContactAvatarModel?): Boolean { + if (other == null) return false + + val picture = picturePath.value + val otherPicture = other.picturePath.value + if (picture != null && otherPicture != null && picture != otherPicture) { + return false + } + + if (contactName != other.contactName) { + return false + } + + if (id != other.id) { + return false + } + + if (isFavourite.value != other.isFavourite.value) { + return false + } + + return true } @WorkerThread @@ -85,6 +119,12 @@ class ContactAvatarModel } } + @WorkerThread + fun refreshSortingName() { + sortingName = getNameToUseForSorting() + firstLetter = AppUtils.getFirstLetter(getNameToUseForSorting().orEmpty()) + } + @WorkerThread fun update(address: Address?) { updateSecurityLevel(address) @@ -148,6 +188,13 @@ class ContactAvatarModel } } + @WorkerThread + fun getNameToUseForSorting(): String? { + val sortByFirstName = corePreferences.sortContactsByFirstName + val firstOrLastName = if (sortByFirstName) friend.firstName else friend.lastName + return firstOrLastName ?: friend.name ?: friend.organization ?: friend.vcard?.fullName + } + @WorkerThread private fun getAvatarUri(friend: Friend): Uri? { val picturePath = friend.photo diff --git a/app/src/main/java/org/linphone/ui/main/contacts/model/ContactNumberOrAddressModel.kt b/app/src/main/java/org/linphone/ui/main/contacts/model/ContactNumberOrAddressModel.kt index eeb397e8ff..c4afde6645 100644 --- a/app/src/main/java/org/linphone/ui/main/contacts/model/ContactNumberOrAddressModel.kt +++ b/app/src/main/java/org/linphone/ui/main/contacts/model/ContactNumberOrAddressModel.kt @@ -39,6 +39,8 @@ class ContactNumberOrAddressModel ) { val selected = MutableLiveData() + val name: String = friend.name.orEmpty() + private var actionDoneCallback: (() -> Unit)? = null @UiThread diff --git a/app/src/main/java/org/linphone/ui/main/contacts/viewmodel/ContactNewOrEditViewModel.kt b/app/src/main/java/org/linphone/ui/main/contacts/viewmodel/ContactNewOrEditViewModel.kt index 3fd54dd968..fe35eb3dcc 100644 --- a/app/src/main/java/org/linphone/ui/main/contacts/viewmodel/ContactNewOrEditViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/contacts/viewmodel/ContactNewOrEditViewModel.kt @@ -42,6 +42,7 @@ import org.linphone.ui.main.contacts.model.NewOrEditNumberOrAddressModel import org.linphone.utils.Event import org.linphone.utils.FileUtils import androidx.core.net.toUri +import org.linphone.utils.LinphoneUtils class ContactNewOrEditViewModel @UiThread @@ -66,6 +67,8 @@ class ContactNewOrEditViewModel val sipAddresses = ArrayList() + val hideSipAddresses = MutableLiveData() + val phoneNumbers = ArrayList() val company = MutableLiveData() @@ -73,7 +76,7 @@ class ContactNewOrEditViewModel val jobTitle = MutableLiveData() val saveChangesEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val friendFoundEvent = MutableLiveData>() @@ -82,6 +85,16 @@ class ContactNewOrEditViewModel val removeNewNumberOrAddressFieldEvent = MutableLiveData>() + private val sipAddressesBeforeEdit = arrayListOf() + + private val phoneNumbersBeforeEdit = arrayListOf() + + private val sipAddressesLinkedToPresence = arrayListOf() + + init { + hideSipAddresses.postValue(corePreferences.hideSipAddresses) + } + @UiThread fun findFriendByRefKey(refKey: String?) { reset() @@ -108,9 +121,18 @@ class ContactNewOrEditViewModel } for (address in friend.addresses) { - addSipAddress(address.asStringUriOnly()) + val sipAddress = address.asStringUriOnly() + // Prevents showing presence address as editable when in fact it's not + if (!LinphoneUtils.isSipAddressLinkedToPhoneNumberByPresence(friend, sipAddress)) { + addSipAddress(sipAddress) + sipAddressesBeforeEdit.add(sipAddress) + } else { + sipAddressesLinkedToPresence.add(sipAddress) + } } + for (number in friend.phoneNumbersWithLabel) { + phoneNumbersBeforeEdit.add(number.phoneNumber) addPhoneNumber(number.phoneNumber, number.label) } @@ -153,14 +175,14 @@ class ContactNewOrEditViewModel } val name = if (fn.isNotEmpty() && ln.isNotEmpty()) { "$fn $ln" - } else if (fn.isNotEmpty()) { - fn - } else if (ln.isNotEmpty()) { - ln - } else if (organization.isNotEmpty()) { - organization } else { - "" + fn.ifEmpty { + ln.ifEmpty { + organization.ifEmpty { + "" + } + } + } } friend.edit() @@ -323,32 +345,39 @@ class ContactNewOrEditViewModel phoneNumbers.clear() company.value = "" jobTitle.value = "" + sipAddressesBeforeEdit.clear() + phoneNumbersBeforeEdit.clear() + sipAddressesLinkedToPresence.clear() } @UiThread fun isPendingChanges(): Boolean { if (isEdit.value == true) { + Log.i("$TAG Contact is being edited, checking if at least one field has changed") if (firstName.value.orEmpty() != friend.firstName.orEmpty()) return true if (lastName.value.orEmpty() != friend.lastName.orEmpty()) return true if (picturePath.value.orEmpty() != friend.photo.orEmpty()) return true if (company.value.orEmpty() != friend.organization.orEmpty()) return true if (jobTitle.value.orEmpty() != friend.jobTitle.orEmpty()) return true - for (address in friend.addresses) { + for (sipAddress in sipAddressesBeforeEdit) { + if (sipAddressesLinkedToPresence.contains(sipAddress)) continue + val found = sipAddresses.find { - it.isSip && it.value.value.orEmpty() == address.asStringUriOnly() + it.isSip && it.value.value.orEmpty() == sipAddress } if (found == null) return true } for (address in sipAddresses) { - if (address.value.value.orEmpty().isEmpty()) continue + val sipAddress = address.value.value.orEmpty() + if (sipAddress.isEmpty()) continue - val found = friend.addresses.find { - it.asStringUriOnly() == address.value.value.orEmpty() + val found = sipAddressesBeforeEdit.find { + it == sipAddress } if (found == null) return true } - for (number in friend.phoneNumbers) { + for (number in phoneNumbersBeforeEdit) { val found = phoneNumbers.find { !it.isSip && it.value.value.orEmpty() == number } @@ -357,7 +386,7 @@ class ContactNewOrEditViewModel for (number in phoneNumbers) { if (number.value.value.orEmpty().isEmpty()) continue - val found = friend.phoneNumbers.find { + val found = phoneNumbersBeforeEdit.find { it == number.value.value.orEmpty() } if (found == null) return true @@ -366,6 +395,7 @@ class ContactNewOrEditViewModel return false } + Log.i("$TAG Checking if at least a field of the new contact form has been filled") return !picturePath.value.isNullOrEmpty() || !firstName.value.isNullOrEmpty() || !lastName.value.isNullOrEmpty() || diff --git a/app/src/main/java/org/linphone/ui/main/contacts/viewmodel/ContactViewModel.kt b/app/src/main/java/org/linphone/ui/main/contacts/viewmodel/ContactViewModel.kt index 60569f3393..1957f1a3ba 100644 --- a/app/src/main/java/org/linphone/ui/main/contacts/viewmodel/ContactViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/contacts/viewmodel/ContactViewModel.kt @@ -91,50 +91,52 @@ class ContactViewModel val isStored = MutableLiveData() + val isReadOnly = MutableLiveData() + + val isNative = MutableLiveData() + val chatDisabled = MutableLiveData() val videoCallDisabled = MutableLiveData() - val operationInProgress = MutableLiveData() + val existingConversationId = MutableLiveData() - val chatRoomCreationErrorEvent: MutableLiveData> by lazy { - MutableLiveData>() - } + val operationInProgress = MutableLiveData() val showLongPressMenuForNumberOrAddressEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val showNumberOrAddressPickerDialogEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val openNativeContactEditor: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val openLinphoneContactEditor: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val goToConversationEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val vCardTerminatedEvent: MutableLiveData>> by lazy { - MutableLiveData>>() + MutableLiveData() } val displayTrustProcessDialogEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } - val startCallToDeviceToIncreaseTrustEvent: MutableLiveData>> by lazy { - MutableLiveData>>() + val startCallToDeviceToIncreaseTrustEvent: MutableLiveData>> by lazy { + MutableLiveData() } val contactRemovedEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } private var expectedAction: Int = START_AUDIO_CALL @@ -201,20 +203,23 @@ class ContactViewModel if (state == ChatRoom.State.Instantiated) return val id = LinphoneUtils.getConversationId(chatRoom) - Log.i("$TAG Conversation [$id] (${chatRoom.subject}) state changed: [$state]") + Log.i("$TAG Conversation [$id] (${chatRoom.subjectUtf8}) state changed: [$state]") if (state == ChatRoom.State.Created) { Log.i("$TAG Conversation [$id] successfully created") chatRoom.removeListener(this) operationInProgress.postValue(false) - goToConversationEvent.postValue(Event(LinphoneUtils.getConversationId(chatRoom))) + + val conversationId = LinphoneUtils.getConversationId(chatRoom) + if (existingConversationId.value.orEmpty().isEmpty()) { + existingConversationId.postValue(conversationId) + } + goToConversationEvent.postValue(Event(conversationId)) } else if (state == ChatRoom.State.CreationFailed) { Log.e("$TAG Conversation [$id] creation has failed!") chatRoom.removeListener(this) operationInProgress.postValue(false) - chatRoomCreationErrorEvent.postValue( - Event(R.string.conversation_failed_to_create_toast) - ) + showRedToast(R.string.conversation_failed_to_create_toast, R.drawable.warning_circle) } } } @@ -239,6 +244,8 @@ class ContactViewModel init { isStored.value = false + isReadOnly.value = false + isNative.value = false expandNumbersAndAddresses.value = true trustedDevicesPercentage.value = 0 @@ -314,6 +321,8 @@ class ContactViewModel // Do not show edit contact button for contacts not stored in a FriendList or // if they are in a temporary one (for example if they are from a remote directory such as LDAP or CardDAV) isStored.postValue(!coreContext.contactsManager.isContactTemporary(friend)) + isReadOnly.postValue(friend.isReadOnly) + isNative.postValue(!friend.nativeUri.isNullOrEmpty()) contact.value?.destroy() contact.postValue(ContactAvatarModel(friend)) @@ -328,6 +337,7 @@ class ContactViewModel sipAddressesAndPhoneNumbers.postValue(addressesAndNumbers) fetchDevicesAndTrust() + lookUpExistingChatRoom() } @UiThread @@ -350,7 +360,7 @@ class ContactViewModel coreContext.postOnCoreThread { if (::friend.isInitialized) { val uri = friend.nativeUri - if (uri != null) { + if (uri != null && !corePreferences.editNativeContactsInLinphone) { Log.i( "$TAG Contact [${friend.name}] is a native contact, opening native contact editor using URI [$uri]" ) @@ -543,7 +553,12 @@ class ContactViewModel existingChatRoom )}], going to it" ) - goToConversationEvent.postValue(Event(LinphoneUtils.getConversationId(existingChatRoom))) + + val conversationId = LinphoneUtils.getConversationId(existingChatRoom) + if (existingConversationId.value.orEmpty().isEmpty()) { + existingConversationId.postValue(conversationId) + } + goToConversationEvent.postValue(Event(conversationId)) } else { Log.i( "$TAG No existing conversation between [$localSipUri] and [$remoteSipUri] was found, let's create it" @@ -556,7 +571,12 @@ class ContactViewModel val id = LinphoneUtils.getConversationId(chatRoom) Log.i("$TAG 1-1 conversation [$id] has been created") operationInProgress.postValue(false) - goToConversationEvent.postValue(Event(LinphoneUtils.getConversationId(chatRoom))) + + val conversationId = LinphoneUtils.getConversationId(chatRoom) + if (existingConversationId.value.orEmpty().isEmpty()) { + existingConversationId.postValue(conversationId) + } + goToConversationEvent.postValue(Event(conversationId)) } else { Log.i("$TAG Conversation isn't in Created state yet, wait for it") chatRoom.addListener(chatRoomListener) @@ -565,16 +585,19 @@ class ContactViewModel val id = LinphoneUtils.getConversationId(chatRoom) Log.i("$TAG Conversation successfully created [$id]") operationInProgress.postValue(false) - goToConversationEvent.postValue(Event(LinphoneUtils.getConversationId(chatRoom))) + + val conversationId = LinphoneUtils.getConversationId(chatRoom) + if (existingConversationId.value.orEmpty().isEmpty()) { + existingConversationId.postValue(conversationId) + } + goToConversationEvent.postValue(Event(conversationId)) } } else { Log.e( "$TAG Failed to create 1-1 conversation with [${remote.asStringUriOnly()}]!" ) operationInProgress.postValue(false) - chatRoomCreationErrorEvent.postValue( - Event(R.string.conversation_failed_to_create_toast) - ) + showRedToast(R.string.conversation_failed_to_create_toast, R.drawable.warning_circle) } } } @@ -602,7 +625,7 @@ class ContactViewModel ) { if (::friend.isInitialized) { startCallToDeviceToIncreaseTrustEvent.value = - Event(Pair(friend.name.orEmpty(), it.address.asStringUriOnly())) + Event(Triple(friend.name.orEmpty(), it.name, it.address.asStringUriOnly())) } } ) @@ -627,4 +650,44 @@ class ContactViewModel devices.postValue(devicesList) } + + @WorkerThread + private fun lookUpExistingChatRoom() { + val account = LinphoneUtils.getDefaultAccount() + if (account != null) { + val params = coreContext.core.createConferenceParams(null) + params.isChatEnabled = true + params.isGroupEnabled = false + params.account = account + + val localAddress = account.params.identityAddress + val addresses = friend.addresses + for (address in addresses) { + val sameDomain = address.domain == corePreferences.defaultDomain && address.domain == account.params.domain + if (account.params.instantMessagingEncryptionMandatory && sameDomain) { + params.securityLevel = Conference.SecurityLevel.EndToEnd + } else if (!account.params.instantMessagingEncryptionMandatory) { + if (LinphoneUtils.isEndToEndEncryptedChatAvailable(coreContext.core)) { + params.securityLevel = Conference.SecurityLevel.EndToEnd + } else { + params.securityLevel = Conference.SecurityLevel.None + } + } + + val participants = arrayOf(address) + val existingChatRoom = coreContext.core.searchChatRoom(params, localAddress, null, participants) + if (existingChatRoom != null) { + val conversationId = LinphoneUtils.getConversationId(existingChatRoom) + Log.i("$TAG Found existing conversation with ID [$conversationId]") + existingConversationId.postValue(conversationId) + return + } + } + + Log.w("$TAG No existing conversation was found for this contact with any of it's SIP addresses") + existingConversationId.postValue("") + } else { + Log.e("$TAG No default account found!") + } + } } diff --git a/app/src/main/java/org/linphone/ui/main/contacts/viewmodel/ContactsListViewModel.kt b/app/src/main/java/org/linphone/ui/main/contacts/viewmodel/ContactsListViewModel.kt index 26e81a4327..f4e3dc8b1e 100644 --- a/app/src/main/java/org/linphone/ui/main/contacts/viewmodel/ContactsListViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/contacts/viewmodel/ContactsListViewModel.kt @@ -33,6 +33,8 @@ import org.linphone.LinphoneApplication.Companion.corePreferences import org.linphone.R import org.linphone.contacts.ContactsManager.ContactsListener import org.linphone.core.Friend +import org.linphone.core.FriendList +import org.linphone.core.FriendListListenerStub import org.linphone.core.MagicSearch import org.linphone.core.MagicSearchListenerStub import org.linphone.core.SearchResult @@ -67,8 +69,16 @@ class ContactsListViewModel val isDefaultAccountLinphone = MutableLiveData() + val showResultsLimitReached = MutableLiveData() + + val disableAddContact = MutableLiveData() + val vCardTerminatedEvent: MutableLiveData>> by lazy { - MutableLiveData>>() + MutableLiveData() + } + + val cardDavSynchronizationCompletedEvent: MutableLiveData> by lazy { + MutableLiveData() } private var previousFilter = "NotSet" @@ -86,6 +96,14 @@ class ContactsListViewModel Log.i("$TAG Magic search contacts available") processMagicSearchResults(magicSearch.lastSearch, favourites = false) } + + @WorkerThread + override fun onResultsLimitReached(magicSearch: MagicSearch, sourcesFlag: Int) { + Log.w("$TAG Results limit reached (configured limit is [${magicSearch.searchLimit}]) for source(s) [$sourcesFlag], user should refine it's search") + if (searchFilter.value.orEmpty().isNotEmpty()) { + showResultsLimitReached.postValue(true) + } + } } private val favouritesMagicSearchListener = object : MagicSearchListenerStub() { @@ -96,6 +114,22 @@ class ContactsListViewModel } } + private val friendListListener = object : FriendListListenerStub() { + @WorkerThread + override fun onSyncStatusChanged( + friendList: FriendList, + status: FriendList.SyncStatus?, + message: String? + ) { + Log.i("$TAG Synchronization status changed to [$status] for friend list [${friendList.displayName}] with message [$message]") + if (status == FriendList.SyncStatus.Successful || status == FriendList.SyncStatus.Failure) { + friendList.removeListener(this) + cardDavSynchronizationCompletedEvent.postValue(Event(true)) + } + // TODO FIXME: alert user when failure ? + } + } + private val contactsListener = object : ContactsListener { @WorkerThread override fun onContactsLoaded() { @@ -105,7 +139,8 @@ class ContactsListViewModel applyFilter( currentFilter, - domainFilter + domainFilter, + true ) } @@ -116,7 +151,9 @@ class ContactsListViewModel init { fetchInProgress.value = true showFavourites.value = corePreferences.showFavoriteContacts - showFilter.value = !corePreferences.hidePhoneNumbers + showFilter.value = !corePreferences.hidePhoneNumbers && !corePreferences.hideSipAddresses + disableAddContact.value = corePreferences.disableAddContact + isListFiltered.value = false coreContext.postOnCoreThread { core -> domainFilter = corePreferences.contactsFilter @@ -133,9 +170,7 @@ class ContactsListViewModel favouritesMagicSearch.limitedSearch = false favouritesMagicSearch.addListener(favouritesMagicSearchListener) - coreContext.postOnMainThread { - applyFilter(currentFilter) - } + applyFilter(currentFilter, domainFilter) } } @@ -153,10 +188,7 @@ class ContactsListViewModel override fun filter() { isListFiltered.value = currentFilter.isNotEmpty() coreContext.postOnCoreThread { - applyFilter( - currentFilter, - domainFilter - ) + applyFilter(currentFilter, domainFilter) } } @@ -167,9 +199,7 @@ class ContactsListViewModel areAllContactsDisplayed.postValue(domainFilter.isEmpty()) checkIfDefaultAccountOnDefaultDomain() - coreContext.postOnMainThread { - applyFilter(currentFilter) - } + applyFilter(currentFilter, domainFilter) } } @@ -187,8 +217,8 @@ class ContactsListViewModel corePreferences.contactsFilter = domainFilter Log.i("$TAG Newly set filter is [${corePreferences.contactsFilter}]") - coreContext.postOnMainThread { - applyFilter(currentFilter) + coreContext.postOnCoreThread { + applyFilter(currentFilter, domainFilter, filterChanged = true) } } } @@ -255,10 +285,24 @@ class ContactsListViewModel } } + @UiThread + fun refreshCardDavContacts() { + coreContext.postOnCoreThread { core -> + for (friendList in core.friendsLists) { + if (friendList.type == FriendList.Type.CardDAV) { + Log.i("$TAG Found CardDAV friend list [${friendList.displayName}], starting update") + friendList.addListener(friendListListener) + friendList.synchronizeFriendsFromServer() + } + } + } + } + @WorkerThread private fun applyFilter( filter: String, - domain: String + domain: String, + filterChanged: Boolean = false ) { if (contactsList.value.orEmpty().isEmpty()) { fetchInProgress.postValue(true) @@ -278,8 +322,9 @@ class ContactsListViewModel "$TAG Asking Magic search for contacts matching filter [$filter], domain [$domain] and in sources Friends/LDAP/CardDAV" ) searchInProgress.postValue(filter.isNotEmpty()) + showResultsLimitReached.postValue(false) - if (filter.isEmpty()) { + if (filter.isEmpty() && (favouritesList.value.orEmpty().isEmpty() || filterChanged)) { favouritesMagicSearch.getContactsListAsync( filter, domain, @@ -302,11 +347,17 @@ class ContactsListViewModel Log.i("$TAG Processing [${results.size}] results, favourites is [$favourites]") val list = arrayListOf() - var count = 0 + val collator = Collator.getInstance(Locale.getDefault()) + val hideEmptyContacts = corePreferences.hideContactsWithoutPhoneNumberOrSipAddress for (result in results) { val friend = result.friend if (friend != null) { + if (hideEmptyContacts && friend.addresses.isEmpty() && friend.phoneNumbers.isEmpty()) { + Log.i("$TAG Friend [${friend.name}] has no SIP address nor phone number, do not show it") + continue + } + if (friend.refKey.orEmpty().isEmpty()) { if (friend.vcard != null) { friend.vcard?.generateUniqueId() @@ -325,21 +376,15 @@ class ContactsListViewModel } else { coreContext.contactsManager.getContactAvatarModelForAddress(result.address) } - - list.add(model) - count += 1 - + model.refreshSortingName() val starred = friend?.starred == true model.isFavourite.postValue(starred) - if (!favourites && firstLoad && count == 20) { - contactsList.postValue(list) - } + list.add(model) } - val collator = Collator.getInstance(Locale.getDefault()) list.sortWith { model1, model2 -> - collator.compare(model1.friend.name, model2.friend.name) + collator.compare(model1.getNameToUseForSorting(), model2.getNameToUseForSorting()) } searchInProgress.postValue(false) diff --git a/app/src/main/java/org/linphone/ui/main/fragment/AddParticipantsFragment.kt b/app/src/main/java/org/linphone/ui/main/fragment/AddParticipantsFragment.kt index f9acb82ab9..85c370eb47 100644 --- a/app/src/main/java/org/linphone/ui/main/fragment/AddParticipantsFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/fragment/AddParticipantsFragment.kt @@ -25,13 +25,10 @@ import android.view.View import android.view.ViewGroup import androidx.activity.OnBackPressedCallback import androidx.annotation.UiThread -import androidx.annotation.WorkerThread import androidx.core.view.doOnPreDraw import androidx.lifecycle.ViewModelProvider import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs -import org.linphone.core.Address -import org.linphone.core.Friend import org.linphone.core.tools.Log import org.linphone.databinding.GenericAddParticipantsFragmentBinding import org.linphone.ui.main.viewmodel.AddParticipantsViewModel @@ -92,11 +89,6 @@ class AddParticipantsFragment : GenericAddressPickerFragment() { return false } - @WorkerThread - override fun onSingleAddressSelected(address: Address, friend: Friend) { - Log.e("$TAG This shouldn't happen as we should always be in multiple selection mode here!") - } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { viewModel = ViewModelProvider(this)[AddParticipantsViewModel::class.java] diff --git a/app/src/main/java/org/linphone/ui/main/fragment/DrawerMenuFragment.kt b/app/src/main/java/org/linphone/ui/main/fragment/DrawerMenuFragment.kt index ce762f7de3..a61bff04e9 100644 --- a/app/src/main/java/org/linphone/ui/main/fragment/DrawerMenuFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/fragment/DrawerMenuFragment.kt @@ -22,14 +22,10 @@ package org.linphone.ui.main.fragment import android.content.ActivityNotFoundException import android.content.Intent import android.os.Bundle -import android.view.Gravity import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.view.ViewGroup.LayoutParams -import android.widget.PopupWindow import androidx.annotation.UiThread -import androidx.databinding.DataBindingUtil import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.Dispatchers @@ -38,9 +34,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.R -import org.linphone.core.Account import org.linphone.core.tools.Log -import org.linphone.databinding.AccountPopupMenuBinding import org.linphone.databinding.DrawerMenuBinding import org.linphone.ui.assistant.AssistantActivity import org.linphone.ui.main.MainActivity @@ -121,9 +115,15 @@ class DrawerMenuFragment : GenericMainFragment() { } } - viewModel.showAccountPopupMenuEvent.observe(viewLifecycleOwner) { - it.consume { pair -> - showAccountPopupMenu(pair.first, pair.second) + viewModel.openAccountProfileEvent.observe(viewLifecycleOwner) { + it.consume { model -> + val navController = (requireActivity() as MainActivity).findNavController() + val action = AccountProfileFragmentDirections.actionGlobalAccountProfileFragment( + model.identity + ) + Log.i("$TAG Going to account [${model.identity}] profile") + navController.navigate(action) + (requireActivity() as MainActivity).closeDrawerMenu() } } @@ -183,36 +183,4 @@ class DrawerMenuFragment : GenericMainFragment() { } } } - - private fun showAccountPopupMenu(view: View, account: Account) { - val popupView: AccountPopupMenuBinding = DataBindingUtil.inflate( - LayoutInflater.from(requireContext()), - R.layout.account_popup_menu, - null, - false - ) - - val popupWindow = PopupWindow( - popupView.root, - LayoutParams.WRAP_CONTENT, - LayoutParams.WRAP_CONTENT, - true - ) - - popupView.setManageProfileClickListener { - val navController = (requireActivity() as MainActivity).findNavController() - val identity = account.params.identityAddress?.asStringUriOnly().orEmpty() - val action = AccountProfileFragmentDirections.actionGlobalAccountProfileFragment( - identity - ) - Log.i("$TAG Going to account [$identity] profile") - navController.navigate(action) - popupWindow.dismiss() - (requireActivity() as MainActivity).closeDrawerMenu() - } - - // Elevation is for showing a shadow around the popup - popupWindow.elevation = 20f - popupWindow.showAsDropDown(view, 0, 0, Gravity.BOTTOM) - } } diff --git a/app/src/main/java/org/linphone/ui/main/fragment/EmptyFragment.kt b/app/src/main/java/org/linphone/ui/main/fragment/EmptyFragment.kt index bc2d195368..9a4ddf861c 100644 --- a/app/src/main/java/org/linphone/ui/main/fragment/EmptyFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/fragment/EmptyFragment.kt @@ -35,7 +35,7 @@ import org.linphone.utils.Event class EmptyFragment : GenericFragment() { private lateinit var binding: EmptyFragmentBinding - protected lateinit var sharedViewModel: SharedMainViewModel + private lateinit var sharedViewModel: SharedMainViewModel override fun onCreateView( inflater: LayoutInflater, diff --git a/app/src/main/java/org/linphone/ui/main/fragment/GenericAddressPickerFragment.kt b/app/src/main/java/org/linphone/ui/main/fragment/GenericAddressPickerFragment.kt index 23c599f573..98e8d1ace1 100644 --- a/app/src/main/java/org/linphone/ui/main/fragment/GenericAddressPickerFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/fragment/GenericAddressPickerFragment.kt @@ -23,23 +23,13 @@ import android.app.Dialog import android.os.Bundle import android.view.View import androidx.annotation.UiThread -import androidx.annotation.WorkerThread import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView -import org.linphone.LinphoneApplication.Companion.coreContext -import org.linphone.contacts.getListOfSipAddressesAndPhoneNumbers -import org.linphone.core.Address -import org.linphone.core.Friend -import org.linphone.core.tools.Log import org.linphone.ui.main.adapter.ConversationsContactsAndSuggestionsListAdapter -import org.linphone.ui.main.contacts.model.ContactNumberOrAddressClickListener import org.linphone.ui.main.contacts.model.ContactNumberOrAddressModel import org.linphone.ui.main.contacts.model.NumberOrAddressPickerDialogModel -import org.linphone.ui.main.model.ConversationContactOrSuggestionModel -import org.linphone.ui.main.model.SelectedAddressModel import org.linphone.ui.main.viewmodel.AddressSelectionViewModel import org.linphone.utils.DialogUtils -import org.linphone.utils.LinphoneUtils import org.linphone.utils.RecyclerViewHeaderDecoration @UiThread @@ -56,31 +46,6 @@ abstract class GenericAddressPickerFragment : GenericMainFragment() { private lateinit var recyclerView: RecyclerView - private val listener = object : ContactNumberOrAddressClickListener { - @UiThread - override fun onClicked(model: ContactNumberOrAddressModel) { - val address = model.address - coreContext.postOnCoreThread { - if (address != null) { - Log.i( - "$TAG Selected address [${model.address.asStringUriOnly()}] from friend [${model.friend.name}]" - ) - onAddressSelected(model.address, model.friend) - } - } - - numberOrAddressPickerDialog?.dismiss() - numberOrAddressPickerDialog = null - } - - @UiThread - override fun onLongPress(model: ContactNumberOrAddressModel) { - } - } - - @WorkerThread - abstract fun onSingleAddressSelected(address: Address, friend: Friend) - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -92,7 +57,7 @@ abstract class GenericAddressPickerFragment : GenericMainFragment() { adapter.onClickedEvent.observe(viewLifecycleOwner) { it.consume { model -> - handleClickOnContactModel(model) + viewModel.handleClickOnContactModel(model) } } @@ -100,6 +65,19 @@ abstract class GenericAddressPickerFragment : GenericMainFragment() { val trimmed = filter.trim() viewModel.applyFilter(trimmed) } + + viewModel.showNumberOrAddressPickerDialogEvent.observe(viewLifecycleOwner) { + it.consume { list -> + showNumbersOrAddressesDialog(list) + } + } + + viewModel.dismissNumberOrAddressPickerDialogEvent.observe(viewLifecycleOwner) { + it.consume { + numberOrAddressPickerDialog?.dismiss() + numberOrAddressPickerDialog = null + } + } } override fun onPause() { @@ -109,7 +87,6 @@ abstract class GenericAddressPickerFragment : GenericMainFragment() { numberOrAddressPickerDialog = null } - @UiThread protected fun setupRecyclerView(view: RecyclerView) { recyclerView = view recyclerView.setHasFixedSize(true) @@ -119,7 +96,6 @@ abstract class GenericAddressPickerFragment : GenericMainFragment() { recyclerView.addItemDecoration(headerItemDecoration) } - @UiThread protected fun attachAdapter() { if (::recyclerView.isInitialized) { if (recyclerView.adapter != adapter) { @@ -128,82 +104,21 @@ abstract class GenericAddressPickerFragment : GenericMainFragment() { } } - @WorkerThread - fun onAddressSelected(address: Address, friend: Friend) { - if (viewModel.multipleSelectionMode.value == true) { - val avatarModel = coreContext.contactsManager.getContactAvatarModelForAddress(address) - val model = SelectedAddressModel(address, avatarModel) { - viewModel.removeAddressModelFromSelection(it) - } - viewModel.addAddressModelToSelection(model) - } else { - onSingleAddressSelected(address, friend) - } - - // Clear filter after it was used - coreContext.postOnMainThread { - viewModel.clearFilter() - } - } - - private fun handleClickOnContactModel(model: ConversationContactOrSuggestionModel) { - if (model.selected.value == true) { - Log.i( - "$TAG User clicked on already selected item [${model.name}], removing it from selection" + private fun showNumbersOrAddressesDialog(list: List) { + val numberOrAddressModel = NumberOrAddressPickerDialogModel(list) + val dialog = + DialogUtils.getNumberOrAddressPickerDialog( + requireActivity(), + numberOrAddressModel ) - val found = viewModel.selection.value.orEmpty().find { - it.address.weakEqual(model.address) || it.avatarModel?.friend == model.friend - } - if (found != null) { - coreContext.postOnCoreThread { - viewModel.removeAddressModelFromSelection(found) - } - return - } else { - Log.e("$TAG Failed to find already selected entry matching the one clicked") - } - } + numberOrAddressPickerDialog = dialog - coreContext.postOnCoreThread { core -> - val friend = model.friend - if (friend == null) { - Log.i("$TAG Friend is null, using address [${model.address.asStringUriOnly()}]") - val fakeFriend = core.createFriend() - fakeFriend.addAddress(model.address) - onAddressSelected(model.address, fakeFriend) - return@postOnCoreThread - } - - val singleAvailableAddress = LinphoneUtils.getSingleAvailableAddressForFriend(friend) - if (singleAvailableAddress != null) { - Log.i( - "$TAG Only 1 SIP address or phone number found for contact [${friend.name}], using it" - ) - onAddressSelected(singleAvailableAddress, friend) - } else { - val list = friend.getListOfSipAddressesAndPhoneNumbers(listener) - Log.i( - "$TAG [${list.size}] numbers or addresses found for contact [${friend.name}], showing selection dialog" - ) - - coreContext.postOnMainThread { - val numberOrAddressModel = NumberOrAddressPickerDialogModel(list) - val dialog = - DialogUtils.getNumberOrAddressPickerDialog( - requireActivity(), - numberOrAddressModel - ) - numberOrAddressPickerDialog = dialog - - numberOrAddressModel.dismissEvent.observe(viewLifecycleOwner) { event -> - event.consume { - dialog.dismiss() - } - } - - dialog.show() - } + numberOrAddressModel.dismissEvent.observe(viewLifecycleOwner) { event -> + event.consume { + dialog.dismiss() } } + + dialog.show() } } diff --git a/app/src/main/java/org/linphone/ui/main/help/fragment/DebugFragment.kt b/app/src/main/java/org/linphone/ui/main/help/fragment/DebugFragment.kt index 6a312c1a61..f5ce1b5b9d 100644 --- a/app/src/main/java/org/linphone/ui/main/help/fragment/DebugFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/help/fragment/DebugFragment.kt @@ -20,9 +20,6 @@ package org.linphone.ui.main.help.fragment import android.content.ActivityNotFoundException -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context import android.content.Intent import android.os.Bundle import android.view.LayoutInflater @@ -39,6 +36,7 @@ import org.linphone.ui.fileviewer.FileViewerActivity import org.linphone.ui.main.MainActivity import org.linphone.ui.main.fragment.GenericMainFragment import org.linphone.ui.main.help.viewmodel.HelpViewModel +import org.linphone.utils.AppUtils class DebugFragment : GenericMainFragment() { private lateinit var binding: HelpDebugFragmentBinding @@ -69,19 +67,15 @@ class DebugFragment : GenericMainFragment() { } binding.setAppVersionClickListener { - val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val label = getString(R.string.help_troubleshooting_app_version_title) - clipboard.setPrimaryClip( - ClipData.newPlainText(label, viewModel.appVersion.value.orEmpty()) - ) + val value = viewModel.appVersion.value.orEmpty() + AppUtils.copyToClipboard(requireContext(), label, value) } binding.setSdkVersionClickListener { - val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val label = getString(R.string.help_troubleshooting_sdk_version_title) - clipboard.setPrimaryClip( - ClipData.newPlainText(label, viewModel.sdkVersion.value.orEmpty()) - ) + val value = viewModel.sdkVersion.value.orEmpty() + AppUtils.copyToClipboard(requireContext(), label, value) } viewModel.debugLogsCleanedEvent.observe(viewLifecycleOwner) { @@ -96,9 +90,7 @@ class DebugFragment : GenericMainFragment() { viewModel.uploadDebugLogsFinishedEvent.observe(viewLifecycleOwner) { it.consume { url -> if (requireActivity() is AssistantActivity) { - val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val label = "Logs upload URL" - clipboard.setPrimaryClip(ClipData.newPlainText(label, url)) + AppUtils.copyToClipboard(requireContext(), "Logs upload URL", url) return@consume } diff --git a/app/src/main/java/org/linphone/ui/main/help/viewmodel/HelpViewModel.kt b/app/src/main/java/org/linphone/ui/main/help/viewmodel/HelpViewModel.kt index 2dd6f40cde..2bb288448e 100644 --- a/app/src/main/java/org/linphone/ui/main/help/viewmodel/HelpViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/help/viewmodel/HelpViewModel.kt @@ -29,9 +29,9 @@ import org.linphone.BuildConfig import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.LinphoneApplication.Companion.corePreferences import org.linphone.R -import org.linphone.contacts.ContactLoader.Companion.NATIVE_ADDRESS_BOOK_FRIEND_LIST import org.linphone.core.Core import org.linphone.core.CoreListenerStub +import org.linphone.core.Factory import org.linphone.core.VersionUpdateCheckResult import org.linphone.core.tools.Log import org.linphone.ui.GenericViewModel @@ -44,6 +44,8 @@ class HelpViewModel constructor() : GenericViewModel() { companion object { private const val TAG = "[Help ViewModel]" + + private const val NUMBER_OF_CLICK_TO_ENABLE_DEVELOPER_MODE = 6 } val version = MutableLiveData() @@ -60,38 +62,42 @@ class HelpViewModel val logsUploadInProgress = MutableLiveData() - val versionClickCount = MutableLiveData() + val printLogsInLogcat = MutableLiveData() + + val developerSettingsEnabled = MutableLiveData() val canConfigFileBeViewed = MutableLiveData() val newVersionAvailableEvent: MutableLiveData>> by lazy { - MutableLiveData>>() + MutableLiveData() } val versionUpToDateEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val errorEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val debugLogsCleanedEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val uploadDebugLogsFinishedEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val uploadDebugLogsErrorEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val showConfigFileEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } + private var versionClickCount: Int = 0 + private val coreListener = object : CoreListenerStub() { @WorkerThread override fun onVersionUpdateCheckResultReceived( @@ -138,7 +144,6 @@ class HelpViewModel init { val currentVersion = BuildConfig.VERSION_NAME version.value = currentVersion - versionClickCount.value = 0 val versionCode = BuildConfig.VERSION_CODE val appGitDescribe = AppUtils.getString(R.string.linphone_app_version) @@ -155,9 +160,18 @@ class HelpViewModel firebaseProjectId.value = "unknown" } + versionClickCount = if (corePreferences.showDeveloperSettings) { + Log.i("$TAG Developer settings are already enabled") + NUMBER_OF_CLICK_TO_ENABLE_DEVELOPER_MODE + } else { + 0 + } + coreContext.postOnCoreThread { core -> core.addListener(coreListener) + printLogsInLogcat.postValue(corePreferences.printLogsInLogcat) + developerSettingsEnabled.postValue(corePreferences.showDeveloperSettings) checkUpdateAvailable.postValue(corePreferences.checkForUpdateServerUrl.isNotEmpty()) uploadLogsAvailable.postValue(!core.logCollectionUploadServerUrl.isNullOrEmpty()) } @@ -174,19 +188,27 @@ class HelpViewModel @UiThread fun versionClicked() { - if (corePreferences.showDeveloperSettings == true) { - showRedToast(R.string.settings_developer_already_enabled_toast, R.drawable.warning_circle) - return - } - - versionClickCount.value = (versionClickCount.value ?: 0) + 1 - if (versionClickCount.value == 7) { - coreContext.postOnCoreThread { - Log.w("$TAG Version was clicked seven times, enabling developer settings") - corePreferences.showDeveloperSettings = true - + versionClickCount += 1 + Log.i("$TAG Version was clicked [$versionClickCount] times") + when (versionClickCount) { + NUMBER_OF_CLICK_TO_ENABLE_DEVELOPER_MODE - 2 -> { + showGreenToast(R.string.settings_developer_two_more_clicks_required_toast, R.drawable.gear) + } + NUMBER_OF_CLICK_TO_ENABLE_DEVELOPER_MODE - 1 -> { + showGreenToast(R.string.settings_developer_one_more_click_required_toast, R.drawable.gear) + } + NUMBER_OF_CLICK_TO_ENABLE_DEVELOPER_MODE -> { showGreenToast(R.string.settings_developer_enabled_toast, R.drawable.gear) + coreContext.postOnCoreThread { + Log.w("$TAG Enabling developer settings") + corePreferences.showDeveloperSettings = true + developerSettingsEnabled.postValue(true) + } + } + NUMBER_OF_CLICK_TO_ENABLE_DEVELOPER_MODE + 1 -> { + showGreenToast(R.string.settings_developer_already_enabled_toast, R.drawable.gear) } + else -> { } } } @@ -217,6 +239,17 @@ class HelpViewModel } } + @UiThread + fun toggleLogcat() { + val newValue = printLogsInLogcat.value == false + coreContext.postOnCoreThread { + corePreferences.printLogsInLogcat = newValue + coreContext.updateLogcatEnabledSetting(newValue) + Factory.instance().enableLogcatLogs(newValue) + printLogsInLogcat.postValue(newValue) + } + } + @UiThread fun showConfigFile() { coreContext.postOnCoreThread { core -> @@ -236,20 +269,4 @@ class HelpViewModel } } } - - @UiThread - fun clearNativeFriendsDatabase() { - coreContext.postOnCoreThread { core -> - val list = core.getFriendListByName(NATIVE_ADDRESS_BOOK_FRIEND_LIST) - if (list != null) { - val friends = list.friends - Log.i("$TAG Friend list to remove found with [${friends.size}] friends") - for (friend in friends) { - list.removeFriend(friend) - } - core.removeFriendList(list) - Log.i("$TAG Friend list [$NATIVE_ADDRESS_BOOK_FRIEND_LIST] removed") - } - } - } } diff --git a/app/src/main/java/org/linphone/ui/main/history/adapter/ContactHistoryListAdapter.kt b/app/src/main/java/org/linphone/ui/main/history/adapter/ContactHistoryListAdapter.kt index fc1feba30f..3b2e74878c 100644 --- a/app/src/main/java/org/linphone/ui/main/history/adapter/ContactHistoryListAdapter.kt +++ b/app/src/main/java/org/linphone/ui/main/history/adapter/ContactHistoryListAdapter.kt @@ -49,7 +49,7 @@ class ContactHistoryListAdapter : ListAdapter(CallLogDiffCallback()), + HeaderAdapter { + companion object { + private const val CALL_LOG_TYPE = 0 + private const val CONTACT_TYPE = 1 + private const val SUGGESTION_TYPE = 2 + } -class HistoryListAdapter : ListAdapter(CallLogDiffCallback()) { var selectedAdapterPosition = -1 val callLogClickedEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val callLogLongClickedEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val callLogCallBackClickedEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - val binding: HistoryListCellBinding = DataBindingUtil.inflate( - LayoutInflater.from(parent.context), - R.layout.history_list_cell, - parent, - false - ) - val viewHolder = ViewHolder(binding) - binding.apply { - lifecycleOwner = parent.findViewTreeLifecycleOwner() + val callFriendClickedEvent: MutableLiveData> by lazy { + MutableLiveData() + } + + val callAddressClickedEvent: MutableLiveData> by lazy { + MutableLiveData() + } + + override fun displayHeaderForPosition(position: Int): Boolean { + // Don't show header for call history section + if (position == 0 && getItemViewType(0) == CALL_LOG_TYPE) { + return false + } - setOnClickListener { - callLogClickedEvent.value = Event(model!!) + return getItemViewType(position) != getItemViewType(position - 1) + } + + override fun getHeaderViewForPosition( + context: Context, + position: Int + ): View { + val binding = GenericAddressPickerListDecorationBinding.inflate( + LayoutInflater.from(context) + ) + binding.header.text = when (getItemViewType(position)) { + SUGGESTION_TYPE -> { + AppUtils.getString(R.string.generic_address_picker_suggestions_list_title) + } + else -> { + AppUtils.getString(R.string.generic_address_picker_contacts_list_title) } + } + return binding.root + } - setOnLongClickListener { - selectedAdapterPosition = viewHolder.bindingAdapterPosition - root.isSelected = true - callLogLongClickedEvent.value = Event(model!!) - true + override fun getItemViewType(position: Int): Int { + try { + val model = getItem(position) + return if (model.isCallLog) { + CALL_LOG_TYPE + } else if (model.contactModel?.friend != null) { + CONTACT_TYPE + } else { + SUGGESTION_TYPE } + } catch (ioobe: IndexOutOfBoundsException) { - setOnCallClickListener { - callLogCallBackClickedEvent.value = Event(model!!) + } + return CALL_LOG_TYPE + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return when (viewType) { + CALL_LOG_TYPE -> { + val binding: HistoryListCellBinding = DataBindingUtil.inflate( + LayoutInflater.from(parent.context), + R.layout.history_list_cell, + parent, + false + ) + val viewHolder = CallLogViewHolder(binding) + binding.apply { + lifecycleOwner = parent.findViewTreeLifecycleOwner() + + setOnClickListener { + callLogClickedEvent.value = Event(model!!) + } + + setOnLongClickListener { + selectedAdapterPosition = viewHolder.bindingAdapterPosition + root.isSelected = true + callLogLongClickedEvent.value = Event(model!!) + true + } + + setOnCallClickListener { + callLogCallBackClickedEvent.value = Event(model!!) + } + } + viewHolder + } + else -> { + val binding: HistoryListContactSuggestionCellBinding = DataBindingUtil.inflate( + LayoutInflater.from(parent.context), + R.layout.history_list_contact_suggestion_cell, + parent, + false + ) + binding.apply { + lifecycleOwner = parent.findViewTreeLifecycleOwner() + + setOnCallClickListener { + val friend = model?.friend + if (friend != null) { + callFriendClickedEvent.value = Event(friend) + } else { + callAddressClickedEvent.value = Event(model!!.address) + } + } + } + ContactSuggestionViewHolder(binding) } } - return viewHolder } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - (holder as ViewHolder).bind(getItem(position)) + when (getItemViewType(position)) { + CALL_LOG_TYPE -> (holder as CallLogViewHolder).bind(getItem(position).callLogModel!!) + else -> (holder as ContactSuggestionViewHolder).bind(getItem(position).contactModel!!) + } } fun resetSelection() { @@ -86,7 +183,7 @@ class HistoryListAdapter : ListAdapter(Ca selectedAdapterPosition = -1 } - inner class ViewHolder( + inner class CallLogViewHolder( val binding: HistoryListCellBinding ) : RecyclerView.ViewHolder(binding.root) { @UiThread @@ -101,13 +198,34 @@ class HistoryListAdapter : ListAdapter(Ca } } - private class CallLogDiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: CallLogModel, newItem: CallLogModel): Boolean { - return oldItem.id == newItem.id && oldItem.timestamp == newItem.timestamp + class ContactSuggestionViewHolder( + val binding: HistoryListContactSuggestionCellBinding + ) : RecyclerView.ViewHolder(binding.root) { + @UiThread + fun bind(conversationContactOrSuggestionModel: ConversationContactOrSuggestionModel) { + with(binding) { + model = conversationContactOrSuggestionModel + + executePendingBindings() + } } + } - override fun areContentsTheSame(oldItem: CallLogModel, newItem: CallLogModel): Boolean { - return false // ContactAvatarModel will be the same object but with an updated content + private class CallLogDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: CallLogModelWrapper, newItem: CallLogModelWrapper): Boolean { + if (oldItem.isCallLog && newItem.isCallLog) { + return oldItem.callLogModel?.id == newItem.callLogModel?.id && oldItem.callLogModel?.timestamp == newItem.callLogModel?.timestamp + } else if (oldItem.isContactOrSuggestion && newItem.isContactOrSuggestion) { + return oldItem.contactModel?.id == newItem.contactModel?.id + } + return false + } + + override fun areContentsTheSame(oldItem: CallLogModelWrapper, newItem: CallLogModelWrapper): Boolean { + if (oldItem.isCallLog && newItem.isCallLog) { + return newItem.callLogModel?.avatarModel?.compare(oldItem.callLogModel?.avatarModel) == true + } + return false } } } diff --git a/app/src/main/java/org/linphone/ui/main/history/fragment/HistoryFragment.kt b/app/src/main/java/org/linphone/ui/main/history/fragment/HistoryFragment.kt index 4fad504486..b503cf4843 100644 --- a/app/src/main/java/org/linphone/ui/main/history/fragment/HistoryFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/history/fragment/HistoryFragment.kt @@ -19,9 +19,6 @@ */ package org.linphone.ui.main.history.fragment -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context import android.os.Bundle import android.view.Gravity import android.view.LayoutInflater @@ -34,6 +31,7 @@ import androidx.lifecycle.ViewModelProvider import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import androidx.recyclerview.widget.LinearLayoutManager +import org.linphone.LinphoneApplication.Companion.corePreferences import org.linphone.R import org.linphone.core.tools.Log import org.linphone.databinding.HistoryFragmentBinding @@ -43,6 +41,7 @@ import org.linphone.ui.main.fragment.SlidingPaneChildFragment import org.linphone.ui.main.history.adapter.ContactHistoryListAdapter import org.linphone.utils.ConfirmationDialogModel import org.linphone.ui.main.history.viewmodel.HistoryViewModel +import org.linphone.utils.AppUtils import org.linphone.utils.DialogUtils import org.linphone.utils.Event @@ -189,14 +188,12 @@ class HistoryFragment : SlidingPaneChildFragment() { } private fun copyNumberOrAddressToClipboard(value: String) { - val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val label = "SIP address" - clipboard.setPrimaryClip(ClipData.newPlainText(label, value)) - - (requireActivity() as GenericActivity).showGreenToast( - getString(R.string.sip_address_copied_to_clipboard_toast), - R.drawable.check - ) + if (AppUtils.copyToClipboard(requireContext(), "SIP address", value)) { + (requireActivity() as GenericActivity).showGreenToast( + getString(R.string.sip_address_copied_to_clipboard_toast), + R.drawable.check + ) + } } private fun showPopupMenu() { @@ -216,6 +213,7 @@ class HistoryFragment : SlidingPaneChildFragment() { popupView.contactExists = viewModel.callLogModel.value?.friendExists == true popupView.isConferenceCallLog = viewModel.isConferenceCallLog.value == true + popupView.disableAddContact = corePreferences.disableAddContact popupView.setAddToContactsListener { sharedViewModel.sipAddressToAddToNewContact = viewModel.callLogModel.value?.displayedAddress.orEmpty() diff --git a/app/src/main/java/org/linphone/ui/main/history/fragment/HistoryListFragment.kt b/app/src/main/java/org/linphone/ui/main/history/fragment/HistoryListFragment.kt index 2cc9d6db50..1a792a0430 100644 --- a/app/src/main/java/org/linphone/ui/main/history/fragment/HistoryListFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/history/fragment/HistoryListFragment.kt @@ -19,19 +19,13 @@ */ package org.linphone.ui.main.history.fragment -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context import android.os.Bundle -import android.view.Gravity import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.animation.Animation import android.view.animation.AnimationUtils -import android.widget.PopupWindow import androidx.annotation.UiThread -import androidx.databinding.DataBindingUtil import androidx.lifecycle.ViewModelProvider import androidx.navigation.findNavController import androidx.navigation.fragment.findNavController @@ -39,16 +33,23 @@ import androidx.recyclerview.widget.LinearLayoutManager import com.google.android.material.bottomsheet.BottomSheetDialogFragment import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.R +import org.linphone.contacts.getListOfSipAddressesAndPhoneNumbers import org.linphone.core.tools.Log import org.linphone.databinding.HistoryListFragmentBinding -import org.linphone.databinding.HistoryListPopupMenuBinding import org.linphone.ui.GenericActivity +import org.linphone.ui.main.contacts.model.ContactNumberOrAddressClickListener +import org.linphone.ui.main.contacts.model.ContactNumberOrAddressModel +import org.linphone.ui.main.contacts.model.NumberOrAddressPickerDialogModel import org.linphone.ui.main.fragment.AbstractMainFragment import org.linphone.ui.main.history.adapter.HistoryListAdapter +import org.linphone.ui.main.history.model.CallLogModel import org.linphone.utils.ConfirmationDialogModel import org.linphone.ui.main.history.viewmodel.HistoryListViewModel +import org.linphone.utils.AppUtils import org.linphone.utils.DialogUtils import org.linphone.utils.Event +import org.linphone.utils.LinphoneUtils +import org.linphone.utils.RecyclerViewHeaderDecoration @UiThread class HistoryListFragment : AbstractMainFragment() { @@ -64,11 +65,26 @@ class HistoryListFragment : AbstractMainFragment() { private var bottomSheetDialog: BottomSheetDialogFragment? = null + private val numberOrAddressClickListener = object : ContactNumberOrAddressClickListener { + @UiThread + override fun onClicked(model: ContactNumberOrAddressModel) { + coreContext.postOnCoreThread { + val address = model.address + if (address != null) { + Log.i("$TAG Starting call to [${address.asStringUriOnly()}]") + coreContext.startAudioCall(address) + } + } + } + + override fun onLongPress(model: ContactNumberOrAddressModel) { } + } + override fun onDefaultAccountChanged() { Log.i( "$TAG Default account changed, updating avatar in top bar & re-computing call logs" ) - listViewModel.applyFilter() + listViewModel.filter() } override fun onCreateAnimation(transit: Int, enter: Boolean, nextAnim: Int): Animation? { @@ -110,6 +126,9 @@ class HistoryListFragment : AbstractMainFragment() { binding.historyList.outlineProvider = outlineProvider binding.historyList.clipToOutline = true + val headerItemDecoration = RecyclerViewHeaderDecoration(requireContext(), adapter) + binding.historyList.addItemDecoration(headerItemDecoration) + adapter.callLogLongClickedEvent.observe(viewLifecycleOwner) { it.consume { model -> val modalBottomSheet = HistoryMenuDialogFragment( @@ -146,9 +165,7 @@ class HistoryListFragment : AbstractMainFragment() { copyNumberOrAddressToClipboard(addressToCopy) }, { // onDeleteCallLog - Log.i("$TAG Deleting call log with ref key or call ID [${model.id}]") - model.delete() - listViewModel.applyFilter() + showDeleteConfirmationDialog(model) } ) modalBottomSheet.show(parentFragmentManager, HistoryMenuDialogFragment.TAG) @@ -188,6 +205,35 @@ class HistoryListFragment : AbstractMainFragment() { } } + adapter.callFriendClickedEvent.observe(viewLifecycleOwner) { + it.consume { friend -> + coreContext.postOnCoreThread { + val singleAvailableAddress = LinphoneUtils.getSingleAvailableAddressForFriend(friend) + if (singleAvailableAddress != null) { + Log.i( + "$TAG Only 1 SIP address or phone number found for contact [${friend.name}], using it" + ) + coreContext.startAudioCall(singleAvailableAddress) + } else { + val list = friend.getListOfSipAddressesAndPhoneNumbers(numberOrAddressClickListener) + Log.i( + "$TAG [${list.size}] numbers or addresses found for contact [${friend.name}], showing selection dialog" + ) + coreContext.postOnMainThread { + showNumbersOrAddressesDialog(list) + } + } + } + } + } + + adapter.callAddressClickedEvent.observe(viewLifecycleOwner) { + it.consume { address -> + Log.i("$TAG Starting call to [${address.asStringUriOnly()}]") + coreContext.startAudioCall(address) + } + } + listViewModel.callLogs.observe(viewLifecycleOwner) { adapter.submitList(it) @@ -220,7 +266,8 @@ class HistoryListFragment : AbstractMainFragment() { sharedViewModel.forceRefreshCallLogsListEvent.observe(viewLifecycleOwner) { it.consume { - listViewModel.applyFilter() + Log.i("$TAG Re-compute call log history") + listViewModel.filter() } } @@ -237,8 +284,8 @@ class HistoryListFragment : AbstractMainFragment() { } } - binding.setMenuClickListener { - showPopupMenu() + binding.setDeleteAllClickListener { + showDeleteAllConfirmationDialog() } binding.setStartCallClickListener { @@ -283,41 +330,15 @@ class HistoryListFragment : AbstractMainFragment() { } private fun copyNumberOrAddressToClipboard(value: String) { - val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val label = "SIP address" - clipboard.setPrimaryClip(ClipData.newPlainText(label, value)) - - (requireActivity() as GenericActivity).showGreenToast( - getString(R.string.sip_address_copied_to_clipboard_toast), - R.drawable.check - ) - } - - private fun showPopupMenu() { - val popupView: HistoryListPopupMenuBinding = DataBindingUtil.inflate( - LayoutInflater.from(requireContext()), - R.layout.history_list_popup_menu, - null, - false - ) - val popupWindow = PopupWindow( - popupView.root, - ViewGroup.LayoutParams.WRAP_CONTENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - true - ) - - popupView.setDeleteAllHistoryClickListener { - showDeleteConfirmationDialog() - popupWindow.dismiss() + if (AppUtils.copyToClipboard(requireContext(), "SIP address", value)) { + (requireActivity() as GenericActivity).showGreenToast( + getString(R.string.sip_address_copied_to_clipboard_toast), + R.drawable.check + ) } - - // Elevation is for showing a shadow around the popup - popupWindow.elevation = 20f - popupWindow.showAsDropDown(binding.topBar.extraAction, 0, 0, Gravity.BOTTOM) } - private fun showDeleteConfirmationDialog() { + private fun showDeleteAllConfirmationDialog() { val model = ConfirmationDialogModel() val dialog = DialogUtils.getRemoveAllCallLogsConfirmationDialog( requireActivity(), @@ -340,4 +361,46 @@ class HistoryListFragment : AbstractMainFragment() { dialog.show() } + + private fun showDeleteConfirmationDialog(callLogModel: CallLogModel) { + val dialogModel = ConfirmationDialogModel() + val dialog = DialogUtils.getRemoveCallLogConfirmationDialog( + requireActivity(), + dialogModel + ) + + dialogModel.dismissEvent.observe(viewLifecycleOwner) { + it.consume { + dialog.dismiss() + } + } + + dialogModel.confirmEvent.observe(viewLifecycleOwner) { + it.consume { + Log.i("$TAG Deleting call log with ref key or call ID [${callLogModel.id}]") + callLogModel.delete() + listViewModel.filter() + dialog.dismiss() + } + } + + dialog.show() + } + + private fun showNumbersOrAddressesDialog(list: List) { + val numberOrAddressModel = NumberOrAddressPickerDialogModel(list) + val dialog = + DialogUtils.getNumberOrAddressPickerDialog( + requireActivity(), + numberOrAddressModel + ) + + numberOrAddressModel.dismissEvent.observe(viewLifecycleOwner) { event -> + event.consume { + dialog.dismiss() + } + } + + dialog.show() + } } diff --git a/app/src/main/java/org/linphone/ui/main/history/fragment/HistoryMenuDialogFragment.kt b/app/src/main/java/org/linphone/ui/main/history/fragment/HistoryMenuDialogFragment.kt index cab571da49..15ee5d5a5c 100644 --- a/app/src/main/java/org/linphone/ui/main/history/fragment/HistoryMenuDialogFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/history/fragment/HistoryMenuDialogFragment.kt @@ -29,6 +29,7 @@ import androidx.annotation.UiThread import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import org.linphone.LinphoneApplication.Companion.corePreferences import org.linphone.databinding.HistoryListLongPressMenuBinding @UiThread @@ -69,6 +70,7 @@ class HistoryMenuDialogFragment( ): View { val view = HistoryListLongPressMenuBinding.inflate(layoutInflater) view.contactExists = contactExists + view.disableAddContact = corePreferences.disableAddContact view.setCopyNumberClickListener { onCopyNumberOrAddressToClipboard?.invoke() diff --git a/app/src/main/java/org/linphone/ui/main/history/fragment/StartCallFragment.kt b/app/src/main/java/org/linphone/ui/main/history/fragment/StartCallFragment.kt index 573d16b8c3..ee07240881 100644 --- a/app/src/main/java/org/linphone/ui/main/history/fragment/StartCallFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/history/fragment/StartCallFragment.kt @@ -26,15 +26,12 @@ import android.view.View import android.view.ViewGroup import androidx.activity.OnBackPressedCallback import androidx.annotation.UiThread -import androidx.annotation.WorkerThread import androidx.core.view.doOnPreDraw import androidx.lifecycle.ViewModelProvider import com.google.android.material.bottomsheet.BottomSheetBehavior import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.LinphoneApplication.Companion.corePreferences import org.linphone.R -import org.linphone.core.Address -import org.linphone.core.Friend import org.linphone.core.tools.Log import org.linphone.databinding.StartCallFragmentBinding import org.linphone.ui.GenericActivity @@ -42,7 +39,6 @@ import org.linphone.ui.main.fragment.GenericAddressPickerFragment import org.linphone.ui.main.history.viewmodel.StartCallViewModel import org.linphone.ui.main.model.GroupSetOrEditSubjectDialogModel import org.linphone.utils.DialogUtils -import org.linphone.utils.Event import org.linphone.utils.addCharacterAtPosition import org.linphone.utils.hideKeyboard import org.linphone.utils.removeCharacterAtPosition @@ -209,19 +205,11 @@ class StartCallFragment : GenericAddressPickerFragment() { ) } - @WorkerThread - override fun onSingleAddressSelected(address: Address, friend: Friend) { - coreContext.startAudioCall(address) - viewModel.leaveFragmentEvent.postValue(Event(true)) - } - override fun onResume() { super.onResume() - coreContext.postOnCoreThread { - if (corePreferences.automaticallyShowDialpad) { - viewModel.isNumpadVisible.postValue(true) - } + if (corePreferences.automaticallyShowDialpad) { + viewModel.isNumpadVisible.value = true } } diff --git a/app/src/main/java/org/linphone/ui/main/history/model/CallLogModel.kt b/app/src/main/java/org/linphone/ui/main/history/model/CallLogModel.kt index 2179a07c29..56df7e8724 100644 --- a/app/src/main/java/org/linphone/ui/main/history/model/CallLogModel.kt +++ b/app/src/main/java/org/linphone/ui/main/history/model/CallLogModel.kt @@ -23,7 +23,6 @@ import androidx.annotation.IntegerRes import androidx.annotation.UiThread import androidx.annotation.WorkerThread import org.linphone.LinphoneApplication.Companion.coreContext -import org.linphone.LinphoneApplication.Companion.corePreferences import org.linphone.R import org.linphone.core.CallLog import org.linphone.core.tools.Log @@ -97,11 +96,7 @@ class CallLogModel friendRefKey = friend.refKey friendExists = coreContext.contactsManager.isContactAvailable(friend) } - displayedAddress = if (corePreferences.onlyDisplaySipUriUsername) { - address.username ?: "" - } else { - sipUri - } + displayedAddress = LinphoneUtils.getDisplayAddress(address) iconResId = LinphoneUtils.getCallIconResId(callLog.status, callLog.dir) } diff --git a/app/src/main/java/org/linphone/ui/main/history/model/CallLogModelWrapper.kt b/app/src/main/java/org/linphone/ui/main/history/model/CallLogModelWrapper.kt new file mode 100644 index 0000000000..76beaecffd --- /dev/null +++ b/app/src/main/java/org/linphone/ui/main/history/model/CallLogModelWrapper.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2010-2026 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.ui.main.history.model + +import org.linphone.ui.main.model.ConversationContactOrSuggestionModel + +class CallLogModelWrapper(val callLogModel: CallLogModel?, val contactModel: ConversationContactOrSuggestionModel? = null) { + val isCallLog = callLogModel != null + + val isContactOrSuggestion = contactModel != null +} diff --git a/app/src/main/java/org/linphone/ui/main/history/model/NumpadModel.kt b/app/src/main/java/org/linphone/ui/main/history/model/NumpadModel.kt index 9a74695c99..5eff23c66f 100644 --- a/app/src/main/java/org/linphone/ui/main/history/model/NumpadModel.kt +++ b/app/src/main/java/org/linphone/ui/main/history/model/NumpadModel.kt @@ -19,12 +19,16 @@ */ package org.linphone.ui.main.history.model +import android.provider.Settings +import android.provider.Settings.SettingNotFoundException +import android.view.View import androidx.annotation.UiThread import androidx.lifecycle.MutableLiveData import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.LinphoneApplication.Companion.corePreferences import org.linphone.core.tools.Log import org.linphone.utils.LinphoneUtils +import org.linphone.utils.TouchListener open class NumpadModel @UiThread @@ -47,7 +51,38 @@ open class NumpadModel val showLetters = MutableLiveData() + val systemPolicyAllowsDtmf: Boolean + + val touchListener = object : TouchListener { + override fun onPressed(view: View): Boolean { + Log.i("$TAG Numpad digit [${view.tag}] pressed") + startPlayingDtmf(view.tag.toString()) + return false + } + + override fun onReleased(view: View): Boolean { + Log.i("$TAG Numpad digit [${view.tag}] released") + stopPlayingDtmf(view.tag.toString()) + return false + } + } + init { + var dtmfAllowed = false + try { + dtmfAllowed = Settings.System.getInt( + coreContext.context.contentResolver, + Settings.System.DTMF_TONE_WHEN_DIALING + ) != 0 + if (!dtmfAllowed) { + Log.w("$TAG Numpad DTMF tones are disabled in system settings, not playing them") + } + } catch (snfe: SettingNotFoundException) { + Log.e("$TAG DTMF_TONE_WHEN_DIALING system setting not found: $snfe") + dtmfAllowed = false + } + systemPolicyAllowsDtmf = dtmfAllowed + coreContext.postOnCoreThread { showLetters.postValue(corePreferences.showLettersOnDialpad) @@ -61,12 +96,6 @@ open class NumpadModel fun onDigitClicked(value: String) { Log.i("$TAG Clicked on digit [$value]") onDigitClicked.invoke(value) - - if (value.isNotEmpty()) { - coreContext.postOnCoreThread { - coreContext.playDtmf(value[0], ignoreSystemPolicy = inCallNumpad) - } - } } @UiThread @@ -113,4 +142,24 @@ open class NumpadModel Log.i("$TAG Transferring call") onTransferCallClicked.invoke() } + + @UiThread + private fun startPlayingDtmf(dtmf: String) { + if (dtmf.isEmpty() || (!inCallNumpad && !systemPolicyAllowsDtmf)) return + + coreContext.postOnCoreThread { core -> + Log.i("$TAG Start playing DTMF [$dtmf]") + core.playDtmf(dtmf[0], -1) + } + } + + @UiThread + private fun stopPlayingDtmf(dtmf: String) { + if (dtmf.isEmpty() || (!inCallNumpad && !systemPolicyAllowsDtmf)) return + + coreContext.postOnCoreThread { core -> + Log.i("$TAG Stop playing DTMF [$dtmf]") + core.stopDtmf() + } + } } diff --git a/app/src/main/java/org/linphone/ui/main/history/viewmodel/HistoryListViewModel.kt b/app/src/main/java/org/linphone/ui/main/history/viewmodel/HistoryListViewModel.kt index 422996ffdc..eddf951176 100644 --- a/app/src/main/java/org/linphone/ui/main/history/viewmodel/HistoryListViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/history/viewmodel/HistoryListViewModel.kt @@ -23,17 +23,27 @@ import androidx.annotation.UiThread import androidx.annotation.WorkerThread import androidx.lifecycle.MutableLiveData import org.linphone.LinphoneApplication.Companion.coreContext +import org.linphone.LinphoneApplication.Companion.corePreferences import org.linphone.contacts.ContactsManager +import org.linphone.core.Address import org.linphone.core.CallLog import org.linphone.core.Core import org.linphone.core.CoreListenerStub import org.linphone.core.Friend import org.linphone.core.GlobalState +import org.linphone.core.MagicSearch +import org.linphone.core.MagicSearchListenerStub +import org.linphone.core.SearchResult import org.linphone.core.tools.Log +import org.linphone.ui.main.contacts.model.ContactAvatarModel import org.linphone.ui.main.history.model.CallLogModel +import org.linphone.ui.main.history.model.CallLogModelWrapper +import org.linphone.ui.main.model.ConversationContactOrSuggestionModel import org.linphone.ui.main.viewmodel.AbstractMainViewModel import org.linphone.utils.Event import org.linphone.utils.LinphoneUtils +import java.text.Collator +import java.util.Locale class HistoryListViewModel @UiThread @@ -42,16 +52,30 @@ class HistoryListViewModel private const val TAG = "[History List ViewModel]" } - val callLogs = MutableLiveData>() + val callLogs = MutableLiveData>() val fetchInProgress = MutableLiveData() val historyInsertedEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val historyDeletedEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() + } + + private val tempCallLogsList = ArrayList() + + private val magicSearch = coreContext.core.createMagicSearch() + + private val magicSearchListener = object : MagicSearchListenerStub() { + @WorkerThread + override fun onSearchResultsReceived(magicSearch: MagicSearch) { + Log.i("$TAG Magic search contacts available") + val results = magicSearch.lastSearch + processMagicSearchResults(results) + fetchInProgress.postValue(false) + } } private val coreListener = object : CoreListenerStub() { @@ -88,6 +112,7 @@ class HistoryListViewModel coreContext.postOnCoreThread { core -> coreContext.contactsManager.addListener(contactsListener) core.addListener(coreListener) + magicSearch.addListener(magicSearchListener) computeCallLogsList(currentFilter) } @@ -100,6 +125,7 @@ class HistoryListViewModel coreContext.postOnCoreThread { core -> coreContext.contactsManager.removeListener(contactsListener) core.removeListener(coreListener) + magicSearch.removeListener(magicSearchListener) } } @@ -141,8 +167,17 @@ class HistoryListViewModel fetchInProgress.postValue(true) } - val list = arrayListOf() - var count = 0 + val isFilterEmpty = filter.isEmpty() + if (!isFilterEmpty) { + magicSearch.getContactsListAsync( + filter, + corePreferences.contactsFilter, + MagicSearch.Source.All.toInt(), + MagicSearch.Aggregation.Friend + ) + } + + val list = arrayListOf() val account = LinphoneUtils.getDefaultAccount() // Fetch all call logs if only one account to workaround no history issue @@ -156,17 +191,18 @@ class HistoryListViewModel for (callLog in logs) { val model = CallLogModel(callLog) if (isCallLogMatchingFilter(model, filter)) { - list.add(model) - count += 1 - } - - if (count == 20) { - callLogs.postValue(list) + list.add(CallLogModelWrapper(model)) } } Log.i("$TAG Fetched [${list.size}] call log(s)") - callLogs.postValue(list) + if (isFilterEmpty) { + callLogs.postValue(list) + } else { + fetchInProgress.postValue(true) + tempCallLogsList.clear() + tempCallLogsList.addAll(list) + } } @WorkerThread @@ -176,4 +212,83 @@ class HistoryListViewModel val friendName = model.avatarModel.friend.name ?: LinphoneUtils.getDisplayName(model.address) return friendName.contains(filter, ignoreCase = true) || model.address.asStringUriOnly().contains(filter, ignoreCase = true) } + + @WorkerThread + private fun processMagicSearchResults(results: Array) { + Log.i("$TAG Processing [${results.size}] results") + + val contactsList = arrayListOf() + val suggestionsList = arrayListOf() + val requestList = arrayListOf() + + for (result in results) { + val address = result.address + val friend = result.friend + if (friend != null) { + val found = contactsList.find { it.contactModel?.friend == friend } + if (found != null) continue + + val mainAddress = address ?: LinphoneUtils.getFirstAvailableAddressForFriend(friend) + if (mainAddress != null) { + val model = ConversationContactOrSuggestionModel(mainAddress, friend = friend) + val avatarModel = coreContext.contactsManager.getContactAvatarModelForFriend( + friend + ) + model.avatarModel.postValue(avatarModel) + contactsList.add(CallLogModelWrapper(null, model)) + } else { + Log.w("$TAG Found friend [${friend.name}] in search results but no Address could be found, skipping it") + } + } else if (address != null) { + if (result.sourceFlags == MagicSearch.Source.Request.toInt()) { + val model = ConversationContactOrSuggestionModel(address) { + coreContext.startAudioCall(address) + } + val avatarModel = getContactAvatarModelForAddress(address) + model.avatarModel.postValue(avatarModel) + requestList.add(CallLogModelWrapper(null, model)) + continue + } + + val defaultAccountAddress = coreContext.core.defaultAccount?.params?.identityAddress + if (defaultAccountAddress != null && address.weakEqual(defaultAccountAddress)) { + Log.i("$TAG Removing from suggestions current default account address") + continue + } + + val model = ConversationContactOrSuggestionModel(address) { + coreContext.startAudioCall(address) + } + val avatarModel = getContactAvatarModelForAddress(address) + model.avatarModel.postValue(avatarModel) + suggestionsList.add(CallLogModelWrapper(null, model)) + } + } + + val collator = Collator.getInstance(Locale.getDefault()) + contactsList.sortWith { model1, model2 -> + collator.compare(model1.contactModel?.name, model2.contactModel?.name) + } + suggestionsList.sortWith { model1, model2 -> + collator.compare(model1.contactModel?.name, model2.contactModel?.name) + } + + val list = arrayListOf() + list.addAll(tempCallLogsList) + list.addAll(contactsList) + list.addAll(suggestionsList) + list.addAll(requestList) + callLogs.postValue(list) + Log.i( + "$TAG Processed [${results.size}] results: [${contactsList.size}] contacts and [${suggestionsList.size}] suggestions" + ) + } + + @WorkerThread + private fun getContactAvatarModelForAddress(address: Address): ContactAvatarModel { + val fakeFriend = coreContext.core.createFriend() + fakeFriend.name = LinphoneUtils.getDisplayName(address) + fakeFriend.address = address + return ContactAvatarModel(fakeFriend) + } } diff --git a/app/src/main/java/org/linphone/ui/main/history/viewmodel/HistoryViewModel.kt b/app/src/main/java/org/linphone/ui/main/history/viewmodel/HistoryViewModel.kt index 024c60f562..9eae4870ae 100644 --- a/app/src/main/java/org/linphone/ui/main/history/viewmodel/HistoryViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/history/viewmodel/HistoryViewModel.kt @@ -63,26 +63,24 @@ class HistoryViewModel val isChatRoomAvailable = MutableLiveData() - val callLogFoundEvent = MutableLiveData>() + val hideSipAddresses = MutableLiveData() - val chatRoomCreationErrorEvent: MutableLiveData> by lazy { - MutableLiveData>() - } + val callLogFoundEvent = MutableLiveData>() val goToMeetingConversationEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val goToConversationEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val conferenceToJoinEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val historyDeletedEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } private lateinit var address: Address @@ -112,7 +110,7 @@ class HistoryViewModel if (state == ChatRoom.State.Instantiated) return val id = LinphoneUtils.getConversationId(chatRoom) - Log.i("$TAG Conversation [$id] (${chatRoom.subject}) state changed: [$state]") + Log.i("$TAG Conversation [$id] (${chatRoom.subjectUtf8}) state changed: [$state]") if (state == ChatRoom.State.Created) { Log.i("$TAG Conversation [$id] successfully created") @@ -123,9 +121,7 @@ class HistoryViewModel Log.e("$TAG Conversation [$id] creation has failed!") chatRoom.removeListener(this) operationInProgress.postValue(false) - chatRoomCreationErrorEvent.postValue( - Event(R.string.conversation_failed_to_create_toast) - ) + showRedToast(R.string.conversation_failed_to_create_toast, R.drawable.warning_circle) } } } @@ -135,6 +131,7 @@ class HistoryViewModel core.addListener(coreListener) chatDisabled.postValue(corePreferences.disableChat) videoCallDisabled.postValue(!core.isVideoEnabled) + hideSipAddresses.postValue(corePreferences.hideSipAddresses) } } @@ -304,9 +301,7 @@ class HistoryViewModel "$TAG Failed to create 1-1 conversation with [${remote.asStringUriOnly()}]!" ) operationInProgress.postValue(false) - chatRoomCreationErrorEvent.postValue( - Event(R.string.conversation_failed_to_create_toast) - ) + showRedToast(R.string.conversation_failed_to_create_toast, R.drawable.warning_circle) } } } diff --git a/app/src/main/java/org/linphone/ui/main/history/viewmodel/StartCallViewModel.kt b/app/src/main/java/org/linphone/ui/main/history/viewmodel/StartCallViewModel.kt index f253ef44b7..787bae29f9 100644 --- a/app/src/main/java/org/linphone/ui/main/history/viewmodel/StartCallViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/history/viewmodel/StartCallViewModel.kt @@ -32,6 +32,7 @@ import org.linphone.R import org.linphone.core.Address import org.linphone.core.Conference import org.linphone.core.ConferenceListenerStub +import org.linphone.core.Friend import org.linphone.core.MediaDirection import org.linphone.core.tools.Log import org.linphone.ui.main.history.model.NumpadModel @@ -61,27 +62,27 @@ class StartCallViewModel val operationInProgress = MutableLiveData() val appendDigitToSearchBarEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val removedCharacterAtCurrentPositionEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val clearSearchBarEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val requestKeyboardVisibilityChangedEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val leaveFragmentEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val initiateBlindTransferEvent: MutableLiveData>> by lazy { - MutableLiveData>>() + MutableLiveData() } private val conferenceListener = object : ConferenceListenerStub() { @@ -174,6 +175,12 @@ class StartCallViewModel updateGroupCallButtonVisibility() } + @WorkerThread + override fun onSingleAddressSelected(address: Address, friend: Friend?) { + coreContext.startAudioCall(address) + leaveFragmentEvent.postValue(Event(true)) + } + @UiThread fun updateGroupCallButtonVisibility() { coreContext.postOnCoreThread { core -> diff --git a/app/src/main/java/org/linphone/ui/main/meetings/adapter/MeetingParticipantsAdapter.kt b/app/src/main/java/org/linphone/ui/main/meetings/adapter/MeetingParticipantsAdapter.kt index 7cc0033b8f..77fca3be4b 100644 --- a/app/src/main/java/org/linphone/ui/main/meetings/adapter/MeetingParticipantsAdapter.kt +++ b/app/src/main/java/org/linphone/ui/main/meetings/adapter/MeetingParticipantsAdapter.kt @@ -49,7 +49,7 @@ class MeetingParticipantsAdapter : ListAdapter> by lazy { - MutableLiveData>() + MutableLiveData() } val meetingLongClickedEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } override fun displayHeaderForPosition(position: Int): Boolean { @@ -156,7 +156,7 @@ class MeetingsListAdapter : } } - inner class TodayIndicatorViewHolder( + class TodayIndicatorViewHolder( val binding: MeetingListTodayIndicatorBinding ) : RecyclerView.ViewHolder(binding.root) { @UiThread diff --git a/app/src/main/java/org/linphone/ui/main/meetings/fragment/MeetingFragment.kt b/app/src/main/java/org/linphone/ui/main/meetings/fragment/MeetingFragment.kt index 12a7bc1635..9ef01f6cc5 100644 --- a/app/src/main/java/org/linphone/ui/main/meetings/fragment/MeetingFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/meetings/fragment/MeetingFragment.kt @@ -20,9 +20,6 @@ package org.linphone.ui.main.meetings.fragment import android.content.ActivityNotFoundException -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context import android.content.Intent import android.os.Bundle import android.provider.CalendarContract @@ -47,6 +44,7 @@ import org.linphone.ui.main.fragment.SlidingPaneChildFragment import org.linphone.utils.ConfirmationDialogModel import org.linphone.ui.main.meetings.adapter.MeetingParticipantsAdapter import org.linphone.ui.main.meetings.viewmodel.MeetingViewModel +import org.linphone.utils.AppUtils import org.linphone.utils.DialogUtils import org.linphone.utils.Event @@ -136,7 +134,7 @@ class MeetingFragment : SlidingPaneChildFragment() { } } - binding.setShareClickListener { + binding.setCopyUriToClipboardClickListener { copyMeetingAddressIntoClipboard(uri) } @@ -223,14 +221,16 @@ class MeetingFragment : SlidingPaneChildFragment() { ) val isUserOrganizer = viewModel.isEditable.value == true && viewModel.isCancelled.value == false - popupView.cancelInsteadOfDelete = isUserOrganizer + val hasNotStartedYet = viewModel.hasNotStartedYet.value == true + val showCancelActionInsteadOfDelete = isUserOrganizer && hasNotStartedYet + popupView.cancelInsteadOfDelete = showCancelActionInsteadOfDelete popupView.setDeleteClickListener { - if (isUserOrganizer) { - // In case we are organizer of the meeting, ask user confirmation before cancelling it + if (isUserOrganizer && hasNotStartedYet) { + Log.i("$TAG Meeting start hasn't started yet and we are the organizer, asking user if it should be cancelled") showCancelMeetingDialog() } else { - // If we're not organizer, ask user confirmation of removing itself from participants & deleting it locally - showDeleteMeetingDialog() + Log.i("$TAG Deleting meeting [${viewModel.sipUri}]") + viewModel.delete() } popupWindow.dismiss() } @@ -247,15 +247,12 @@ class MeetingFragment : SlidingPaneChildFragment() { private fun copyMeetingAddressIntoClipboard(meetingSipUri: String) { Log.i("$TAG Copying conference SIP URI [$meetingSipUri] into clipboard") - - val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val label = "Meeting SIP address" - clipboard.setPrimaryClip(ClipData.newPlainText(label, meetingSipUri)) - - (requireActivity() as GenericActivity).showGreenToast( - getString(R.string.meeting_address_copied_to_clipboard_toast), - R.drawable.check - ) + if (AppUtils.copyToClipboard(requireContext(), "Meeting SIP address", meetingSipUri)) { + (requireActivity() as GenericActivity).showGreenToast( + getString(R.string.meeting_address_copied_to_clipboard_toast), + R.drawable.check + ) + } } private fun shareMeetingInfoAsCalendarEvent() { @@ -297,42 +294,18 @@ class MeetingFragment : SlidingPaneChildFragment() { } } - model.cancelEvent.observe(viewLifecycleOwner) { - it.consume { - dialog.dismiss() - } - } - model.confirmEvent.observe(viewLifecycleOwner) { it.consume { - viewModel.cancel() - dialog.dismiss() - } - } - - dialog.show() - } - - private fun showDeleteMeetingDialog() { - Log.i("$TAG Meeting is not editable or already cancelled, asking whether to delete it or not") - val model = ConfirmationDialogModel() - val dialog = DialogUtils.getDeleteMeetingDialog(requireContext(), model) - - model.dismissEvent.observe(viewLifecycleOwner) { - it.consume { + Log.i("$TAG Cancelling meeting [${viewModel.sipUri}] and sending notification to participants") + viewModel.cancel(true) dialog.dismiss() } } - model.cancelEvent.observe(viewLifecycleOwner) { + model.alternativeChoiceEvent.observe(viewLifecycleOwner) { it.consume { - dialog.dismiss() - } - } - - model.confirmEvent.observe(viewLifecycleOwner) { - it.consume { - viewModel.delete() + Log.i("$TAG Cancelling meeting [${viewModel.sipUri}] without notifying participants") + viewModel.cancel(false) dialog.dismiss() } } diff --git a/app/src/main/java/org/linphone/ui/main/meetings/fragment/MeetingsListFragment.kt b/app/src/main/java/org/linphone/ui/main/meetings/fragment/MeetingsListFragment.kt index 7f6e238d93..20b792cbe9 100644 --- a/app/src/main/java/org/linphone/ui/main/meetings/fragment/MeetingsListFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/meetings/fragment/MeetingsListFragment.kt @@ -77,7 +77,7 @@ class MeetingsListFragment : AbstractMainFragment() { Log.i( "$TAG Default account changed, updating avatar in top bar & re-computing meetings list" ) - listViewModel.applyFilter() + listViewModel.filter() } } @@ -166,7 +166,7 @@ class MeetingsListFragment : AbstractMainFragment() { listViewModel.fetchInProgress.value = false } - listViewModel.conferenceCancelledEvent.observe(viewLifecycleOwner) { + listViewModel.cancelMeetingViewModel.conferenceCancelledEvent.observe(viewLifecycleOwner) { it.consume { Log.i("$TAG Meeting has been cancelled successfully, deleting it") (requireActivity() as GenericActivity).showGreenToast( @@ -176,7 +176,7 @@ class MeetingsListFragment : AbstractMainFragment() { meetingViewModelBeingCancelled?.delete() meetingViewModelBeingCancelled = null - listViewModel.applyFilter() + listViewModel.filter() (requireActivity() as GenericActivity).showGreenToast( getString(R.string.meeting_info_deleted_toast), @@ -188,24 +188,18 @@ class MeetingsListFragment : AbstractMainFragment() { adapter.meetingLongClickedEvent.observe(viewLifecycleOwner) { it.consume { model -> val isUserOrganizer = model.isMyselfOrganizer && !model.isCancelled + val showCancelActionInsteadOfDelete = isUserOrganizer && model.hasNotStartedYet val modalBottomSheet = MeetingsMenuDialogFragment( - isUserOrganizer, + showCancelActionInsteadOfDelete, { // onDismiss adapter.resetSelection() }, { // onDelete - if (isUserOrganizer) { + if (showCancelActionInsteadOfDelete) { + Log.i("$TAG Meeting start hasn't started yet and we are the organizer, asking user if it should be cancelled") showCancelMeetingDialog(model) } else { showDeleteMeetingDialog(model) - /*Log.i("$TAG Deleting meeting [${model.id}]") - model.delete() - listViewModel.applyFilter() - - (requireActivity() as GenericActivity).showGreenToast( - getString(R.string.meeting_info_deleted_toast), - R.drawable.trash_simple - )*/ } } ) @@ -217,7 +211,7 @@ class MeetingsListFragment : AbstractMainFragment() { sharedViewModel.forceRefreshMeetingsListEvent.observe(viewLifecycleOwner) { it.consume { Log.i("$TAG We were asked to refresh the meetings list, doing it now") - listViewModel.applyFilter() + listViewModel.filter() } } @@ -312,7 +306,7 @@ class MeetingsListFragment : AbstractMainFragment() { } val index = listViewModel.meetings.value.orEmpty().indexOf(todayMeeting) Log.i("$TAG 'Today' is at position [$index]") - if (index > 0) { + if (index >= 0) { binding.meetingsList.smoothScrollToPosition(index) // Workaround to have header decoration visible at top (binding.meetingsList.layoutManager as LinearLayoutManager).scrollToPositionWithOffset( index, @@ -333,17 +327,20 @@ class MeetingsListFragment : AbstractMainFragment() { } } - model.cancelEvent.observe(viewLifecycleOwner) { + model.confirmEvent.observe(viewLifecycleOwner) { it.consume { + Log.i("$TAG Cancelling meeting [${meetingModel.id}] and sending notification to participants") + meetingViewModelBeingCancelled = meetingModel + listViewModel.cancelMeetingViewModel.cancelMeeting(meetingModel.conferenceInfo, true) dialog.dismiss() } } - model.confirmEvent.observe(viewLifecycleOwner) { + model.alternativeChoiceEvent.observe(viewLifecycleOwner) { it.consume { - Log.i("$TAG Cancelling meeting [${meetingModel.id}]") + Log.i("$TAG Cancelling meeting [${meetingModel.id}] without notifying participants") meetingViewModelBeingCancelled = meetingModel - listViewModel.cancelMeeting(meetingModel.conferenceInfo) + listViewModel.cancelMeetingViewModel.cancelMeeting(meetingModel.conferenceInfo, false) dialog.dismiss() } } @@ -352,8 +349,6 @@ class MeetingsListFragment : AbstractMainFragment() { } private fun showDeleteMeetingDialog(meetingModel: MeetingModel) { - Log.i("$TAG Meeting is not editable or already cancelled, asking whether to deleting it or not") - val model = ConfirmationDialogModel() val dialog = DialogUtils.getDeleteMeetingDialog(requireContext(), model) @@ -363,23 +358,12 @@ class MeetingsListFragment : AbstractMainFragment() { } } - model.cancelEvent.observe(viewLifecycleOwner) { - it.consume { - dialog.dismiss() - } - } - model.confirmEvent.observe(viewLifecycleOwner) { it.consume { Log.i("$TAG Deleting meeting [${meetingModel.id}]") meetingModel.delete() - listViewModel.applyFilter() - + listViewModel.filter() dialog.dismiss() - (requireActivity() as GenericActivity).showGreenToast( - getString(R.string.meeting_info_deleted_toast), - R.drawable.trash_simple - ) } } diff --git a/app/src/main/java/org/linphone/ui/main/meetings/fragment/MeetingsMenuDialogFragment.kt b/app/src/main/java/org/linphone/ui/main/meetings/fragment/MeetingsMenuDialogFragment.kt index e5529097b3..08ed41870b 100644 --- a/app/src/main/java/org/linphone/ui/main/meetings/fragment/MeetingsMenuDialogFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/meetings/fragment/MeetingsMenuDialogFragment.kt @@ -33,7 +33,7 @@ import org.linphone.databinding.MeetingsListLongPressMenuBinding @UiThread class MeetingsMenuDialogFragment( - private val isUserOrganizer: Boolean, + private val showCancelActionInsteadOfDelete: Boolean, private val onDismiss: (() -> Unit)? = null, private val onDeleteMeeting: (() -> Unit)? = null ) : BottomSheetDialogFragment() { @@ -65,7 +65,7 @@ class MeetingsMenuDialogFragment( savedInstanceState: Bundle? ): View { val view = MeetingsListLongPressMenuBinding.inflate(layoutInflater) - view.cancelInsteadOfDelete = isUserOrganizer + view.cancelInsteadOfDelete = showCancelActionInsteadOfDelete view.setDeleteClickListener { onDeleteMeeting?.invoke() diff --git a/app/src/main/java/org/linphone/ui/main/meetings/model/MeetingModel.kt b/app/src/main/java/org/linphone/ui/main/meetings/model/MeetingModel.kt index a648ae8594..dbf627c385 100644 --- a/app/src/main/java/org/linphone/ui/main/meetings/model/MeetingModel.kt +++ b/app/src/main/java/org/linphone/ui/main/meetings/model/MeetingModel.kt @@ -49,6 +49,8 @@ class MeetingModel val isAfterToday = TimestampUtils.isAfterToday(timestamp) + val hasNotStartedYet = timestamp * 1000 > System.currentTimeMillis() + private val startTime = TimestampUtils.timeToString(timestamp) private val endTime = TimestampUtils.timeToString(timestamp + (conferenceInfo.duration * 60)) diff --git a/app/src/main/java/org/linphone/ui/main/meetings/viewmodel/CancelMeetingViewModel.kt b/app/src/main/java/org/linphone/ui/main/meetings/viewmodel/CancelMeetingViewModel.kt new file mode 100644 index 0000000000..ae4b5c5fba --- /dev/null +++ b/app/src/main/java/org/linphone/ui/main/meetings/viewmodel/CancelMeetingViewModel.kt @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2010-2025 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.ui.main.meetings.viewmodel + +import androidx.annotation.UiThread +import androidx.lifecycle.MutableLiveData +import org.linphone.LinphoneApplication.Companion.coreContext +import org.linphone.LinphoneApplication.Companion.corePreferences +import org.linphone.core.Address +import org.linphone.core.ConferenceInfo +import org.linphone.core.ConferenceScheduler +import org.linphone.core.ConferenceSchedulerListenerStub +import org.linphone.core.tools.Log +import org.linphone.ui.GenericViewModel +import org.linphone.utils.Event +import org.linphone.utils.LinphoneUtils + +open class CancelMeetingViewModel + @UiThread + constructor() : GenericViewModel() { + companion object { + private const val TAG = "[Cancel Meeting ViewModel]" + } + + val operationInProgress = MutableLiveData() + + val conferenceCancelledEvent: MutableLiveData> by lazy { + MutableLiveData() + } + + private var sendNotificationForCancelledConference: Boolean = false + + private val conferenceSchedulerListener = object : ConferenceSchedulerListenerStub() { + override fun onStateChanged( + conferenceScheduler: ConferenceScheduler, + state: ConferenceScheduler.State? + ) { + Log.i("$TAG Conference scheduler state is $state") + if (state == ConferenceScheduler.State.Ready) { + Log.i( + "$TAG Conference ${conferenceScheduler.info?.subject} has been cancelled" + ) + if (sendNotificationForCancelledConference) { + Log.i("$TAG Sending cancelled meeting ICS to participants") + val params = LinphoneUtils.getChatRoomParamsToCancelMeeting() + if (params != null && !corePreferences.disableChat) { + conferenceScheduler.sendInvitations(params) + } else { + Log.e("$TAG Failed to get chat room params to send cancelled meeting ICS!") + operationInProgress.postValue(false) + } + } else { + operationInProgress.postValue(false) + conferenceCancelledEvent.postValue(Event(true)) + } + } else if (state == ConferenceScheduler.State.Error) { + operationInProgress.postValue(false) + // TODO FIXME: show error to user + } + } + + override fun onInvitationsSent( + conferenceScheduler: ConferenceScheduler, + failedInvitations: Array? + ) { + if (failedInvitations?.isNotEmpty() == true) { + // TODO FIXME: show error to user + for (address in failedInvitations) { + Log.e( + "$TAG Conference cancelled ICS wasn't sent to participant ${address.asStringUriOnly()}" + ) + } + } else { + Log.i( + "$TAG Conference cancelled ICS successfully sent to all participants" + ) + } + conferenceScheduler.removeListener(this) + + operationInProgress.postValue(false) + conferenceCancelledEvent.postValue(Event(true)) + } + } + + init { + operationInProgress.value = false + } + + @UiThread + fun cancelMeeting(conferenceInfo: ConferenceInfo, sendNotification: Boolean) { + coreContext.postOnCoreThread { core -> + Log.w("$TAG Cancelling conference info [${conferenceInfo.uri?.asStringUriOnly()}]") + sendNotificationForCancelledConference = sendNotification + operationInProgress.postValue(true) + + val conferenceScheduler = LinphoneUtils.createConferenceScheduler( + LinphoneUtils.getDefaultAccount() + ) + conferenceScheduler.addListener(conferenceSchedulerListener) + conferenceScheduler.cancelConference(conferenceInfo) + } + } +} diff --git a/app/src/main/java/org/linphone/ui/main/meetings/viewmodel/MeetingViewModel.kt b/app/src/main/java/org/linphone/ui/main/meetings/viewmodel/MeetingViewModel.kt index 94499c278a..2c889a6197 100644 --- a/app/src/main/java/org/linphone/ui/main/meetings/viewmodel/MeetingViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/meetings/viewmodel/MeetingViewModel.kt @@ -24,23 +24,18 @@ import androidx.annotation.WorkerThread import androidx.lifecycle.MutableLiveData import java.util.TimeZone import org.linphone.LinphoneApplication.Companion.coreContext -import org.linphone.core.Address import org.linphone.core.ConferenceInfo -import org.linphone.core.ConferenceScheduler -import org.linphone.core.ConferenceSchedulerListenerStub import org.linphone.core.Factory import org.linphone.core.Participant import org.linphone.core.tools.Log -import org.linphone.ui.GenericViewModel import org.linphone.ui.main.meetings.model.ParticipantModel import org.linphone.ui.main.meetings.model.TimeZoneModel import org.linphone.utils.Event -import org.linphone.utils.LinphoneUtils import org.linphone.utils.TimestampUtils class MeetingViewModel @UiThread - constructor() : GenericViewModel() { + constructor() : CancelMeetingViewModel() { companion object { private const val TAG = "[Meeting ViewModel]" } @@ -59,6 +54,8 @@ class MeetingViewModel val timezone = MutableLiveData() + val hasNotStartedYet = MutableLiveData() + val description = MutableLiveData() val speakers = MutableLiveData>() @@ -72,65 +69,12 @@ class MeetingViewModel val startTimeStamp = MutableLiveData() val endTimeStamp = MutableLiveData() - val operationInProgress = MutableLiveData() - - val conferenceCancelledEvent: MutableLiveData> by lazy { - MutableLiveData>() - } - val conferenceInfoDeletedEvent: MutableLiveData> by lazy { - MutableLiveData>() - } - - private val conferenceSchedulerListener = object : ConferenceSchedulerListenerStub() { - override fun onStateChanged( - conferenceScheduler: ConferenceScheduler, - state: ConferenceScheduler.State? - ) { - Log.i("$TAG Conference scheduler state is $state") - if (state == ConferenceScheduler.State.Ready) { - Log.i( - "$TAG Conference ${conferenceScheduler.info?.subject} cancelled" - ) - val params = LinphoneUtils.getChatRoomParamsToCancelMeeting() - if (params != null) { - conferenceScheduler.sendInvitations(params) - } else { - operationInProgress.postValue(false) - } - } else if (state == ConferenceScheduler.State.Error) { - operationInProgress.postValue(false) - } - } - - override fun onInvitationsSent( - conferenceScheduler: ConferenceScheduler, - failedInvitations: Array? - ) { - if (failedInvitations?.isNotEmpty() == true) { - for (address in failedInvitations) { - Log.e( - "$TAG Conference cancelled ICS wasn't sent to participant ${address.asStringUriOnly()}" - ) - } - } else { - Log.i( - "$TAG Conference cancelled ICS successfully sent to all participants" - ) - } - conferenceScheduler.removeListener(this) - - operationInProgress.postValue(false) - conferenceCancelledEvent.postValue(Event(true)) - } + MutableLiveData() } private lateinit var conferenceInfo: ConferenceInfo - init { - operationInProgress.value = false - } - @UiThread fun findConferenceInfo(meeting: ConferenceInfo?, uri: String) { coreContext.postOnCoreThread { core -> @@ -181,21 +125,6 @@ class MeetingViewModel } } - @UiThread - fun cancel() { - coreContext.postOnCoreThread { core -> - if (::conferenceInfo.isInitialized) { - Log.i("$TAG Cancelling conference information [$conferenceInfo]") - operationInProgress.postValue(true) - val conferenceScheduler = LinphoneUtils.createConferenceScheduler( - LinphoneUtils.getDefaultAccount() - ) - conferenceScheduler.addListener(conferenceSchedulerListener) - conferenceScheduler.cancelConference(conferenceInfo) - } - } - } - @UiThread fun refreshInfo(uri: String) { coreContext.postOnCoreThread { core -> @@ -242,6 +171,7 @@ class MeetingViewModel endTimeStamp.postValue(end * 1000) val displayedTimestamp = "$date | $startTime - $endTime" dateTime.postValue(displayedTimestamp) + hasNotStartedYet.postValue(timestamp * 1000 > System.currentTimeMillis()) Log.i("$TAG Conference is scheduled for [$displayedTimestamp]") timezone.postValue(TimeZoneModel(TimeZone.getDefault()).toString()) @@ -310,4 +240,11 @@ class MeetingViewModel speakers.postValue(speakersList) participants.postValue(participantsList) } + + @UiThread + fun cancel(sendNotification: Boolean) { + if (::conferenceInfo.isInitialized) { + cancelMeeting(conferenceInfo, sendNotification) + } + } } diff --git a/app/src/main/java/org/linphone/ui/main/meetings/viewmodel/MeetingWaitingRoomViewModel.kt b/app/src/main/java/org/linphone/ui/main/meetings/viewmodel/MeetingWaitingRoomViewModel.kt index 85017ef10c..0455472226 100644 --- a/app/src/main/java/org/linphone/ui/main/meetings/viewmodel/MeetingWaitingRoomViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/meetings/viewmodel/MeetingWaitingRoomViewModel.kt @@ -41,8 +41,8 @@ import org.linphone.core.tools.Log import org.linphone.ui.GenericViewModel import org.linphone.ui.call.model.AudioDeviceModel import org.linphone.ui.main.contacts.model.ContactAvatarModel -import org.linphone.utils.AppUtils import org.linphone.utils.Event +import org.linphone.utils.LinphoneUtils import org.linphone.utils.TimestampUtils class MeetingWaitingRoomViewModel @@ -79,15 +79,15 @@ class MeetingWaitingRoomViewModel val conferenceInfoFoundEvent = MutableLiveData>() val showAudioDevicesListEvent: MutableLiveData>> by lazy { - MutableLiveData>>() + MutableLiveData() } val leaveWaitingRoomEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val conferenceCreationError: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } private lateinit var conferenceAddress: Address @@ -322,37 +322,17 @@ class MeetingWaitingRoomViewModel for (device in audioDevices) { // Only list output audio devices if (!device.hasCapability(AudioDevice.Capabilities.CapabilityPlay)) continue - - val name = when (device.type) { + when (device.type) { AudioDevice.Type.Earpiece -> { earpieceAudioDevice = device - AppUtils.getString(R.string.call_audio_device_type_earpiece) } AudioDevice.Type.Speaker -> { speakerAudioDevice = device - AppUtils.getString(R.string.call_audio_device_type_speaker) - } - AudioDevice.Type.Headset -> { - AppUtils.getString(R.string.call_audio_device_type_headset) - } - AudioDevice.Type.Headphones -> { - AppUtils.getString(R.string.call_audio_device_type_headphones) - } - AudioDevice.Type.Bluetooth -> { - AppUtils.getFormattedString( - R.string.call_audio_device_type_bluetooth, - device.deviceName - ) - } - AudioDevice.Type.HearingAid -> { - AppUtils.getFormattedString( - R.string.call_audio_device_type_hearing_aid, - device.deviceName - ) } - else -> device.deviceName + else -> {} } + val name = LinphoneUtils.getAudioDeviceName(device) val currentDevice = if (::selectedOutputAudioDevice.isInitialized) { selectedOutputAudioDevice } else { diff --git a/app/src/main/java/org/linphone/ui/main/meetings/viewmodel/MeetingsListViewModel.kt b/app/src/main/java/org/linphone/ui/main/meetings/viewmodel/MeetingsListViewModel.kt index 5673b406a4..00ac002a8a 100644 --- a/app/src/main/java/org/linphone/ui/main/meetings/viewmodel/MeetingsListViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/meetings/viewmodel/MeetingsListViewModel.kt @@ -23,20 +23,16 @@ import androidx.annotation.UiThread import androidx.annotation.WorkerThread import androidx.lifecycle.MutableLiveData import org.linphone.LinphoneApplication.Companion.coreContext +import org.linphone.LinphoneApplication.Companion.corePreferences import org.linphone.core.Account import org.linphone.core.AccountListenerStub -import org.linphone.core.Address import org.linphone.core.ConferenceInfo -import org.linphone.core.ConferenceScheduler -import org.linphone.core.ConferenceSchedulerListenerStub import org.linphone.core.Core import org.linphone.core.CoreListenerStub import org.linphone.core.tools.Log import org.linphone.ui.main.meetings.model.MeetingListItemModel import org.linphone.ui.main.meetings.model.MeetingModel import org.linphone.ui.main.viewmodel.AbstractMainViewModel -import org.linphone.utils.Event -import org.linphone.utils.LinphoneUtils import org.linphone.utils.TimestampUtils class MeetingsListViewModel @@ -50,11 +46,7 @@ class MeetingsListViewModel val fetchInProgress = MutableLiveData() - val operationInProgress = MutableLiveData() - - val conferenceCancelledEvent: MutableLiveData> by lazy { - MutableLiveData>() - } + val cancelMeetingViewModel = CancelMeetingViewModel() private val coreListener = object : CoreListenerStub() { @WorkerThread @@ -77,51 +69,7 @@ class MeetingsListViewModel } } - private val conferenceSchedulerListener = object : ConferenceSchedulerListenerStub() { - override fun onStateChanged( - conferenceScheduler: ConferenceScheduler, - state: ConferenceScheduler.State? - ) { - Log.i("$TAG Conference scheduler state is $state") - if (state == ConferenceScheduler.State.Ready) { - Log.i( - "$TAG Conference ${conferenceScheduler.info?.subject} cancelled" - ) - val params = LinphoneUtils.getChatRoomParamsToCancelMeeting() - if (params != null) { - conferenceScheduler.sendInvitations(params) - } else { - operationInProgress.postValue(false) - } - } else if (state == ConferenceScheduler.State.Error) { - operationInProgress.postValue(false) - } - } - - override fun onInvitationsSent( - conferenceScheduler: ConferenceScheduler, - failedInvitations: Array? - ) { - if (failedInvitations?.isNotEmpty() == true) { - for (address in failedInvitations) { - Log.e( - "$TAG Conference cancelled ICS wasn't sent to participant ${address.asStringUriOnly()}" - ) - } - } else { - Log.i( - "$TAG Conference cancelled ICS successfully sent to all participants" - ) - } - conferenceScheduler.removeListener(this) - - operationInProgress.postValue(false) - conferenceCancelledEvent.postValue(Event(true)) - } - } - init { - operationInProgress.value = false fetchInProgress.value = true coreContext.postOnCoreThread { core -> @@ -149,19 +97,6 @@ class MeetingsListViewModel } } - @UiThread - fun cancelMeeting(conferenceInfo: ConferenceInfo) { - coreContext.postOnCoreThread { core -> - Log.w("$TAG Cancelling conference info [${conferenceInfo.uri?.asStringUriOnly()}]") - operationInProgress.postValue(true) - val conferenceScheduler = LinphoneUtils.createConferenceScheduler( - LinphoneUtils.getDefaultAccount() - ) - conferenceScheduler.addListener(conferenceSchedulerListener) - conferenceScheduler.cancelConference(conferenceInfo) - } - } - @WorkerThread private fun computeMeetingsListFromLocallyStoredInfo() { var source = coreContext.core.defaultAccount?.conferenceInformationList @@ -180,6 +115,9 @@ class MeetingsListViewModel fetchInProgress.postValue(true) } + val showPastMeetings = corePreferences.showPastMeetings + val nowInSecs = System.currentTimeMillis() / 1000 + val sortedSource = source.toList().sortedBy { it.dateTime } @@ -201,6 +139,14 @@ class MeetingsListViewModel ) continue } // This isn't a scheduled conference, don't display it + + if (!showPastMeetings && (info.dateTime + (info.duration * 60) < nowInSecs)) { + Log.d( + "$TAG Skipping conference info [${info.subject}] with uri [${info.uri?.asStringUriOnly()}] because it's in the past" + ) + continue + } + val add = if (filter.isNotEmpty()) { val organizerCheck = info.organizer?.asStringUriOnly()?.contains( filter, diff --git a/app/src/main/java/org/linphone/ui/main/meetings/viewmodel/ScheduleMeetingViewModel.kt b/app/src/main/java/org/linphone/ui/main/meetings/viewmodel/ScheduleMeetingViewModel.kt index 2261a7f69c..6c749a6ff6 100644 --- a/app/src/main/java/org/linphone/ui/main/meetings/viewmodel/ScheduleMeetingViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/meetings/viewmodel/ScheduleMeetingViewModel.kt @@ -80,8 +80,10 @@ class ScheduleMeetingViewModel val hideBroadcast = MutableLiveData() + val chatDisabled = MutableLiveData() + val conferenceCreatedEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } private var startTimestamp = 0L @@ -135,7 +137,7 @@ class ScheduleMeetingViewModel ) } - if (sendInvitations.value == true) { + if (sendInvitations.value == true && !corePreferences.disableChat) { Log.i("$TAG User asked for invitations to be sent, let's do it") val chatRoomParams = coreContext.core.createConferenceParams(null) @@ -188,10 +190,11 @@ class ScheduleMeetingViewModel init { coreContext.postOnCoreThread { hideBroadcast.postValue(corePreferences.disableBroadcasts) + chatDisabled.postValue(corePreferences.disableChat) + sendInvitations.postValue(!corePreferences.disableChat) } isBroadcastSelected.value = false showBroadcastHelp.value = false - sendInvitations.value = true selectedTimeZone.value = availableTimeZones.find { it.id == TimeZone.getDefault().id diff --git a/app/src/main/java/org/linphone/ui/main/model/AccountModel.kt b/app/src/main/java/org/linphone/ui/main/model/AccountModel.kt index 65100beb57..9672615460 100644 --- a/app/src/main/java/org/linphone/ui/main/model/AccountModel.kt +++ b/app/src/main/java/org/linphone/ui/main/model/AccountModel.kt @@ -21,7 +21,6 @@ package org.linphone.ui.main.model -import android.view.View import androidx.annotation.UiThread import androidx.annotation.WorkerThread import androidx.lifecycle.MutableLiveData @@ -46,12 +45,14 @@ class AccountModel @WorkerThread constructor( val account: Account, - private val onMenuClicked: ((view: View, account: Account) -> Unit)? = null + private val onMenuClicked: ((model: AccountModel) -> Unit)? = null ) : AbstractAvatarModel() { companion object { private const val TAG = "[Account Model]" } + val identity = account.params.identityAddress?.asStringUriOnly().orEmpty() + val displayName = MutableLiveData() val registrationState = MutableLiveData() @@ -81,6 +82,7 @@ class AccountModel update() } + @WorkerThread override fun onMessageWaitingIndicationChanged( account: Account, mwi: MessageWaitingIndication @@ -110,6 +112,11 @@ class AccountModel computeNotificationsCount() } + @WorkerThread + override fun onMessageRetracted(core: Core, chatRoom: ChatRoom, message: ChatMessage) { + computeNotificationsCount() + } + @WorkerThread override fun onMessagesReceived( core: Core, @@ -161,8 +168,8 @@ class AccountModel } @UiThread - fun openMenu(view: View) { - onMenuClicked?.invoke(view, account) + fun openMenu() { + onMenuClicked?.invoke(this) } @UiThread diff --git a/app/src/main/java/org/linphone/ui/main/model/ConversationContactOrSuggestionModel.kt b/app/src/main/java/org/linphone/ui/main/model/ConversationContactOrSuggestionModel.kt index 012dfd6748..83c09c95a7 100644 --- a/app/src/main/java/org/linphone/ui/main/model/ConversationContactOrSuggestionModel.kt +++ b/app/src/main/java/org/linphone/ui/main/model/ConversationContactOrSuggestionModel.kt @@ -35,11 +35,12 @@ class ConversationContactOrSuggestionModel val conversationId: String = "", conversationSubject: String? = null, val friend: Friend? = null, - val defaultAccountDomain: String? = null, private val onClicked: ((Address) -> Unit)? = null ) { val id = friend?.refKey ?: address.asStringUriOnly().hashCode() + val isFriend = friend != null + val starred = friend?.starred == true val name = conversationSubject @@ -49,13 +50,7 @@ class ConversationContactOrSuggestionModel address.username ?: address.domain.orEmpty() } - // Hide SIP address and only show username for suggestions - // on the same domain as the currently selected account - val sipUri = if (!defaultAccountDomain.isNullOrEmpty() && defaultAccountDomain == address.domain) { - address.username - } else { - address.asStringUriOnly() - } + val sipUri = LinphoneUtils.getDisplayAddress(address) val initials = AppUtils.getInitials(conversationSubject ?: name) diff --git a/app/src/main/java/org/linphone/ui/main/recordings/adapter/RecordingsListAdapter.kt b/app/src/main/java/org/linphone/ui/main/recordings/adapter/RecordingsListAdapter.kt index bd3d5a2080..ac89af58df 100644 --- a/app/src/main/java/org/linphone/ui/main/recordings/adapter/RecordingsListAdapter.kt +++ b/app/src/main/java/org/linphone/ui/main/recordings/adapter/RecordingsListAdapter.kt @@ -45,11 +45,11 @@ class RecordingsListAdapter : var selectedAdapterPosition = -1 val recordingClickedEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val recordingLongClickedEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } override fun displayHeaderForPosition(position: Int): Boolean { diff --git a/app/src/main/java/org/linphone/ui/main/recordings/fragment/RecordingMediaPlayerFragment.kt b/app/src/main/java/org/linphone/ui/main/recordings/fragment/RecordingMediaPlayerFragment.kt index 956940cb9a..79ba55950a 100644 --- a/app/src/main/java/org/linphone/ui/main/recordings/fragment/RecordingMediaPlayerFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/recordings/fragment/RecordingMediaPlayerFragment.kt @@ -27,6 +27,7 @@ import android.view.LayoutInflater import android.view.TextureView import android.view.View import android.view.ViewGroup +import android.widget.SeekBar import androidx.core.content.FileProvider import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope @@ -52,6 +53,21 @@ class RecordingMediaPlayerFragment : GenericMainFragment() { private lateinit var viewModel: RecordingMediaPlayerViewModel + private val seekBarListener = object : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { + + } + + override fun onStartTrackingTouch(seekBar: SeekBar) { + viewModel.pause() + } + + override fun onStopTrackingTouch(seekBar: SeekBar) { + val newPosition = seekBar.progress + viewModel.seekTo(newPosition) + } + } + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -84,6 +100,8 @@ class RecordingMediaPlayerFragment : GenericMainFragment() { exportFile(viewModel.recordingModel.filePath) } + binding.setSeekBarListener(seekBarListener) + val model = sharedViewModel.playingRecording if (model != null) { Log.i("$TAG Loading recording [${model.fileName}] from shared view model") diff --git a/app/src/main/java/org/linphone/ui/main/recordings/fragment/RecordingsListFragment.kt b/app/src/main/java/org/linphone/ui/main/recordings/fragment/RecordingsListFragment.kt index a152a848d0..25e45fd993 100644 --- a/app/src/main/java/org/linphone/ui/main/recordings/fragment/RecordingsListFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/recordings/fragment/RecordingsListFragment.kt @@ -21,10 +21,12 @@ package org.linphone.ui.main.recordings.fragment import android.content.ActivityNotFoundException import android.content.Intent +import android.graphics.Outline import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.view.ViewOutlineProvider import androidx.annotation.UiThread import androidx.core.content.FileProvider import androidx.lifecycle.ViewModelProvider @@ -63,6 +65,14 @@ class RecordingsListFragment : GenericMainFragment() { private var bottomSheetDialog: BottomSheetDialogFragment? = null + private val outlineProvider = object : ViewOutlineProvider() { + override fun getOutline(view: View?, outline: Outline?) { + val radius = resources.getDimension(R.dimen.top_list_item_rounded_corner_radius) + view ?: return + outline?.setRoundRect(0, 0, view.width, (view.height + radius).toInt(), radius) + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -94,6 +104,8 @@ class RecordingsListFragment : GenericMainFragment() { binding.recordingsList.layoutManager = LinearLayoutManager(requireContext()) val headerItemDecoration = RecyclerViewHeaderDecoration(requireContext(), adapter) binding.recordingsList.addItemDecoration(headerItemDecoration) + binding.recordingsList.outlineProvider = outlineProvider + binding.recordingsList.clipToOutline = true listViewModel.recordings.observe(viewLifecycleOwner) { val count = it.size diff --git a/app/src/main/java/org/linphone/ui/main/recordings/model/RecordingModel.kt b/app/src/main/java/org/linphone/ui/main/recordings/model/RecordingModel.kt index 4cc1500d4c..25d1bae34d 100644 --- a/app/src/main/java/org/linphone/ui/main/recordings/model/RecordingModel.kt +++ b/app/src/main/java/org/linphone/ui/main/recordings/model/RecordingModel.kt @@ -83,7 +83,7 @@ class RecordingModel val indexOfSeparator = withoutHeader.indexOf( LinphoneUtils.RECORDING_FILE_NAME_URI_TIMESTAMP_SEPARATOR ) - sipUri = withoutHeader.substring(0, indexOfSeparator) + sipUri = withoutHeader.take(indexOfSeparator) val sipAddress = Factory.instance().createAddress(sipUri) displayName = if (sipAddress != null) { val contact = coreContext.contactsManager.findContactByAddress(sipAddress) diff --git a/app/src/main/java/org/linphone/ui/main/recordings/viewmodel/RecordingMediaPlayerViewModel.kt b/app/src/main/java/org/linphone/ui/main/recordings/viewmodel/RecordingMediaPlayerViewModel.kt index 2959a87646..0033493601 100644 --- a/app/src/main/java/org/linphone/ui/main/recordings/viewmodel/RecordingMediaPlayerViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/recordings/viewmodel/RecordingMediaPlayerViewModel.kt @@ -113,8 +113,12 @@ class RecordingMediaPlayerViewModel fun setVideoRenderingSurface(textureView: TextureView) { val texture = textureView.surfaceTexture coreContext.postOnCoreThread { - Log.i("$TAG Setting window ID in player") - player.setWindowId(texture) + if (::player.isInitialized) { + Log.i("$TAG Setting window ID in player") + player.setWindowId(texture) + } else { + Log.e("$TAG Player hasn't been created yet, can't set the video texture") + } } } @@ -175,6 +179,14 @@ class RecordingMediaPlayerViewModel } } + @UiThread + fun seekTo(position: Int) { + coreContext.postOnCoreThread { + seekPlaybackTo(position) + startPlayback() + } + } + @WorkerThread private fun startPlayback() { if (!::player.isInitialized) return @@ -232,6 +244,18 @@ class RecordingMediaPlayerViewModel updatePositionJob = null } + @WorkerThread + private fun seekPlaybackTo(position: Int) { + if (!::player.isInitialized) return + + if (player.state == Player.State.Closed) { + player.open(recordingModel.filePath) + } + + Log.i("$TAG Seeking player to position [$position]") + player.seek(position) + } + @WorkerThread private fun stop() { if (!::player.isInitialized) return diff --git a/app/src/main/java/org/linphone/ui/main/recordings/viewmodel/RecordingsListViewModel.kt b/app/src/main/java/org/linphone/ui/main/recordings/viewmodel/RecordingsListViewModel.kt index 0b88792df8..aba9bd0a6e 100644 --- a/app/src/main/java/org/linphone/ui/main/recordings/viewmodel/RecordingsListViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/recordings/viewmodel/RecordingsListViewModel.kt @@ -49,7 +49,7 @@ class RecordingsListViewModel val fetchInProgress = MutableLiveData() val focusSearchBarEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } init { @@ -76,7 +76,12 @@ class RecordingsListViewModel @UiThread fun clearFilter() { - searchFilter.value = "" + if (searchFilter.value.orEmpty().isEmpty()) { + searchBarVisible.value = false + focusSearchBarEvent.value = Event(false) + } else { + searchFilter.value = "" + } } @UiThread diff --git a/app/src/main/java/org/linphone/ui/main/settings/fragment/AccountProfileFragment.kt b/app/src/main/java/org/linphone/ui/main/settings/fragment/AccountProfileFragment.kt index 93bd679a39..a66eda6310 100644 --- a/app/src/main/java/org/linphone/ui/main/settings/fragment/AccountProfileFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/settings/fragment/AccountProfileFragment.kt @@ -19,9 +19,7 @@ */ package org.linphone.ui.main.settings.fragment -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context +import android.content.ActivityNotFoundException import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -43,6 +41,7 @@ import org.linphone.ui.GenericActivity import org.linphone.ui.main.fragment.GenericMainFragment import org.linphone.utils.ConfirmationDialogModel import org.linphone.ui.main.settings.viewmodel.AccountProfileViewModel +import org.linphone.utils.AppUtils import org.linphone.utils.DialogUtils import org.linphone.utils.Event import org.linphone.utils.FileUtils @@ -225,17 +224,18 @@ class AccountProfileFragment : GenericMainFragment() { } private fun pickImage() { - pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) + try { + pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) + } catch (anfe: ActivityNotFoundException) { + Log.e("$TAG Failed to start media picker: $anfe") + } } private fun copyAddressToClipboard(value: String) { - val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - clipboard.setPrimaryClip(ClipData.newPlainText("SIP address", value)) - val message = getString(R.string.sip_address_copied_to_clipboard_toast) - (requireActivity() as GenericActivity).showGreenToast( - message, - R.drawable.check - ) + if (AppUtils.copyToClipboard(requireContext(), "SIP address", value)) { + val message = getString(R.string.sip_address_copied_to_clipboard_toast) + (requireActivity() as GenericActivity).showGreenToast(message, R.drawable.check) + } } private fun setupDialPlanPicker() { diff --git a/app/src/main/java/org/linphone/ui/main/settings/fragment/AccountSettingsFragment.kt b/app/src/main/java/org/linphone/ui/main/settings/fragment/AccountSettingsFragment.kt index 8a9f218104..ceec8bbb97 100644 --- a/app/src/main/java/org/linphone/ui/main/settings/fragment/AccountSettingsFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/settings/fragment/AccountSettingsFragment.kt @@ -23,16 +23,12 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.AdapterView -import android.widget.ArrayAdapter import androidx.annotation.UiThread import androidx.core.view.doOnPreDraw import androidx.lifecycle.ViewModelProvider import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs -import java.util.Locale import org.linphone.R -import org.linphone.core.TransportType import org.linphone.core.tools.Log import org.linphone.databinding.AccountSettingsFragmentBinding import org.linphone.ui.GenericActivity @@ -54,22 +50,6 @@ class AccountSettingsFragment : GenericMainFragment() { private lateinit var viewModel: AccountSettingsViewModel - private val dropdownListener = object : AdapterView.OnItemSelectedListener { - override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { - val transport = viewModel.availableTransports[position] - val transportType = when (transport) { - TransportType.Tcp.name.uppercase(Locale.getDefault()) -> TransportType.Tcp - TransportType.Tls.name.uppercase(Locale.getDefault()) -> TransportType.Tls - else -> TransportType.Udp - } - Log.i("$TAG Selected transport updated [$transport] -> [${transportType.name}]") - viewModel.selectedTransport.value = transportType - } - - override fun onNothingSelected(parent: AdapterView<*>?) { - } - } - override fun goBack(): Boolean { try { return findNavController().popBackStack() @@ -110,26 +90,15 @@ class AccountSettingsFragment : GenericMainFragment() { showUpdatePasswordDialog() } + binding.setOutboundProxyTooltipClickListener { + showOutboundProxyInfoDialog() + } + viewModel.accountFoundEvent.observe(viewLifecycleOwner) { it.consume { found -> if (found) { (view.parent as? ViewGroup)?.doOnPreDraw { startPostponedEnterTransition() - - val adapter = ArrayAdapter( - requireContext(), - R.layout.drop_down_item, - viewModel.availableTransports - ) - adapter.setDropDownViewResource(R.layout.generic_dropdown_cell) - val currentTransport = viewModel.selectedTransport.value?.name?.uppercase( - Locale.getDefault() - ) - binding.accountAdvancedSettings.transportSpinner.adapter = adapter - binding.accountAdvancedSettings.transportSpinner.setSelection( - viewModel.availableTransports.indexOf(currentTransport) - ) - binding.accountAdvancedSettings.transportSpinner.onItemSelectedListener = dropdownListener } } else { Log.e( @@ -171,4 +140,9 @@ class AccountSettingsFragment : GenericMainFragment() { dialog.show() } + + private fun showOutboundProxyInfoDialog() { + val dialog = DialogUtils.getAccountOutboundProxyHelpDialog(requireActivity()) + dialog.show() + } } diff --git a/app/src/main/java/org/linphone/ui/main/settings/fragment/SettingsAdvancedCallFragment.kt b/app/src/main/java/org/linphone/ui/main/settings/fragment/SettingsAdvancedCallFragment.kt new file mode 100644 index 0000000000..c5ca37b063 --- /dev/null +++ b/app/src/main/java/org/linphone/ui/main/settings/fragment/SettingsAdvancedCallFragment.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2010-2025 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.ui.main.settings.fragment + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.AdapterView +import android.widget.ArrayAdapter +import androidx.annotation.UiThread +import androidx.lifecycle.ViewModelProvider +import org.linphone.R +import org.linphone.databinding.SettingsAdvancedCallsFragmentBinding +import org.linphone.ui.main.fragment.GenericMainFragment +import org.linphone.ui.main.settings.viewmodel.SettingsViewModel + +@UiThread +class SettingsAdvancedCallFragment : GenericMainFragment() { + private lateinit var binding: SettingsAdvancedCallsFragmentBinding + + private lateinit var viewModel: SettingsViewModel + + private val mediaEncryptionDropdownListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { + viewModel.setMediaEncryption(position) + } + + override fun onNothingSelected(parent: AdapterView<*>?) { + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = SettingsAdvancedCallsFragmentBinding.inflate(layoutInflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + postponeEnterTransition() + super.onViewCreated(view, savedInstanceState) + + viewModel = ViewModelProvider(this)[SettingsViewModel::class.java] + + binding.lifecycleOwner = viewLifecycleOwner + binding.viewModel = viewModel + observeToastEvents(viewModel) + + binding.setBackClickListener { + goBack() + } + + viewModel.mediaEncryptionIndex.observe(viewLifecycleOwner) { + setupMediaEncryptionPicker() + } + + startPostponedEnterTransition() + } + + private fun setupMediaEncryptionPicker() { + val index = viewModel.mediaEncryptionIndex.value ?: 0 + val adapter = ArrayAdapter( + requireContext(), + R.layout.drop_down_item, + viewModel.mediaEncryptionLabels + ) + adapter.setDropDownViewResource(R.layout.generic_dropdown_cell) + binding.mediaEncryption.adapter = adapter + binding.mediaEncryption.onItemSelectedListener = mediaEncryptionDropdownListener + binding.mediaEncryption.setSelection(index) + } +} diff --git a/app/src/main/java/org/linphone/ui/main/settings/fragment/SettingsAdvancedFragment.kt b/app/src/main/java/org/linphone/ui/main/settings/fragment/SettingsAdvancedFragment.kt index b3e38aa445..f1cc856a02 100644 --- a/app/src/main/java/org/linphone/ui/main/settings/fragment/SettingsAdvancedFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/settings/fragment/SettingsAdvancedFragment.kt @@ -40,15 +40,6 @@ class SettingsAdvancedFragment : GenericMainFragment() { private lateinit var viewModel: SettingsViewModel - private val mediaEncryptionDropdownListener = object : AdapterView.OnItemSelectedListener { - override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { - viewModel.setMediaEncryption(position) - } - - override fun onNothingSelected(parent: AdapterView<*>?) { - } - } - private val inputAudioDeviceDropdownListener = object : AdapterView.OnItemSelectedListener { override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { viewModel.setInputAudioDevice(position) @@ -94,10 +85,6 @@ class SettingsAdvancedFragment : GenericMainFragment() { (requireActivity() as GenericActivity).goToAndroidPermissionSettings() } - viewModel.mediaEncryptionIndex.observe(viewLifecycleOwner) { - setupMediaEncryptionPicker() - } - viewModel.inputAudioDeviceIndex.observe(viewLifecycleOwner) { setupInputAudioDevicePicker() } @@ -122,19 +109,6 @@ class SettingsAdvancedFragment : GenericMainFragment() { super.onPause() } - private fun setupMediaEncryptionPicker() { - val index = viewModel.mediaEncryptionIndex.value ?: 0 - val adapter = ArrayAdapter( - requireContext(), - R.layout.drop_down_item, - viewModel.mediaEncryptionLabels - ) - adapter.setDropDownViewResource(R.layout.generic_dropdown_cell) - binding.advancedCallsSettings.mediaEncryption.adapter = adapter - binding.advancedCallsSettings.mediaEncryption.onItemSelectedListener = mediaEncryptionDropdownListener - binding.advancedCallsSettings.mediaEncryption.setSelection(index) - } - private fun setupInputAudioDevicePicker() { val index = viewModel.inputAudioDeviceIndex.value ?: 0 val adapter = ArrayAdapter( diff --git a/app/src/main/java/org/linphone/ui/main/settings/fragment/SettingsDeveloperFragment.kt b/app/src/main/java/org/linphone/ui/main/settings/fragment/SettingsDeveloperFragment.kt index 6f0a0bcd33..1dedb7d51d 100644 --- a/app/src/main/java/org/linphone/ui/main/settings/fragment/SettingsDeveloperFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/settings/fragment/SettingsDeveloperFragment.kt @@ -63,6 +63,7 @@ class SettingsDeveloperFragment : GenericMainFragment() { override fun onPause() { viewModel.updateSharingServersUrl() + viewModel.updatePushCompatibleDomainsList() super.onPause() } diff --git a/app/src/main/java/org/linphone/ui/main/settings/fragment/SettingsFragment.kt b/app/src/main/java/org/linphone/ui/main/settings/fragment/SettingsFragment.kt index e024923815..2bb045f7bf 100644 --- a/app/src/main/java/org/linphone/ui/main/settings/fragment/SettingsFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/settings/fragment/SettingsFragment.kt @@ -30,17 +30,19 @@ import android.view.ViewGroup import android.widget.AdapterView import android.widget.ArrayAdapter import androidx.annotation.UiThread -import androidx.lifecycle.ViewModelProvider import androidx.navigation.fragment.findNavController +import androidx.navigation.navGraphViewModels import org.linphone.R import org.linphone.compatibility.Compatibility import org.linphone.core.tools.Log import org.linphone.databinding.SettingsFragmentBinding +import org.linphone.ui.GenericActivity import org.linphone.ui.main.fragment.GenericMainFragment import org.linphone.utils.ConfirmationDialogModel import org.linphone.ui.main.settings.viewmodel.SettingsViewModel import org.linphone.utils.AppUtils import org.linphone.utils.DialogUtils +import org.linphone.utils.Event import java.lang.Exception @UiThread @@ -53,7 +55,23 @@ class SettingsFragment : GenericMainFragment() { private lateinit var binding: SettingsFragmentBinding - private lateinit var viewModel: SettingsViewModel + private val viewModel: SettingsViewModel by navGraphViewModels( + R.id.main_nav_graph + ) + + private val sortContactsByListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { + val label = viewModel.sortContactsByNames[position] + val value = viewModel.sortContactsByValues[position] + Log.i("$TAG Selected contact sorting is now [$label] ($value)") + viewModel.setContactSorting(value) + + sharedViewModel.forceRefreshContactsList.postValue(Event(true)) + } + + override fun onNothingSelected(parent: AdapterView<*>?) { + } + } private val layoutListener = object : AdapterView.OnItemSelectedListener { override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { @@ -123,8 +141,6 @@ class SettingsFragment : GenericMainFragment() { postponeEnterTransition() super.onViewCreated(view, savedInstanceState) - viewModel = ViewModelProvider(this)[SettingsViewModel::class.java] - binding.lifecycleOwner = viewLifecycleOwner binding.viewModel = viewModel observeToastEvents(viewModel) @@ -133,6 +149,13 @@ class SettingsFragment : GenericMainFragment() { goBack() } + binding.setAdvancedCallSettingsClickListener { + if (findNavController().currentDestination?.id == R.id.settingsFragment) { + val action = SettingsFragmentDirections.actionSettingsFragmentToSettingsAdvancedCallFragment() + findNavController().navigate(action) + } + } + binding.setAdvancedSettingsClickListener { if (findNavController().currentDestination?.id == R.id.settingsFragment) { val action = SettingsFragmentDirections.actionSettingsFragmentToSettingsAdvancedFragment() @@ -154,36 +177,44 @@ class SettingsFragment : GenericMainFragment() { } } - viewModel.goToIncomingCallNotificationChannelSettingsEvent.observe(viewLifecycleOwner) { + viewModel.showRingtonePickerEvent.observe(viewLifecycleOwner) { it.consume { currentRingtone -> try { - /* - Log.w("$TAG Going to incoming call channel settings") - val intent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply { - putExtra(Settings.EXTRA_APP_PACKAGE, requireContext().packageName) - putExtra( - Settings.EXTRA_CHANNEL_ID, - getString(R.string.notification_channel_without_ringtone_incoming_call_id) - ) - } - startActivity(intent) - */ val intent = Intent(RingtoneManager.ACTION_RINGTONE_PICKER).apply { putExtra( RingtoneManager.EXTRA_RINGTONE_TYPE, RingtoneManager.TYPE_RINGTONE ) - putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, currentRingtone) + if (currentRingtone != null) { + putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, currentRingtone) + } putExtra(RingtoneManager.EXTRA_RINGTONE_TITLE, AppUtils.getString(R.string.settings_calls_change_ringtone_pick_title)) } startActivityForResult(intent, RINGTONE_PICKER_INTENT_ID) } catch (e: Exception) { Log.e("$TAG Failed start ringtone picker: $e") - // TODO: show error to user + val toastMessage = getString(R.string.settings_calls_change_ringtone_picker_unavailable_toast) + (requireActivity() as GenericActivity).showRedToast(toastMessage, R.drawable.warning_circle) } } } + // Setup sort contacts by spinner + val sortContactsByAdapter = ArrayAdapter( + requireContext(), + R.layout.drop_down_item, + viewModel.sortContactsByNames + ) + sortContactsByAdapter.setDropDownViewResource(R.layout.generic_dropdown_cell) + binding.contactsSettings.sortContactsByFirstNameSpinner.adapter = sortContactsByAdapter + + viewModel.sortContactsBy.observe(viewLifecycleOwner) { sort -> + binding.contactsSettings.sortContactsByFirstNameSpinner.setSelection( + viewModel.sortContactsByValues.indexOf(sort) + ) + } + binding.contactsSettings.sortContactsByFirstNameSpinner.onItemSelectedListener = sortContactsByListener + viewModel.addLdapServerEvent.observe(viewLifecycleOwner) { it.consume { if (findNavController().currentDestination?.id == R.id.settingsFragment) { @@ -294,6 +325,12 @@ class SettingsFragment : GenericMainFragment() { binding.tunnelSettings.tunnelModeSpinner.setSelection(index) } + viewModel.forceRefreshMeetingsListEvent.observe(viewLifecycleOwner) { + it.consume { + sharedViewModel.forceRefreshMeetingsListEvent.postValue(Event(true)) + } + } + binding.setTurnOnVfsClickListener { showConfirmVfsDialog() } @@ -338,7 +375,7 @@ class SettingsFragment : GenericMainFragment() { model ) - model.cancelEvent.observe(viewLifecycleOwner) { + model.dismissEvent.observe(viewLifecycleOwner) { it.consume { viewModel.isVfsEnabled.value = false dialog.dismiss() diff --git a/app/src/main/java/org/linphone/ui/main/settings/viewmodel/AccountProfileViewModel.kt b/app/src/main/java/org/linphone/ui/main/settings/viewmodel/AccountProfileViewModel.kt index c34fe0da9b..2f24eefbb8 100644 --- a/app/src/main/java/org/linphone/ui/main/settings/viewmodel/AccountProfileViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/settings/viewmodel/AccountProfileViewModel.kt @@ -23,10 +23,7 @@ import androidx.annotation.UiThread import androidx.annotation.WorkerThread import androidx.lifecycle.MediatorLiveData import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.viewModelScope -import java.io.File import java.util.Locale -import kotlinx.coroutines.launch import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.LinphoneApplication.Companion.corePreferences import org.linphone.R @@ -45,7 +42,6 @@ import org.linphone.ui.main.model.AccountModel import org.linphone.ui.main.model.isEndToEndEncryptionMandatory import org.linphone.ui.main.settings.model.AccountDeviceModel import org.linphone.utils.Event -import org.linphone.utils.FileUtils class AccountProfileViewModel @UiThread @@ -86,12 +82,14 @@ class AccountProfileViewModel val hideAccountSettings = MutableLiveData() + val hideSipAddresses = MutableLiveData() + val deviceId = MutableLiveData() val showDeviceId = MutableLiveData() val accountRemovedEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } private lateinit var account: Account @@ -179,6 +177,7 @@ class AccountProfileViewModel coreContext.postOnCoreThread { hideAccountSettings.postValue(corePreferences.hideAccountSettings) + hideSipAddresses.postValue(corePreferences.hideSipAddresses) dialPlansLabelList.add("") // To allow removing selected dial plan val dialPlans = Factory.instance().dialPlans.toList() @@ -265,42 +264,18 @@ class AccountProfileViewModel fun deleteAccount() { coreContext.postOnCoreThread { core -> if (::account.isInitialized) { - Log.i("$TAG Removing call logs, conversations & meetings related to account being removed") - account.clearCallLogs() - - // Wait for a better API in the SDK, deleteChatRoom will cause user to leave the groups, - // which will cause issues in case of multi device - /* - for (conversation in account.chatRooms) { - core.deleteChatRoom(conversation) - } - */ - for (meeting in account.conferenceInformationList) { - core.deleteConferenceInformation(meeting) - } - val identity = account.params.identityAddress?.asStringUriOnly() - val authInfo = account.findAuthInfo() - if (authInfo != null) { - Log.i("$TAG Found auth info for account [$identity], removing it") - if (authInfo.password.isNullOrEmpty() && authInfo.ha1.isNullOrEmpty() && authInfo.accessToken != null) { - Log.i("$TAG Auth info was using bearer token instead of password") - val ssoCache = File(corePreferences.ssoCacheFile) - if (ssoCache.exists()) { - Log.i("$TAG Found auth_state.json file, deleting it") - viewModelScope.launch { - FileUtils.deleteFile(ssoCache.absolutePath) - } - } + Log.w("$TAG Removing account [$identity] and all related data (auth info, conferences, conversations, call logs)") + core.removeAccountWithData(account) + accountRemovedEvent.postValue(Event(true)) + + if (core.accountList.isEmpty()) { + Log.w("$TAG No more account found in Core") + if (!core.provisioningUri.isNullOrEmpty()) { + Log.w("$TAG Removing remote provisioning URI") + core.provisioningUri = null } - core.removeAuthInfo(authInfo) - } else { - Log.w("$TAG Failed to find matching auth info for account [$identity]") } - - core.removeAccount(account) - Log.i("$TAG Account [$identity] has been removed") - accountRemovedEvent.postValue(Event(true)) } } } @@ -386,6 +361,7 @@ class AccountProfileViewModel registerEnabled.postValue(account.params.isRegisterEnabled) if (!core.isNetworkReachable) { + Log.w("$TAG Network is not reachable, updating registration state to reflect that") // To reflect the difference between Disabled & Disconnected accountModel.value?.updateRegistrationState() } diff --git a/app/src/main/java/org/linphone/ui/main/settings/viewmodel/AccountSettingsViewModel.kt b/app/src/main/java/org/linphone/ui/main/settings/viewmodel/AccountSettingsViewModel.kt index ed201c7111..d2c528b3e2 100644 --- a/app/src/main/java/org/linphone/ui/main/settings/viewmodel/AccountSettingsViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/settings/viewmodel/AccountSettingsViewModel.kt @@ -27,6 +27,7 @@ import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.LinphoneApplication.Companion.corePreferences import org.linphone.core.AVPFMode import org.linphone.core.Account +import org.linphone.core.Address import org.linphone.core.AuthInfo import org.linphone.core.Factory import org.linphone.core.NatPolicy @@ -58,11 +59,9 @@ class AccountSettingsViewModel val availableTransports = arrayListOf() - val selectedTransport = MutableLiveData() - val sipProxyServer = MutableLiveData() - val outboundProxyEnabled = MutableLiveData() + val outboundProxyServer = MutableLiveData() val stunServer = MutableLiveData() @@ -100,6 +99,10 @@ class AccountSettingsViewModel val accountFoundEvent = MutableLiveData>() + val showDeveloperSettings = MutableLiveData() + + val limeAlgorithms = MutableLiveData() + private lateinit var account: Account private lateinit var natPolicy: NatPolicy private lateinit var natPolicyAuthInfo: AuthInfo @@ -119,6 +122,8 @@ class AccountSettingsViewModel imEncryptionMandatoryAvailable.addSource(conferenceFactoryUri) { imEncryptionMandatoryAvailable.value = isImEncryptionMandatoryAvailable() } + + showDeveloperSettings.postValue(corePreferences.showDeveloperSettings) } @UiThread @@ -145,11 +150,10 @@ class AccountSettingsViewModel imEncryptionMandatory.postValue(params.instantMessagingEncryptionMandatory) - val transportType = params.serverAddress?.transport ?: TransportType.Tls - selectedTransport.postValue(transportType) - sipProxyServer.postValue(params.serverAddress?.asStringUriOnly()) - outboundProxyEnabled.postValue(params.isOutboundProxyEnabled) + if (params.routesAddresses.isNotEmpty()) { + outboundProxyServer.postValue(params.routesAddresses.first().asStringUriOnly()) + } natPolicy = params.natPolicy ?: core.createNatPolicy() stunServer.postValue(natPolicy.stunServer) @@ -192,6 +196,8 @@ class AccountSettingsViewModel limeServerUrl.postValue(params.limeServerUrl) + limeAlgorithms.postValue(params.limeAlgo) + accountFoundEvent.postValue(Event(true)) } else { Log.e("$TAG Failed to find account matching identity [$identity]") @@ -214,21 +220,34 @@ class AccountSettingsViewModel val server = sipProxyServer.value.orEmpty() if (server.isNotEmpty()) { + Log.i("$TAG Proxy server set to [$server]") val serverAddress = core.interpretUrl(server, false) if (serverAddress != null) { - serverAddress.transport = selectedTransport.value newParams.serverAddress = serverAddress + } else { + Log.e("$TAG Failed to parse proxy server!") } } - newParams.isOutboundProxyEnabled = outboundProxyEnabled.value == true + val outboundProxy = outboundProxyServer.value.orEmpty() + if (outboundProxy.isNotEmpty()) { + Log.i("$TAG Outbound proxy server set to [$outboundProxy]") + val outboundProxyAddress = core.interpretUrl(outboundProxy, false) + if (outboundProxyAddress != null) { + newParams.setRoutesAddresses(arrayOf(outboundProxyAddress)) + } else { + Log.e("$TAG Failed to parse outbound proxy server!") + } + } else { + newParams.setRoutesAddresses(arrayOf
()) + } if (::natPolicy.isInitialized) { Log.i("$TAG Also applying changes to NAT policy") - natPolicy.stunServer = stunServer.value + natPolicy.stunServer = stunServer.value.orEmpty().trim() natPolicy.isStunEnabled = stunServer.value.orEmpty().isNotEmpty() natPolicy.isIceEnabled = iceEnabled.value == true natPolicy.isTurnEnabled = turnEnabled.value == true - val stunTurnUsername = turnUsername.value.orEmpty() + val stunTurnUsername = turnUsername.value.orEmpty().trim() natPolicy.stunServerUsername = stunTurnUsername newParams.natPolicy = natPolicy @@ -263,7 +282,7 @@ class AccountSettingsViewModel newParams.isCpimInBasicChatRoomEnabled = cpimInBasicChatRooms.value == true - val mwi = mwiUri.value.orEmpty() + val mwi = mwiUri.value.orEmpty().trim() if (mwi.isNotEmpty()) { val mwiAddress = core.interpretUrl(mwi, false) newParams.mwiServerAddress = mwiAddress @@ -271,7 +290,7 @@ class AccountSettingsViewModel newParams.mwiServerAddress = null } - val voicemail = voicemailUri.value.orEmpty() + val voicemail = voicemailUri.value.orEmpty().trim() if (voicemail.isNotEmpty()) { val voicemailAddress = core.interpretUrl(voicemail, false) newParams.voicemailAddress = voicemailAddress @@ -311,8 +330,12 @@ class AccountSettingsViewModel newParams.audioVideoConferenceFactoryAddress = null } - newParams.ccmpServerUrl = ccmpServerUrl.value - newParams.limeServerUrl = limeServerUrl.value + newParams.ccmpServerUrl = ccmpServerUrl.value.orEmpty().trim() + newParams.limeServerUrl = limeServerUrl.value.orEmpty().trim() + newParams.limeAlgo = limeAlgorithms.value.orEmpty().trim() + + newParams.useInternationalPrefixForCallsAndChats = applyPrefix.value == true + newParams.isDialEscapePlusEnabled = replacePlusBy00.value == true newParams.useInternationalPrefixForCallsAndChats = applyPrefix.value == true newParams.isDialEscapePlusEnabled = replacePlusBy00.value == true diff --git a/app/src/main/java/org/linphone/ui/main/settings/viewmodel/CardDavViewModel.kt b/app/src/main/java/org/linphone/ui/main/settings/viewmodel/CardDavViewModel.kt index 6662c0ed47..d03b94d4f9 100644 --- a/app/src/main/java/org/linphone/ui/main/settings/viewmodel/CardDavViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/settings/viewmodel/CardDavViewModel.kt @@ -59,14 +59,16 @@ class CardDavViewModel val storeNewContactsInIt = MutableLiveData() + val isReadOnly = MutableLiveData() + var pendingAuthInfo: AuthInfo? = null val syncSuccessfulEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val friendListRemovedEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } private lateinit var friendList: FriendList @@ -86,6 +88,21 @@ class CardDavViewModel syncInProgress.postValue(false) showGreenToast(R.string.settings_contacts_carddav_sync_successful_toast, R.drawable.check) + val name = displayName.value.orEmpty().trim() + if (storeNewContactsInIt.value == true) { + val previous = corePreferences.friendListInWhichStoreNewlyCreatedFriends + if (friendList.isReadOnly) { + Log.w("$TAG User asked to add newly created contacts in this friend list but it is read only, keep currently default friend list [$previous]") + storeNewContactsInIt.postValue(false) + } else { + Log.i( + "$TAG Updating default friend list to store newly created contacts from [$previous] to [$name]" + ) + corePreferences.friendListInWhichStoreNewlyCreatedFriends = name + } + isReadOnly.postValue(friendList.isReadOnly) + } + Log.i("$TAG Notifying contacts manager that contacts have changed") coreContext.contactsManager.notifyContactsListChanged() @@ -139,6 +156,7 @@ class CardDavViewModel isEdit.postValue(true) friendList = found friendList.addListener(friendListListener) + isReadOnly.postValue(friendList.isReadOnly) pendingAuthInfo = null displayName.postValue(name) @@ -230,6 +248,13 @@ class CardDavViewModel } if (isEdit.value == true && ::friendList.isInitialized) { + friendList.displayName = name + friendList.uri = if (server.startsWith("http://") || server.startsWith("https://")) { + server + } else { + "https://$server" + } + Log.i( "$TAG Changes were made to CardDAV friend list [$name], synchronizing it" ) @@ -251,13 +276,7 @@ class CardDavViewModel ) } - if (storeNewContactsInIt.value == true) { - val previous = corePreferences.friendListInWhichStoreNewlyCreatedFriends - Log.i( - "$TAG Updating default friend list to store newly created contacts from [$previous] to [$name]" - ) - corePreferences.friendListInWhichStoreNewlyCreatedFriends = name - } else if (storeNewContactsInIt.value == false) { + if (storeNewContactsInIt.value == false && corePreferences.friendListInWhichStoreNewlyCreatedFriends == name) { Log.i( "$TAG No longer using friend list [$name] as default friend list, switching back to [$LINPHONE_ADDRESS_BOOK_FRIEND_LIST]" ) diff --git a/app/src/main/java/org/linphone/ui/main/settings/viewmodel/LdapViewModel.kt b/app/src/main/java/org/linphone/ui/main/settings/viewmodel/LdapViewModel.kt index 9f6749e618..9469063cd5 100644 --- a/app/src/main/java/org/linphone/ui/main/settings/viewmodel/LdapViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/settings/viewmodel/LdapViewModel.kt @@ -65,8 +65,10 @@ class LdapViewModel : GenericViewModel() { val sipDomain = MutableLiveData() + val verboseMode = MutableLiveData() + val ldapServerOperationSuccessfulEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } private lateinit var ldapToEdit: Ldap @@ -80,6 +82,7 @@ class LdapViewModel : GenericViewModel() { minCharacters.value = "3" requestTimeout.value = "5" requestDelay.value = "2000" + verboseMode.value = true } @UiThread @@ -110,6 +113,7 @@ class LdapViewModel : GenericViewModel() { nameAttributes.postValue(ldapParams.nameAttribute.orEmpty()) sipAttributes.postValue(ldapParams.sipAttribute.orEmpty()) sipDomain.postValue(ldapParams.sipDomain.orEmpty()) + verboseMode.postValue(ldapParams.debugLevel == Ldap.DebugLevel.Verbose) Log.i("$TAG Existing LDAP server values loaded") } } @@ -141,23 +145,42 @@ class LdapViewModel : GenericViewModel() { useTls.value = useTls.value == false } + @UiThread + fun toggleDebug() { + verboseMode.value = verboseMode.value == false + } + @UiThread fun addServer() { coreContext.postOnCoreThread { core -> try { val server = serverUrl.value.orEmpty().trim() - if (server.isEmpty()) { - Log.e("$TAG Server field can't be empty!") - showRedToast(R.string.settings_contacts_ldap_empty_server_error_toast, R.drawable.warning_circle) + val bindDn = bindDn.value.orEmpty().trim() + val base = searchBase.value.orEmpty().trim() + val filter = searchFilter.value.orEmpty().trim() + val maxResults = maxResults.value.orEmpty().trim() + val timeout = requestTimeout.value.orEmpty().trim() + val delay = requestDelay.value.orEmpty().trim() + val minChars = minCharacters.value.orEmpty().trim() + val nameAttrs = nameAttributes.value.orEmpty().trim() + val sipAttrs = sipAttributes.value.orEmpty().trim() + val sipDomain = sipDomain.value.orEmpty().trim() + val pwd = password.value.orEmpty().trim() + if ( + server.isEmpty() || bindDn.isEmpty() || base.isEmpty() || filter.isEmpty() || + maxResults.isEmpty() || timeout.isEmpty() || delay.isEmpty() || + minChars.isEmpty() || nameAttrs.isEmpty() || sipAttrs.isEmpty() || + sipDomain.isEmpty() + ) { + Log.e("$TAG All fields must be filled!") + showRedToast(R.string.settings_contacts_ldap_empty_field_error_toast, R.drawable.warning_circle) return@postOnCoreThread } val ldapParams = core.createLdapParams() - ldapParams.enabled = isEnabled.value == true ldapParams.server = server - ldapParams.bindDn = bindDn.value.orEmpty().trim() - val pwd = password.value.orEmpty().trim() + ldapParams.bindDn = bindDn if (!pwd.isEmpty()) { ldapParams.password = pwd } else if (::ldapToEdit.isInitialized) { @@ -166,16 +189,20 @@ class LdapViewModel : GenericViewModel() { ldapParams.authMethod = Ldap.AuthMethod.Simple ldapParams.isTlsEnabled = useTls.value == true ldapParams.serverCertificatesVerificationMode = Ldap.CertVerificationMode.Default - ldapParams.baseObject = searchBase.value.orEmpty().trim() - ldapParams.filter = searchFilter.value.orEmpty().trim() - ldapParams.maxResults = maxResults.value.orEmpty().trim().toInt() - ldapParams.timeout = requestTimeout.value.orEmpty().trim().toInt() - ldapParams.delay = requestDelay.value.orEmpty().trim().toInt() - ldapParams.minChars = minCharacters.value.orEmpty().trim().toInt() - ldapParams.nameAttribute = nameAttributes.value.orEmpty().trim() - ldapParams.sipAttribute = sipAttributes.value.orEmpty().trim() - ldapParams.sipDomain = sipDomain.value.orEmpty().trim() - ldapParams.debugLevel = Ldap.DebugLevel.Verbose + ldapParams.baseObject = base + ldapParams.filter = filter + ldapParams.maxResults = maxResults.toInt() + ldapParams.timeout = timeout.toInt() + ldapParams.delay = delay.toInt() + ldapParams.minChars = minChars.toInt() + ldapParams.nameAttribute = nameAttrs + ldapParams.sipAttribute = sipAttrs + ldapParams.sipDomain = sipDomain + ldapParams.debugLevel = if (verboseMode.value == true) { + Ldap.DebugLevel.Verbose + } else { + Ldap.DebugLevel.Off + } if (isEdit.value == true && ::ldapToEdit.isInitialized) { ldapToEdit.params = ldapParams @@ -185,6 +212,7 @@ class LdapViewModel : GenericViewModel() { core.addLdap(ldap) Log.i("$TAG New LDAP config created") } + showGreenToast(R.string.settings_contacts_ldap_saved_toast, R.drawable.check) ldapServerOperationSuccessfulEvent.postValue(Event(true)) } catch (e: Exception) { Log.e("$TAG Exception while creating LDAP: $e") diff --git a/app/src/main/java/org/linphone/ui/main/settings/viewmodel/SettingsViewModel.kt b/app/src/main/java/org/linphone/ui/main/settings/viewmodel/SettingsViewModel.kt index bbbe3685a9..9afcf6c3de 100644 --- a/app/src/main/java/org/linphone/ui/main/settings/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/settings/viewmodel/SettingsViewModel.kt @@ -29,6 +29,7 @@ import androidx.lifecycle.MutableLiveData import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.LinphoneApplication.Companion.corePreferences import org.linphone.R +import org.linphone.contacts.ContactLoader.Companion.NATIVE_ADDRESS_BOOK_FRIEND_LIST import org.linphone.core.AudioDevice import org.linphone.core.Conference import org.linphone.core.Core @@ -45,6 +46,7 @@ import org.linphone.ui.main.settings.model.CardDavLdapModel import org.linphone.ui.main.settings.model.CodecModel import org.linphone.utils.AppUtils import org.linphone.utils.Event +import org.linphone.utils.LinphoneUtils class SettingsViewModel @UiThread @@ -64,11 +66,11 @@ class SettingsViewModel val isTunnelAvailable = MutableLiveData() val recreateActivityEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val keepAliveServiceSettingChangedEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } // Security settings @@ -82,7 +84,6 @@ class SettingsViewModel val adaptiveRateControlEnabled = MutableLiveData() - val videoEnabled = MutableLiveData() val videoFecEnabled = MutableLiveData() val isVibrationAvailable = MutableLiveData() @@ -90,7 +91,7 @@ class SettingsViewModel val autoRecordCalls = MutableLiveData() - val goToIncomingCallNotificationChannelSettingsEvent = MutableLiveData>() + val showRingtonePickerEvent = MutableLiveData>() // Conversations settings val showConversationsSettings = MutableLiveData() @@ -101,9 +102,21 @@ class SettingsViewModel val markAsReadWhenDismissingNotification = MutableLiveData() + val hideMessageContentInNotification = MutableLiveData() + // Contacts settings val showContactsSettings = MutableLiveData() + val sortContactsBy = MutableLiveData() + val sortContactsByNames = arrayListOf( + AppUtils.getString(R.string.contact_editor_first_name), + AppUtils.getString(R.string.contact_editor_last_name), + ) + val sortContactsByValues = arrayListOf(0, 1) + + val editNativeContactsInLinphone = MutableLiveData() + val hideEmptyContacts = MutableLiveData() + val ldapAvailable = MutableLiveData() val ldapServers = MutableLiveData>() @@ -112,23 +125,28 @@ class SettingsViewModel val presenceSubscribe = MutableLiveData() val addLdapServerEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val editLdapServerEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val addCardDavServerEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val editCardDavServerEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } // Meetings settings val showMeetingsSettings = MutableLiveData() + val showPastMeetings = MutableLiveData() + val forceRefreshMeetingsListEvent: MutableLiveData> by lazy { + MutableLiveData>() + } + val defaultLayout = MutableLiveData() val availableLayoutsNames = arrayListOf( AppUtils.getString(R.string.settings_meetings_layout_active_speaker_label), @@ -159,21 +177,27 @@ class SettingsViewModel val color = MutableLiveData() val availableColorsNames = arrayListOf( AppUtils.getString(R.string.orange), - AppUtils.getString(R.string.yellow), - AppUtils.getString(R.string.green), - AppUtils.getString(R.string.blue), - AppUtils.getString(R.string.red), - AppUtils.getString(R.string.pink), - AppUtils.getString(R.string.purple) + AppUtils.getString(R.string.terracotta), + AppUtils.getString(R.string.lavender), + AppUtils.getString(R.string.honey), + AppUtils.getString(R.string.burgundy), + AppUtils.getString(R.string.mint), + AppUtils.getString(R.string.coral), + AppUtils.getString(R.string.plum), + AppUtils.getString(R.string.titanium), + AppUtils.getString(R.string.mineral_blue), ) val availableColorsValues = arrayListOf( "orange", - "yellow", - "green", - "blue", - "red", - "pink", - "purple" + "terracotta", + "lavender", + "honey", + "burgundy", + "mint", + "coral", + "plum", + "titanium", + "mineral_blue" ) // Tunnel settings @@ -201,17 +225,19 @@ class SettingsViewModel val deviceName = MutableLiveData() val remoteProvisioningUrl = MutableLiveData() - val expandAdvancedCalls = MutableLiveData() - val mediaEncryptionIndex = MutableLiveData() val mediaEncryptionLabels = arrayListOf() private val mediaEncryptionValues = arrayListOf() val mediaEncryptionMandatory = MutableLiveData() + val rfc2833Dtmf = MutableLiveData() + val sipInfoDtmf = MutableLiveData() + val useProximitySensor = MutableLiveData() val acceptEarlyMedia = MutableLiveData() val ringDuringEarlyMedia = MutableLiveData() val allowOutgoingEarlyMedia = MutableLiveData() val autoAnswerIncomingCalls = MutableLiveData() val autoAnswerIncomingCallsDelay = MutableLiveData() + val autoAnswerIncomingCallsWithVideoDirectionSendReceive = MutableLiveData() val expandAudioDevices = MutableLiveData() val inputAudioDeviceIndex = MutableLiveData() @@ -227,6 +253,9 @@ class SettingsViewModel val expandVideoCodecs = MutableLiveData() val videoCodecs = MutableLiveData>() + val expandEarlyMedia = MutableLiveData() + val expandAutoAnswer = MutableLiveData() + // Developer settings val showDeveloperSettings = MutableLiveData() @@ -234,6 +263,9 @@ class SettingsViewModel val fileSharingServerUrl = MutableLiveData() val logsSharingServerUrl = MutableLiveData() val createEndToEndEncryptedConferences = MutableLiveData() + val enableVuMeters = MutableLiveData() + val enableAdvancedCallStats = MutableLiveData() + val pushCompatibleDomainsList = MutableLiveData() private val coreListener = object : CoreListenerStub() { @WorkerThread @@ -276,10 +308,11 @@ class SettingsViewModel expandNetwork.value = false expandUserInterface.value = false expandTunnel.value = false - expandAdvancedCalls.value = false expandAudioDevices.value = false expandAudioCodecs.value = false expandVideoCodecs.value = false + expandEarlyMedia.value = false + expandAutoAnswer.value = false val vfsEnabled = VFS.isEnabled(coreContext.context) isVfsEnabled.value = vfsEnabled @@ -312,7 +345,6 @@ class SettingsViewModel adaptiveRateControlEnabled.postValue(core.isAdaptiveRateControlEnabled) - videoEnabled.postValue(core.isVideoEnabled) videoFecEnabled.postValue(core.isFecEnabled) vibrateDuringIncomingCall.postValue(core.isVibrationOnIncomingCallEnabled) autoRecordCalls.postValue(corePreferences.automaticallyStartCallRecording) @@ -326,9 +358,14 @@ class SettingsViewModel markAsReadWhenDismissingNotification.postValue( corePreferences.markConversationAsReadWhenDismissingMessageNotification ) + hideMessageContentInNotification.postValue(!corePreferences.showChatMessageContentInNotification) + sortContactsBy.postValue(if (corePreferences.sortContactsByFirstName) 0 else 1) + editNativeContactsInLinphone.postValue(corePreferences.editNativeContactsInLinphone) + hideEmptyContacts.postValue(corePreferences.hideContactsWithoutPhoneNumberOrSipAddress) presenceSubscribe.postValue(core.isFriendListSubscriptionEnabled) - + + showPastMeetings.postValue(corePreferences.showPastMeetings) defaultLayout.postValue(core.defaultConferenceLayout.toInt()) autoShowDialpad.postValue(corePreferences.automaticallyShowDialpad) @@ -346,11 +383,16 @@ class SettingsViewModel deviceName.postValue(corePreferences.deviceName) remoteProvisioningUrl.postValue(core.provisioningUri) + rfc2833Dtmf.postValue(core.useRfc2833ForDtmf) + sipInfoDtmf.postValue(core.useInfoForDtmf) + useProximitySensor.postValue(corePreferences.useProximitySensor) + acceptEarlyMedia.postValue(corePreferences.acceptEarlyMedia) ringDuringEarlyMedia.postValue(core.ringDuringIncomingEarlyMedia) allowOutgoingEarlyMedia.postValue(corePreferences.allowOutgoingEarlyMedia) autoAnswerIncomingCalls.postValue(corePreferences.autoAnswerEnabled) autoAnswerIncomingCallsDelay.postValue(corePreferences.autoAnswerDelay) + autoAnswerIncomingCallsWithVideoDirectionSendReceive.postValue(corePreferences.autoAnswerVideoCallsWithVideoDirectionSendReceive) setupMediaEncryption() setupAudioDevices() @@ -360,6 +402,21 @@ class SettingsViewModel fileSharingServerUrl.postValue(core.fileTransferServer) logsSharingServerUrl.postValue(core.logCollectionUploadServerUrl) createEndToEndEncryptedConferences.postValue(corePreferences.createEndToEndEncryptedMeetingsAndGroupCalls) + enableVuMeters.postValue(corePreferences.showMicrophoneAndSpeakerVuMeters) + enableAdvancedCallStats.postValue(corePreferences.showAdvancedCallStats) + + val domainsListBuilder = StringBuilder() + val domainsArray = corePreferences.pushNotificationCompatibleDomains + for (item in domainsArray) { + domainsListBuilder.append(item) + domainsListBuilder.append(",") + } + if (domainsListBuilder.isNotEmpty()) { + domainsListBuilder.deleteAt(domainsListBuilder.length - 1) // Remove last ',' + } + val domainsList = domainsListBuilder.toString() + Log.d("$TAG Computed push compatible domains list is [$domainsList]") + pushCompatibleDomainsList.postValue(domainsList) } } @@ -437,16 +494,6 @@ class SettingsViewModel } } - @UiThread - fun toggleEnableVideo() { - val newValue = videoEnabled.value == false - coreContext.postOnCoreThread { core -> - core.isVideoCaptureEnabled = newValue - core.isVideoDisplayEnabled = newValue - videoEnabled.postValue(newValue) - } - } - @UiThread fun toggleEnableVideoFec() { val newValue = videoFecEnabled.value == false @@ -494,9 +541,10 @@ class SettingsViewModel val coreRingtone = core.ring?.toUri() Log.i("$TAG Currently set ringtone in Core is [$coreRingtone], device default ringtone is [$defaultDeviceRingtone]") val currentRingtone = coreRingtone ?: defaultDeviceRingtone - goToIncomingCallNotificationChannelSettingsEvent.postValue(Event(currentRingtone)) + showRingtonePickerEvent.postValue(Event(currentRingtone)) } catch (e: Exception) { - Log.e("$TAG Failed to get current ringtone: $e") + Log.e("$TAG Failed to get current ringtone, opening picker anyway: $e") + showRingtonePickerEvent.postValue(Event(null)) } } } @@ -541,11 +589,45 @@ class SettingsViewModel } } + @UiThread + fun toggleHideMessageContentInNotification() { + val newValue = hideMessageContentInNotification.value == false + coreContext.postOnCoreThread { + corePreferences.showChatMessageContentInNotification = !newValue + hideMessageContentInNotification.postValue(newValue) + } + } + @UiThread fun toggleContactsExpand() { expandContacts.value = expandContacts.value == false } + @UiThread + fun setContactSorting(sortingValue: Int) { + coreContext.postOnCoreThread { core -> + corePreferences.sortContactsByFirstName = sortingValue == 0 + } + } + + @UiThread + fun toggleEditNativeContactsInLinphone() { + val newValue = editNativeContactsInLinphone.value == false + coreContext.postOnCoreThread { + corePreferences.editNativeContactsInLinphone = newValue + editNativeContactsInLinphone.postValue(newValue) + } + } + + @UiThread + fun toggleHideEmptyContacts() { + val newValue = hideEmptyContacts.value == false + coreContext.postOnCoreThread { + corePreferences.hideContactsWithoutPhoneNumberOrSipAddress = newValue + hideEmptyContacts.postValue(newValue) + } + } + @UiThread fun addLdapServer() { addLdapServerEvent.value = Event(true) @@ -612,6 +694,16 @@ class SettingsViewModel expandMeetings.value = expandMeetings.value == false } + @UiThread + fun toggleShowPastMeetings() { + val newValue = showPastMeetings.value == false + coreContext.postOnCoreThread { + corePreferences.showPastMeetings = newValue + showPastMeetings.postValue(newValue) + forceRefreshMeetingsListEvent.postValue(Event(true)) + } + } + @UiThread fun setDefaultLayout(layoutValue: Int) { coreContext.postOnCoreThread { core -> @@ -834,6 +926,41 @@ class SettingsViewModel } } + @UiThread + fun toggleRfc2833Dtmf() { + val newValue = rfc2833Dtmf.value == false + + coreContext.postOnCoreThread { core -> + core.useRfc2833ForDtmf = newValue + rfc2833Dtmf.postValue(newValue) + } + } + + @UiThread + fun toggleSipInfoDtmf() { + val newValue = sipInfoDtmf.value == false + + coreContext.postOnCoreThread { core -> + core.useInfoForDtmf = newValue + sipInfoDtmf.postValue(newValue) + } + } + + @UiThread + fun toggleUseProximitySensor() { + val newValue = useProximitySensor.value == false + + coreContext.postOnCoreThread { + corePreferences.useProximitySensor = newValue + useProximitySensor.postValue(newValue) + } + } + + @UiThread + fun toggleEarlyMediaExpand() { + expandEarlyMedia.value = expandEarlyMedia.value == false + } + @UiThread fun toggleAcceptEarlyMedia() { val newValue = acceptEarlyMedia.value == false @@ -864,6 +991,11 @@ class SettingsViewModel } } + @UiThread + fun toggleAutoAnswerExpand() { + expandAutoAnswer.value = expandAutoAnswer.value == false + } + @UiThread fun toggleEnableAutoAnswerIncomingCalls() { val newValue = autoAnswerIncomingCalls.value == false @@ -888,6 +1020,16 @@ class SettingsViewModel } } + @UiThread + fun toggleEnableAutoAnswerIncomingCallsWithVideoDirectionSendReceive() { + val newValue = autoAnswerIncomingCallsWithVideoDirectionSendReceive.value == false + + coreContext.postOnCoreThread { core -> + corePreferences.autoAnswerVideoCallsWithVideoDirectionSendReceive = newValue + autoAnswerIncomingCallsWithVideoDirectionSendReceive.postValue(newValue) + } + } + @UiThread fun updateDeviceName() { coreContext.postOnCoreThread { @@ -947,11 +1089,6 @@ class SettingsViewModel } } - @UiThread - fun toggleAdvancedCallsExpand() { - expandAdvancedCalls.value = expandAdvancedCalls.value == false - } - @UiThread fun toggleAudioDevicesExpand() { expandAudioDevices.value = expandAudioDevices.value == false @@ -984,10 +1121,11 @@ class SettingsViewModel var inputIndex = 0 val defaultInputAudioDevice = core.defaultInputAudioDevice - Log.i("$TAG Current default input audio device is [${defaultInputAudioDevice?.id}]") + Log.i("$TAG Current default input audio device ID is [${defaultInputAudioDevice?.id}]") for (audioDevice in core.extendedAudioDevices) { if (audioDevice.hasCapability(AudioDevice.Capabilities.CapabilityRecord)) { - inputAudioDeviceLabels.add(audioDevice.id) + Log.i("$TAG Found record device [${audioDevice.deviceName}] with audio driver [${audioDevice.driverName}] and type [${audioDevice.type}]") + inputAudioDeviceLabels.add(LinphoneUtils.getAudioDeviceName(audioDevice)) inputAudioDeviceValues.add(audioDevice) if (audioDevice.id == defaultInputAudioDevice?.id) { inputAudioDeviceIndex.postValue(inputIndex) @@ -1001,7 +1139,8 @@ class SettingsViewModel Log.i("$TAG Current default output audio device is [${defaultOutputAudioDevice?.id}]") for (audioDevice in core.extendedAudioDevices) { if (audioDevice.hasCapability(AudioDevice.Capabilities.CapabilityPlay)) { - outputAudioDeviceLabels.add(audioDevice.id) + Log.i("$TAG Found playback device [${audioDevice.deviceName}] with audio driver [${audioDevice.driverName}] and type [${audioDevice.type}]") + outputAudioDeviceLabels.add(LinphoneUtils.getAudioDeviceName(audioDevice)) outputAudioDeviceValues.add(audioDevice) if (audioDevice.id == defaultOutputAudioDevice?.id) { outputAudioDeviceIndex.postValue(outputIndex) @@ -1109,4 +1248,81 @@ class SettingsViewModel createEndToEndEncryptedConferences.postValue(newValue) } } + + @UiThread + fun toggleEnableVuMeters() { + val newValue = enableVuMeters.value == false + + coreContext.postOnCoreThread { core -> + corePreferences.showMicrophoneAndSpeakerVuMeters = newValue + enableVuMeters.postValue(newValue) + } + } + + @UiThread + fun toggleEnableAdvancedCallStats() { + val newValue = enableAdvancedCallStats.value == false + + coreContext.postOnCoreThread { core -> + corePreferences.showAdvancedCallStats = newValue + enableAdvancedCallStats.postValue(newValue) + } + } + + @UiThread + fun updatePushCompatibleDomainsList() { + coreContext.postOnCoreThread { core -> + val flatValue = pushCompatibleDomainsList.value.orEmpty().trim() + Log.d("$TAG Updating push compatible domains list using user input [$flatValue]") + val newList = flatValue.split(",").toTypedArray() + corePreferences.pushNotificationCompatibleDomains = newList + } + } + + @UiThread + fun clearNativeFriendsDatabase() { + coreContext.postOnCoreThread { core -> + val list = core.getFriendListByName(NATIVE_ADDRESS_BOOK_FRIEND_LIST) + if (list != null) { + val friends = list.friends + Log.i("$TAG Friend list to remove found with [${friends.size}] friends") + for (friend in friends) { + list.removeFriend(friend) + } + core.removeFriendList(list) + Log.i("$TAG Friend list [$NATIVE_ADDRESS_BOOK_FRIEND_LIST] removed") + } + showGreenToast(R.string.settings_developer_cleared_native_friends_in_database_toast, R.drawable.trash_simple) + } + } + + @UiThread + fun clearOrphanAuthInfo() { + coreContext.postOnCoreThread { core -> + var count = 0 + for (authInfo in core.authInfoList) { + val username = authInfo.username + if (username == null) { + Log.i("$TAG Removing auth info [$authInfo] without username") + core.removeAuthInfo(authInfo) + count += 1 + } else { + val account = core.accountList.find { + it.params.identityAddress?.username == username + } + if (account == null) { + Log.i("$TAG Removing auth info [$authInfo] with username [$username] for which no account was found") + core.removeAuthInfo(authInfo) + count += 1 + } + } + } + if (count == 0) { + showGreenToast(R.string.settings_developer_no_auth_info_removed_toast, R.drawable.trash_simple) + } else { + val message = AppUtils.getStringWithPlural(R.plurals.settings_developer_cleared_auth_info_toast, count, "$count") + showFormattedGreenToast(message, R.drawable.warning_circle) + } + } + } } diff --git a/app/src/main/java/org/linphone/ui/main/sso/fragment/SingleSignOnFragment.kt b/app/src/main/java/org/linphone/ui/main/sso/fragment/SingleSignOnFragment.kt deleted file mode 100644 index 91e40d8721..0000000000 --- a/app/src/main/java/org/linphone/ui/main/sso/fragment/SingleSignOnFragment.kt +++ /dev/null @@ -1,112 +0,0 @@ -/* - * Copyright (c) 2010-2023 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.ui.main.sso.fragment - -import android.content.ActivityNotFoundException -import android.content.Intent -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.lifecycle.ViewModelProvider -import androidx.navigation.fragment.navArgs -import net.openid.appauth.AuthorizationException -import net.openid.appauth.AuthorizationResponse -import org.linphone.R -import org.linphone.core.tools.Log -import org.linphone.databinding.SingleSignOnFragmentBinding -import org.linphone.ui.GenericActivity -import org.linphone.ui.main.fragment.GenericMainFragment -import org.linphone.ui.main.sso.viewmodel.SingleSignOnViewModel - -class SingleSignOnFragment : GenericMainFragment() { - companion object { - private const val TAG = "[Single Sign On Fragment]" - - private const val ACTIVITY_RESULT_ID = 666 - } - - private lateinit var binding: SingleSignOnFragmentBinding - - private lateinit var viewModel: SingleSignOnViewModel - - private val args: SingleSignOnFragmentArgs by navArgs() - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - binding = SingleSignOnFragmentBinding.inflate(layoutInflater) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.lifecycleOwner = viewLifecycleOwner - - viewModel = ViewModelProvider(this)[SingleSignOnViewModel::class.java] - binding.viewModel = viewModel - observeToastEvents(viewModel) - - viewModel.singleSignOnProcessCompletedEvent.observe(viewLifecycleOwner) { - it.consume { - Log.i("$TAG Process complete, going back") - goBack() - } - } - - viewModel.startAuthIntentEvent.observe(viewLifecycleOwner) { - it.consume { intent -> - Log.i("$TAG Starting auth intent activity") - try { - startActivityForResult(intent, ACTIVITY_RESULT_ID) - } catch (exception: ActivityNotFoundException) { - Log.e("$TAG No activity found to handle intent: $exception") - } - } - } - - viewModel.onErrorEvent.observe(viewLifecycleOwner) { - it.consume { errorMessage -> - (requireActivity() as GenericActivity).showRedToast( - errorMessage, - R.drawable.warning_circle - ) - } - } - - val serverUrl = args.serverUrl - val username = args.username - Log.i("$TAG Found server URL [$serverUrl] and username [$username] in args") - viewModel.setUp(serverUrl, username.orEmpty()) - } - - @Deprecated("Deprecated in Java") - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - if (requestCode == ACTIVITY_RESULT_ID && data != null) { - val resp = AuthorizationResponse.fromIntent(data) - val ex = AuthorizationException.fromIntent(data) - viewModel.processAuthIntentResponse(resp, ex) - } - - super.onActivityResult(requestCode, resultCode, data) - } -} diff --git a/app/src/main/java/org/linphone/ui/main/viewmodel/AbstractMainViewModel.kt b/app/src/main/java/org/linphone/ui/main/viewmodel/AbstractMainViewModel.kt index 7482d4b92a..c61cdf0983 100644 --- a/app/src/main/java/org/linphone/ui/main/viewmodel/AbstractMainViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/viewmodel/AbstractMainViewModel.kt @@ -70,32 +70,34 @@ open class AbstractMainViewModel val isFilterEmpty = MutableLiveData() + val moreThanOneAccount = MutableLiveData() + val focusSearchBarEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val openDrawerMenuEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val navigateToHistoryEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val navigateToContactsEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val navigateToConversationsEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val navigateToMeetingsEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val defaultAccountChangedEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } protected var currentFilter = "" @@ -138,6 +140,11 @@ open class AbstractMainViewModel computeUnreadMessagesCount() } + @WorkerThread + override fun onMessageRetracted(core: Core, chatRoom: ChatRoom, message: ChatMessage) { + computeUnreadMessagesCount() + } + @WorkerThread override fun onGlobalStateChanged(core: Core, state: GlobalState?, message: String) { if (core.globalState == GlobalState.On) { @@ -146,6 +153,16 @@ open class AbstractMainViewModel } } + @WorkerThread + override fun onAccountAdded(core: Core, account: Account) { + moreThanOneAccount.postValue(core.accountList.size > 1) + } + + @WorkerThread + override fun onAccountRemoved(core: Core, account: Account) { + moreThanOneAccount.postValue(core.accountList.size > 1) + } + @WorkerThread override fun onDefaultAccountChanged(core: Core, defaultAccount: Account?) { updateAvailableMenus() @@ -175,6 +192,7 @@ open class AbstractMainViewModel hideMeetings.value = !coreContext.defaultAccountHasVideoConferenceFactoryUri coreContext.postOnCoreThread { core -> + moreThanOneAccount.postValue(core.accountList.size > 1) core.addListener(coreListener) configure() } @@ -223,10 +241,12 @@ open class AbstractMainViewModel @UiThread fun applyFilter(filter: String = currentFilter) { - Log.i("$TAG New filter set by user [$filter]") - currentFilter = filter - isFilterEmpty.postValue(filter.isEmpty()) - filter() + if (currentFilter != filter) { + Log.i("$TAG New filter set by user [$filter]") + currentFilter = filter + isFilterEmpty.postValue(filter.isEmpty()) + filter() + } } @UiThread diff --git a/app/src/main/java/org/linphone/ui/main/viewmodel/AddParticipantsViewModel.kt b/app/src/main/java/org/linphone/ui/main/viewmodel/AddParticipantsViewModel.kt index a78be11401..a979ebfa38 100644 --- a/app/src/main/java/org/linphone/ui/main/viewmodel/AddParticipantsViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/viewmodel/AddParticipantsViewModel.kt @@ -20,10 +20,12 @@ package org.linphone.ui.main.viewmodel import androidx.annotation.UiThread +import androidx.annotation.WorkerThread import androidx.lifecycle.MutableLiveData import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.R import org.linphone.core.Address +import org.linphone.core.Friend import org.linphone.core.tools.Log import org.linphone.ui.main.model.SelectedAddressModel import org.linphone.utils.AppUtils @@ -43,6 +45,11 @@ class AddParticipantsViewModel switchToMultipleSelectionMode() } + @WorkerThread + override fun onSingleAddressSelected(address: Address, friend: Friend?) { + Log.e("$TAG This shouldn't happen as we should always be in multiple selection mode here!") + } + @UiThread fun isSelectionEmpty(): Boolean { return selection.value.orEmpty().isEmpty() diff --git a/app/src/main/java/org/linphone/ui/main/viewmodel/AddressSelectionViewModel.kt b/app/src/main/java/org/linphone/ui/main/viewmodel/AddressSelectionViewModel.kt index bc847e6a1c..ee952228c6 100644 --- a/app/src/main/java/org/linphone/ui/main/viewmodel/AddressSelectionViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/viewmodel/AddressSelectionViewModel.kt @@ -28,6 +28,7 @@ import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.LinphoneApplication.Companion.corePreferences import org.linphone.R import org.linphone.contacts.ContactsManager +import org.linphone.contacts.getListOfSipAddressesAndPhoneNumbers import org.linphone.core.Address import org.linphone.core.ChatRoom import org.linphone.core.Friend @@ -36,10 +37,15 @@ import org.linphone.core.MagicSearchListenerStub import org.linphone.core.SearchResult import org.linphone.mediastream.Log import org.linphone.ui.main.contacts.model.ContactAvatarModel +import org.linphone.ui.main.contacts.model.ContactNumberOrAddressClickListener +import org.linphone.ui.main.contacts.model.ContactNumberOrAddressModel import org.linphone.ui.main.model.ConversationContactOrSuggestionModel import org.linphone.ui.main.model.SelectedAddressModel import org.linphone.utils.AppUtils +import org.linphone.utils.Event import org.linphone.utils.LinphoneUtils +import kotlin.collections.find +import kotlin.collections.orEmpty abstract class AddressSelectionViewModel @UiThread @@ -62,6 +68,16 @@ abstract class AddressSelectionViewModel val isEmpty = MutableLiveData() + val showResultsLimitReached = MutableLiveData() + + val showNumberOrAddressPickerDialogEvent: MutableLiveData>> by lazy { + MutableLiveData() + } + + val dismissNumberOrAddressPickerDialogEvent: MutableLiveData> by lazy { + MutableLiveData() + } + protected var magicSearchSourceFlags = MagicSearch.Source.All.toInt() protected var skipConversation: Boolean = true @@ -73,12 +89,41 @@ abstract class AddressSelectionViewModel private lateinit var favouritesMagicSearch: MagicSearch + protected val numberOrAddressClickListener = object : ContactNumberOrAddressClickListener { + @UiThread + override fun onClicked(model: ContactNumberOrAddressModel) { + val address = model.address + coreContext.postOnCoreThread { + if (address != null) { + Log.i( + "$TAG Selected address [${model.address.asStringUriOnly()}] from friend [${model.friend.name}]" + ) + onAddressSelected(model.address, model.friend) + } + } + + dismissNumberOrAddressPickerDialogEvent.postValue(Event(true)) + } + + @UiThread + override fun onLongPress(model: ContactNumberOrAddressModel) { + } + } + private val magicSearchListener = object : MagicSearchListenerStub() { @WorkerThread override fun onSearchResultsReceived(magicSearch: MagicSearch) { Log.i("$TAG Magic search contacts available") processMagicSearchResults(magicSearch.lastSearch) } + + @WorkerThread + override fun onResultsLimitReached(magicSearch: MagicSearch, sourcesFlag: Int) { + Log.w("$TAG Results limit reached (configured limit is [${magicSearch.searchLimit}]) for source(s) [$sourcesFlag], user should refine it's search") + if (searchFilter.value.orEmpty().isNotEmpty()) { + showResultsLimitReached.postValue(true) + } + } } private val contactsListener = object : ContactsManager.ContactsListener { @@ -98,6 +143,9 @@ abstract class AddressSelectionViewModel override fun onContactFoundInRemoteDirectory(friend: Friend) { } } + @WorkerThread + abstract fun onSingleAddressSelected(address: Address, friend: Friend?) + init { multipleSelectionMode.value = false isEmpty.value = true @@ -249,6 +297,7 @@ abstract class AddressSelectionViewModel "$TAG Asking Magic search for contacts matching filter [$filter], domain [$domain] and in sources [$sources]" ) searchInProgress.postValue(filter.isNotEmpty()) + showResultsLimitReached.postValue(false) magicSearch.getContactsListAsync( filter, domain, @@ -267,7 +316,6 @@ abstract class AddressSelectionViewModel arrayListOf() } - val defaultAccountDomain = LinphoneUtils.getDefaultAccount()?.params?.domain val favoritesList = arrayListOf() val domain = corePreferences.contactsFilter // Make a quick synchronous search for favorites (in case of total results exceed magic search limit to prevent missing ones) @@ -295,6 +343,7 @@ abstract class AddressSelectionViewModel val contactsList = arrayListOf() val suggestionsList = arrayListOf() + val requestList = arrayListOf() for (result in results) { val address = result.address @@ -324,7 +373,7 @@ abstract class AddressSelectionViewModel } val avatarModel = getContactAvatarModelForAddress(address) model.avatarModel.postValue(avatarModel) - suggestionsList.add(model) + requestList.add(model) continue } @@ -334,7 +383,7 @@ abstract class AddressSelectionViewModel continue } - val model = ConversationContactOrSuggestionModel(address, defaultAccountDomain = defaultAccountDomain) { + val model = ConversationContactOrSuggestionModel(address) { coreContext.startAudioCall(address) } val avatarModel = getContactAvatarModelForAddress(address) @@ -359,6 +408,7 @@ abstract class AddressSelectionViewModel list.addAll(favoritesList) list.addAll(contactsList) list.addAll(suggestionsList) + list.addAll(requestList) searchInProgress.postValue(false) modelsList.postValue(list) @@ -397,7 +447,7 @@ abstract class AddressSelectionViewModel null } } else { - if (chatRoom.subject.orEmpty().contains(filter, ignoreCase = true)) { + if (chatRoom.subjectUtf8.orEmpty().contains(filter, ignoreCase = true)) { chatRoom } else { chatRoom.participants.find { @@ -440,7 +490,7 @@ abstract class AddressSelectionViewModel val subject = if (isOneToOne) { friend?.name } else { - chatRoom.subject + chatRoom.subjectUtf8 } val model = ConversationContactOrSuggestionModel( remoteAddress, @@ -451,7 +501,7 @@ abstract class AddressSelectionViewModel val avatarModel = if (!isOneToOne) { val fakeFriend = coreContext.core.createFriend() - fakeFriend.name = chatRoom.subject + fakeFriend.name = chatRoom.subjectUtf8 val avatarModel = ContactAvatarModel(fakeFriend) avatarModel.defaultToConversationIcon.postValue(true) avatarModel @@ -465,6 +515,70 @@ abstract class AddressSelectionViewModel return conversationsList } + @UiThread + fun handleClickOnContactModel(model: ConversationContactOrSuggestionModel) { + if (model.selected.value == true) { + Log.i( + "$TAG User clicked on already selected item [${model.name}], removing it from selection" + ) + val found = selection.value.orEmpty().find { + it.address.weakEqual(model.address) || it.avatarModel?.friend == model.friend + } + if (found != null) { + coreContext.postOnCoreThread { + removeAddressModelFromSelection(found) + } + return + } else { + Log.e("$TAG Failed to find already selected entry matching the one clicked") + } + } + + coreContext.postOnCoreThread { core -> + val friend = model.friend + if (friend == null) { + Log.i("$TAG Friend is null, using address [${model.address.asStringUriOnly()}]") + val fakeFriend = core.createFriend() + fakeFriend.addAddress(model.address) + onAddressSelected(model.address, fakeFriend) + return@postOnCoreThread + } + + val singleAvailableAddress = LinphoneUtils.getSingleAvailableAddressForFriend(friend) + if (singleAvailableAddress != null) { + Log.i( + "$TAG Only 1 SIP address or phone number found for contact [${friend.name}], using it" + ) + onAddressSelected(singleAvailableAddress, friend) + } else { + val list = friend.getListOfSipAddressesAndPhoneNumbers(numberOrAddressClickListener) + Log.i( + "$TAG [${list.size}] numbers or addresses found for contact [${friend.name}], showing selection dialog" + ) + + showNumberOrAddressPickerDialogEvent.postValue(Event(list)) + } + } + } + + @WorkerThread + private fun onAddressSelected(address: Address, friend: Friend) { + if (multipleSelectionMode.value == true) { + val avatarModel = coreContext.contactsManager.getContactAvatarModelForAddress(address) + val model = SelectedAddressModel(address, avatarModel) { + removeAddressModelFromSelection(it) + } + addAddressModelToSelection(model) + } else { + onSingleAddressSelected(address, friend) + } + + // Clear filter after it was used + coreContext.postOnMainThread { + clearFilter() + } + } + @WorkerThread private fun getContactAvatarModelForAddress(address: Address): ContactAvatarModel { val fakeFriend = coreContext.core.createFriend() diff --git a/app/src/main/java/org/linphone/ui/main/viewmodel/DefaultAccountChangedViewModel.kt b/app/src/main/java/org/linphone/ui/main/viewmodel/DefaultAccountChangedViewModel.kt index 9862d7139e..cccc5ca333 100644 --- a/app/src/main/java/org/linphone/ui/main/viewmodel/DefaultAccountChangedViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/viewmodel/DefaultAccountChangedViewModel.kt @@ -36,7 +36,7 @@ open class DefaultAccountChangedViewModel : GenericViewModel() { } val defaultAccountChangedEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } private val coreListener = object : CoreListenerStub() { diff --git a/app/src/main/java/org/linphone/ui/main/viewmodel/DrawerMenuViewModel.kt b/app/src/main/java/org/linphone/ui/main/viewmodel/DrawerMenuViewModel.kt index eb01e13f6f..2cef71ba8d 100644 --- a/app/src/main/java/org/linphone/ui/main/viewmodel/DrawerMenuViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/viewmodel/DrawerMenuViewModel.kt @@ -19,7 +19,6 @@ */ package org.linphone.ui.main.viewmodel -import android.view.View import androidx.annotation.UiThread import androidx.annotation.WorkerThread import androidx.lifecycle.MutableLiveData @@ -55,23 +54,23 @@ class DrawerMenuViewModel val hideQuitButton = MutableLiveData() val startAssistantEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val closeDrawerEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } - val showAccountPopupMenuEvent: MutableLiveData>> by lazy { - MutableLiveData>>() + val openAccountProfileEvent: MutableLiveData> by lazy { + MutableLiveData() } val defaultAccountChangedEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val openLinkInBrowserEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } private val coreListener = object : CoreListenerStub() { @@ -185,9 +184,9 @@ class DrawerMenuViewModel val list = arrayListOf() for (account in coreContext.core.accountList) { - val model = AccountModel(account) { view, account -> + val model = AccountModel(account) { model -> // onClicked - showAccountPopupMenuEvent.postValue(Event(Pair(view, account))) + openAccountProfileEvent.postValue(Event(model)) } list.add(model) diff --git a/app/src/main/java/org/linphone/ui/main/viewmodel/MainViewModel.kt b/app/src/main/java/org/linphone/ui/main/viewmodel/MainViewModel.kt index bd9ac52cb0..fab860072a 100644 --- a/app/src/main/java/org/linphone/ui/main/viewmodel/MainViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/viewmodel/MainViewModel.kt @@ -83,35 +83,35 @@ class MainViewModel val filesOrTextPendingSharingLabel = MutableLiveData() val goBackToCallEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val openDrawerEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val askPostNotificationsPermissionEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val askFullScreenIntentPermissionEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val showNewAccountToastEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val startLoadingContactsEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val lastAccountRemovedEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val clearFilesOrTextPendingSharingEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } private var accountsFound = -1 @@ -126,6 +126,8 @@ class MainViewModel private var nonDefaultAccountNotificationsCount = 0 + private var mwiNewMessages = false + private val coreListener = object : CoreListenerStub() { @WorkerThread override fun onGlobalStateChanged(core: Core, state: GlobalState?, message: String) { @@ -197,6 +199,14 @@ class MainViewModel } } + @WorkerThread + override fun onMessageRetracted(core: Core, chatRoom: ChatRoom, message: ChatMessage) { + val account = LinphoneUtils.getAccountForAddress(chatRoom.localAddress) + if (account != null && account != core.defaultAccount) { + computeNonDefaultAccountNotificationsCount() + } + } + @WorkerThread override fun onNetworkReachable(core: Core, reachable: Boolean) { Log.i( @@ -294,6 +304,14 @@ class MainViewModel ) addAlert(DEFAULT_ACCOUNT_DISABLED, label) } + + val mwi = account.latestReceivedMessageWaitingIndication + if (mwi != null) { + parseMwiEvent(mwi) + } else { + removeAlert(MWI_MESSAGES_WAITING) + mwiNewMessages = false + } } computeNonDefaultAccountNotificationsCount() @@ -326,23 +344,10 @@ class MainViewModel event: org.linphone.core.Event, mwi: MessageWaitingIndication ) { - if (mwi.hasMessageWaiting()) { - val summaries = mwi.summaries - Log.i( - "$TAG MWI NOTIFY received, messages are waiting ([${summaries.size}] summaries)" - ) - if (summaries.isNotEmpty()) { - val summary = summaries.first() - val label = AppUtils.getStringWithPlural( - R.plurals.mwi_messages_are_waiting, - summary.nbNew, - summary.nbNew.toString() - ) - addAlert(MWI_MESSAGES_WAITING, label) - } - } else { - Log.i("$TAG MWI NOTIFY received, no message is waiting") - removeAlert(MWI_MESSAGES_WAITING) + val address = mwi.accountAddress + val defaultAccountAddress = core.defaultAccount?.params?.identityAddress + if (defaultAccountAddress != null && address?.weakEqual(defaultAccountAddress) == true) { + parseMwiEvent(mwi) } } } @@ -386,6 +391,12 @@ class MainViewModel if (defaultAccount.state == RegistrationState.Ok && !firstAccountRegistered) { triggerNativeAddressBookImport() } + + mwiNewMessages = false + val mwi = defaultAccount.latestReceivedMessageWaitingIndication + if (mwi != null) { + parseMwiEvent(mwi) + } } } @@ -414,13 +425,6 @@ class MainViewModel } } - @UiThread - fun updateNetworkReachability() { - coreContext.postOnCoreThread { - checkNetworkReachability() - } - } - @UiThread fun updateMissingPermissionAlert() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { @@ -436,13 +440,25 @@ class MainViewModel } @UiThread - fun checkForNewAccount() { + fun updateAccountsAndNetworkReachability() { coreContext.postOnCoreThread { core -> - val count = core.accountList.size + val accounts = core.accountList + val count = accounts.size if (count > accountsFound) { + Log.i("$TAG Newly added account detected!") showNewAccountToastEvent.postValue(Event(true)) } accountsFound = count + + checkNetworkReachability() + + for (account in accounts) { + if (account.state == RegistrationState.Failed) { + val identity = account.params.identityAddress?.asStringUriOnly().orEmpty() + Log.i("$TAG Account [$identity] registration state is failed, refreshing REGISTER") + account.refreshRegister() + } + } } } @@ -466,6 +482,10 @@ class MainViewModel askFullScreenIntentPermissionEvent.value = Event(true) } else if (!Compatibility.isPostNotificationsPermissionGranted(coreContext.context)) { askPostNotificationsPermissionEvent.value = Event(true) + } else if (mwiNewMessages) { + coreContext.postOnCoreThread { + callVoiceMail() + } } else { openDrawerEvent.value = Event(true) } @@ -702,4 +722,45 @@ class MainViewModel removeAlert(SEND_NOTIFICATIONS_PERMISSION_NOT_GRANTED) } } + + @WorkerThread + private fun parseMwiEvent(mwi: MessageWaitingIndication) { + if (mwi.hasMessageWaiting()) { + val summaries = mwi.summaries + Log.i( + "$TAG [MWI] Messages are waiting ([${summaries.size}] summaries)" + ) + if (summaries.isNotEmpty()) { + val summary = summaries.first() + val label = AppUtils.getStringWithPlural( + R.plurals.mwi_messages_are_waiting, + summary.nbNew, + summary.nbNew.toString() + ) + Log.i("$TAG [MWI] Showing alert with [${summary.nbNew}] new message(s)") + addAlert(MWI_MESSAGES_WAITING, label) + mwiNewMessages = true + } + } else { + Log.i("$TAG [MWI] No message is waiting") + removeAlert(MWI_MESSAGES_WAITING) + mwiNewMessages = false + } + } + + @WorkerThread + private fun callVoiceMail() { + val defaultAccount = LinphoneUtils.getDefaultAccount() + if (defaultAccount != null) { + val voiceMailUri = defaultAccount.params.voicemailAddress + if (voiceMailUri != null) { + Log.i("$TAG [MWI] Starting call to voicemail address [${voiceMailUri.asStringUriOnly()}]") + coreContext.startCall(voiceMailUri) + } else { + Log.e("$TAG [MWI] Can't call voicemail, no URI configured in account params!") + } + } else { + Log.e("$TAG [MWI] Can't call voicemail, no default account found!") + } + } } diff --git a/app/src/main/java/org/linphone/ui/main/viewmodel/SharedMainViewModel.kt b/app/src/main/java/org/linphone/ui/main/viewmodel/SharedMainViewModel.kt index 69b1c87082..c035463b2f 100644 --- a/app/src/main/java/org/linphone/ui/main/viewmodel/SharedMainViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/viewmodel/SharedMainViewModel.kt @@ -39,27 +39,27 @@ class SharedMainViewModel val isSlidingPaneSlideable = MutableLiveData() val closeSlidingPaneEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val openSlidingPaneEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val navigateToHistoryEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val navigateToContactsEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val navigateToConversationsEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val navigateToMeetingsEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } var currentlyDisplayedFragment = MutableLiveData() @@ -67,36 +67,40 @@ class SharedMainViewModel // Top bar related val searchFilter: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val refreshDrawerMenuAccountsListEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val refreshDrawerMenuQuitButtonEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val forceUpdateAvailableNavigationItems: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } // Account Profile related val goToAccountProfileEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } // Contacts related var displayedFriend: Friend? = null // Prevents the need to go look for the friend val showContactEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val showNewContactEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() + } + + val forceRefreshContactsList: MutableLiveData> by lazy { + MutableLiveData() } var sipAddressToAddToNewContact: String = "" @@ -104,11 +108,11 @@ class SharedMainViewModel // Call logs related val forceRefreshCallLogsListEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val resetMissedCallsCountEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } // Conversation related @@ -118,48 +122,48 @@ class SharedMainViewModel val filesToShareFromIntent = MutableLiveData>() val messageToForwardEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } var displayedChatRoom: ChatRoom? = null // Prevents the need to go look for the chat room val showConversationEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val hideConversationEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } // When using keyboard to share gif or other, see RichContentReceiver & RichEditText classes val richContentUri = MutableLiveData>() val displayFileEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val forceRefreshDisplayedConversationEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val forceRefreshConversationInfoEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val forceRefreshConversationEvents: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val newChatMessageEphemeralLifetimeToSetEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val updateConversationLastMessageEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val updateUnreadMessageCountForCurrentConversationEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } // Meetings related @@ -167,19 +171,19 @@ class SharedMainViewModel var displayedMeeting: ConferenceInfo? = null // Prevents the need to go look for the conference info val meetingEditedEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val forceRefreshMeetingsListEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val goToMeetingWaitingRoomEvent: MutableLiveData> by lazy { - MutableLiveData>() + MutableLiveData() } val goToScheduleMeetingEvent: MutableLiveData>>> by lazy { - MutableLiveData>>>() + MutableLiveData() } // Recordings related @@ -191,6 +195,6 @@ class SharedMainViewModel val mediaViewerFullScreenMode = MutableLiveData() val listOfSelectedSipUrisEvent: MutableLiveData>> by lazy { - MutableLiveData>>() + MutableLiveData() } } diff --git a/app/src/main/java/org/linphone/ui/sso/SingleSignOnActivity.kt b/app/src/main/java/org/linphone/ui/sso/SingleSignOnActivity.kt new file mode 100644 index 0000000000..e9bdddb188 --- /dev/null +++ b/app/src/main/java/org/linphone/ui/sso/SingleSignOnActivity.kt @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2010-2026 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.ui.sso + +import android.content.ActivityNotFoundException +import android.content.Intent +import android.os.Bundle +import androidx.activity.enableEdgeToEdge +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding +import androidx.databinding.DataBindingUtil +import androidx.lifecycle.ViewModelProvider +import net.openid.appauth.AuthorizationException +import net.openid.appauth.AuthorizationResponse +import org.linphone.R +import org.linphone.core.tools.Log +import org.linphone.databinding.SingleSignOnActivityBinding +import org.linphone.ui.GenericActivity +import kotlin.math.max + +class SingleSignOnActivity : GenericActivity() { + companion object { + private const val TAG = "[Single Sign On Activity]" + + private const val ACTIVITY_RESULT_ID = 666 + + const val INTENT_EXTRA_USERNAME = "EXTRA_USERNAME" + const val INTENT_EXTRA_SERVER_URL = "EXTRA_SERVER_URL" + } + + private lateinit var binding: SingleSignOnActivityBinding + + private lateinit var viewModel: SingleSignOnViewModel + + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + + binding = DataBindingUtil.setContentView(this, R.layout.single_sign_on_activity) + binding.lifecycleOwner = this + setUpToastsArea(binding.toastsArea) + + ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, windowInsets -> + val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val keyboard = windowInsets.getInsets(WindowInsetsCompat.Type.ime()) + v.updatePadding( + insets.left, + insets.top, + insets.right, + max(insets.bottom, keyboard.bottom) + ) + WindowInsetsCompat.CONSUMED + } + + viewModel = ViewModelProvider(this)[SingleSignOnViewModel::class.java] + binding.viewModel = viewModel + observeToastEvents() + + binding.setBackClickListener { + finish() + } + + viewModel.singleSignOnProcessCompletedEvent.observe(this) { + it.consume { + Log.i("$TAG Process complete, going back") + finish() + } + } + + viewModel.startAuthIntentEvent.observe(this) { + it.consume { intent -> + Log.i("$TAG Starting auth intent activity") + try { + startActivityForResult(intent, ACTIVITY_RESULT_ID) + } catch (exception: ActivityNotFoundException) { + Log.e("$TAG No activity found to handle intent: $exception") + } + } + } + + val username = intent.getStringExtra(INTENT_EXTRA_USERNAME).orEmpty() + val serverUrl = intent.getStringExtra(INTENT_EXTRA_SERVER_URL).orEmpty() + Log.i("$TAG Found server URL [$serverUrl] and username [$username] in args") + if (serverUrl.isEmpty()) { + Log.e("$TAG Server URL is empty, aborting authentication and going back") + finish() + } else { + viewModel.setUp(serverUrl, username) + } + } + + @Deprecated("Deprecated in Java") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (requestCode == ACTIVITY_RESULT_ID && data != null) { + val resp = AuthorizationResponse.fromIntent(data) + val ex = AuthorizationException.fromIntent(data) + viewModel.processAuthIntentResponse(resp, ex) + } + + super.onActivityResult(requestCode, resultCode, data) + } + + private fun observeToastEvents() { + viewModel.showRedToastEvent.observe(this) { + it.consume { pair -> + val message = getString(pair.first) + val icon = pair.second + showRedToast(message, icon) + } + } + + viewModel.showFormattedRedToastEvent.observe(this) { + it.consume { pair -> + val message = pair.first + val icon = pair.second + showRedToast(message, icon) + } + } + + viewModel.showGreenToastEvent.observe(this) { + it.consume { pair -> + val message = getString(pair.first) + val icon = pair.second + showGreenToast(message, icon) + } + } + + viewModel.showFormattedGreenToastEvent.observe(this) { + it.consume { pair -> + val message = pair.first + val icon = pair.second + showGreenToast(message, icon) + } + } + } +} diff --git a/app/src/main/java/org/linphone/ui/main/sso/viewmodel/SingleSignOnViewModel.kt b/app/src/main/java/org/linphone/ui/sso/SingleSignOnViewModel.kt similarity index 85% rename from app/src/main/java/org/linphone/ui/main/sso/viewmodel/SingleSignOnViewModel.kt rename to app/src/main/java/org/linphone/ui/sso/SingleSignOnViewModel.kt index 80e468920f..737c360872 100644 --- a/app/src/main/java/org/linphone/ui/main/sso/viewmodel/SingleSignOnViewModel.kt +++ b/app/src/main/java/org/linphone/ui/sso/SingleSignOnViewModel.kt @@ -1,29 +1,10 @@ -/* - * Copyright (c) 2010-2023 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.ui.main.sso.viewmodel +package org.linphone.ui.sso import android.content.Intent import androidx.annotation.UiThread +import androidx.core.net.toUri import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope -import java.io.File import kotlinx.coroutines.launch import net.openid.appauth.AuthState import net.openid.appauth.AuthorizationException @@ -42,7 +23,7 @@ import org.linphone.ui.GenericViewModel import org.linphone.utils.Event import org.linphone.utils.FileUtils import org.linphone.utils.TimestampUtils -import androidx.core.net.toUri +import java.io.File class SingleSignOnViewModel @UiThread @@ -51,7 +32,17 @@ class SingleSignOnViewModel private const val TAG = "[Single Sign On ViewModel]" } - val singleSignOnProcessCompletedEvent = MutableLiveData>() + val operationInProgress = MutableLiveData() + + val errorMessage = MutableLiveData() + + val singleSignOnProcessCompletedEvent: MutableLiveData> by lazy { + MutableLiveData() + } + + val startAuthIntentEvent: MutableLiveData> by lazy { + MutableLiveData() + } private var clientId: String private val redirectUri: String @@ -60,19 +51,12 @@ class SingleSignOnViewModel private var username: String = "" - val startAuthIntentEvent: MutableLiveData> by lazy { - MutableLiveData>() - } - - val onErrorEvent: MutableLiveData> by lazy { - MutableLiveData>() - } - private lateinit var authState: AuthState private lateinit var authService: AuthorizationService init { clientId = corePreferences.singleSignOnClientId + operationInProgress.value = true val openIdCallbackScheme = coreContext.context.getString(R.string.linphone_openid_callback_scheme) redirectUri = "$openIdCallbackScheme:/openidcallback" @@ -120,13 +104,23 @@ class SingleSignOnViewModel performRequestToken(resp) } else { Log.e("$TAG Can't perform request token [$ex]") - onErrorEvent.postValue(Event(ex?.errorDescription.orEmpty())) + errorMessage.postValue(ex?.errorDescription.orEmpty()) + + val file = File(corePreferences.ssoCacheFile) + viewModelScope.launch { + val cache = file.absolutePath + Log.w("$TAG Deleting SSO cache file [$cache] to allow for a new sign-on") + FileUtils.deleteFile(cache) + } + coreContext.abortBearerAuthIfAny() + operationInProgress.value = false } } @UiThread private fun singleSignOn() { Log.i("$TAG Fetch from issuer [$singleSignOnUrl]") + operationInProgress.postValue(true) AuthorizationServiceConfiguration.fetchFromIssuer( singleSignOnUrl.toUri(), AuthorizationServiceConfiguration.RetrieveConfigurationCallback { serviceConfiguration, ex -> @@ -134,15 +128,17 @@ class SingleSignOnViewModel Log.e( "$TAG Failed to fetch configuration from issuer [$singleSignOnUrl]: ${ex.errorDescription}" ) - onErrorEvent.postValue( - Event("Failed to fetch configuration from issuer $singleSignOnUrl") - ) + errorMessage.postValue("Failed to fetch configuration from issuer $singleSignOnUrl") + coreContext.abortBearerAuthIfAny() + operationInProgress.postValue(false) return@RetrieveConfigurationCallback } if (serviceConfiguration == null) { Log.e("$TAG Service configuration is null!") - onErrorEvent.postValue(Event("Service configuration is null")) + errorMessage.postValue("Service configuration is null") + coreContext.abortBearerAuthIfAny() + operationInProgress.postValue(false) return@RetrieveConfigurationCallback } @@ -169,8 +165,10 @@ class SingleSignOnViewModel } val authRequest = authRequestBuilder.build() - authService = AuthorizationService(coreContext.context) + authService = + AuthorizationService(coreContext.context) val authIntent = authService.getAuthorizationRequestIntent(authRequest) + Log.i("$TAG Starting auth using intent [$authIntent]") startAuthIntentEvent.postValue(Event(authIntent)) } ) @@ -178,9 +176,11 @@ class SingleSignOnViewModel @UiThread private fun performRefreshToken() { + operationInProgress.postValue(true) if (::authState.isInitialized) { if (!::authService.isInitialized) { - authService = AuthorizationService(coreContext.context) + authService = + AuthorizationService(coreContext.context) } val authStateJsonFile = File(corePreferences.ssoCacheFile) @@ -202,7 +202,9 @@ class SingleSignOnViewModel Log.e( "$TAG Failed to perform token refresh [$ex], destroying auth_state.json file" ) - onErrorEvent.postValue(Event(ex?.errorDescription.orEmpty())) + errorMessage.postValue(ex?.errorDescription.orEmpty()) + coreContext.abortBearerAuthIfAny() + operationInProgress.postValue(false) viewModelScope.launch { FileUtils.deleteFile(authStateJsonFile.absolutePath) @@ -228,6 +230,7 @@ class SingleSignOnViewModel @UiThread private fun performRequestToken(response: AuthorizationResponse) { + operationInProgress.postValue(true) if (::authService.isInitialized) { Log.i("$TAG Starting perform token request") authService.performTokenRequest( @@ -245,7 +248,9 @@ class SingleSignOnViewModel storeTokensInAuthInfo() } else { Log.e("$TAG Failed to perform token request [$ex]") - onErrorEvent.postValue(Event(ex?.errorDescription.orEmpty())) + errorMessage.postValue(ex?.errorDescription.orEmpty()) + coreContext.abortBearerAuthIfAny() + operationInProgress.postValue(false) } } } @@ -264,7 +269,9 @@ class SingleSignOnViewModel return AuthState.jsonDeserialize(content) } catch (exception: Exception) { Log.e("$TAG Failed to use serialized AuthState [$exception]") - onErrorEvent.postValue(Event("Failed to read stored AuthState")) + errorMessage.postValue("Failed to read stored AuthState") + coreContext.abortBearerAuthIfAny() + operationInProgress.postValue(false) } } } else { @@ -294,6 +301,7 @@ class SingleSignOnViewModel @UiThread private fun updateTokenInfo() { Log.i("$TAG Updating token info") + operationInProgress.postValue(true) if (::authState.isInitialized) { if (authState.isAuthorized) { @@ -342,7 +350,9 @@ class SingleSignOnViewModel val expire = authState.accessTokenExpirationTime if (expire == null) { Log.e("$TAG Access token expiration time is null!") - onErrorEvent.postValue(Event("Invalid access token expiration time")) + errorMessage.postValue("Invalid access token expiration time") + coreContext.abortBearerAuthIfAny() + operationInProgress.postValue(false) } else { val accessToken = Factory.instance().createBearerToken(authState.accessToken.orEmpty(), expire / 1000) // Linphone timestamps are in seconds diff --git a/app/src/main/java/org/linphone/ui/welcome/WelcomeActivity.kt b/app/src/main/java/org/linphone/ui/welcome/WelcomeActivity.kt index 8dddeaa278..488bc188cb 100644 --- a/app/src/main/java/org/linphone/ui/welcome/WelcomeActivity.kt +++ b/app/src/main/java/org/linphone/ui/welcome/WelcomeActivity.kt @@ -110,7 +110,7 @@ class WelcomeActivity : GenericActivity() { startActivity(intent) } - private inner class ScreenSlidePagerAdapter(fa: FragmentActivity) : FragmentStateAdapter(fa) { + private class ScreenSlidePagerAdapter(fa: FragmentActivity) : FragmentStateAdapter(fa) { override fun getItemCount(): Int = PAGES override fun createFragment(position: Int): Fragment { diff --git a/app/src/main/java/org/linphone/utils/AndroidUtils.kt b/app/src/main/java/org/linphone/utils/AndroidUtils.kt index 6849c0a1ff..e2aac35224 100644 --- a/app/src/main/java/org/linphone/utils/AndroidUtils.kt +++ b/app/src/main/java/org/linphone/utils/AndroidUtils.kt @@ -20,6 +20,8 @@ package org.linphone.utils import android.app.Activity +import android.content.ClipData +import android.content.ClipboardManager import android.content.Context import android.os.Build import android.provider.Settings @@ -27,11 +29,13 @@ import android.util.DisplayMetrics import android.util.Rational import android.view.View import androidx.annotation.AnyThread +import androidx.annotation.ColorRes import androidx.annotation.DimenRes import androidx.annotation.MainThread import androidx.annotation.PluralsRes import androidx.annotation.StringRes import androidx.annotation.UiThread +import androidx.core.content.ContextCompat import androidx.core.view.SoftwareKeyboardControllerCompat import java.util.Locale import org.linphone.LinphoneApplication.Companion.coreContext @@ -84,6 +88,11 @@ class AppUtils { return coreContext.context.resources.getQuantityString(id, count, value) } + @AnyThread + fun getColorInt(@ColorRes id: Int): Int { + return ContextCompat.getColor(coreContext.context, id) + } + @MainThread fun getPipRatio( activity: Activity, @@ -154,6 +163,21 @@ class AppUtils { return initials } + @AnyThread + fun isTextOnlyContainsEmoji(text: String): Boolean { + if (text.isEmpty()) return false + + var textToCheck = text + do { + val firstSymbol = extractFirstSymbol(textToCheck) + val symbolLength = firstSymbol.length + if (symbolLength <= 1) return false + textToCheck = textToCheck.substring(symbolLength) + } while (textToCheck.isNotEmpty()) + + return true + } + @AnyThread fun getDeviceName(context: Context): String { var name = Settings.Global.getString( @@ -183,6 +207,18 @@ class AppUtils { return name } + @AnyThread + fun copyToClipboard(context: Context, label: String, value: String): Boolean { + try { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText(label, value)) + return true + } catch (e: Exception) { + Log.e("$TAG Failed to copy text [$value] with label [$label] to clipboard: $e") + } + return false + } + @AnyThread private fun extractFirstSymbol(text: String): String { val sequence = StringBuilder(text.length) diff --git a/app/src/main/java/org/linphone/utils/AudioUtils.kt b/app/src/main/java/org/linphone/utils/AudioUtils.kt index e125b51bc3..7ddee7ff54 100644 --- a/app/src/main/java/org/linphone/utils/AudioUtils.kt +++ b/app/src/main/java/org/linphone/utils/AudioUtils.kt @@ -55,6 +55,25 @@ class AudioUtils { routeAudioTo(call, arrayListOf(AudioDevice.Type.HearingAid)) } + @WorkerThread + fun routeAudioBluetoothOrHearingAid(call: Call? = null) { + routeAudioTo( + call, + arrayListOf(AudioDevice.Type.Bluetooth, AudioDevice.Type.HearingAid) + ) + } + + @WorkerThread + fun routeAudioToAnyConnectedAudioDeviceOtherThanEarpieceAndSpeaker(call: Call? = null) { + routeAudioTo( + call, + arrayListOf( + AudioDevice.Type.Bluetooth, AudioDevice.Type.HearingAid, + AudioDevice.Type.Headphones, AudioDevice.Type.Headset + ) + ) + } + @WorkerThread fun routeAudioToHeadset(call: Call? = null) { routeAudioTo( @@ -63,6 +82,11 @@ class AudioUtils { ) } + @WorkerThread + fun routeAudioToHdmi(call: Call? = null) { + routeAudioTo(call, arrayListOf(AudioDevice.Type.Hdmi)) + } + @WorkerThread private fun routeAudioTo( call: Call?, @@ -80,8 +104,7 @@ class AudioUtils { private fun applyAudioRouteChange( call: Call?, types: List, - output: Boolean = true, - skipTelecom: Boolean = false + output: Boolean = true ) { val currentCall = if (coreContext.core.callsNb > 0) { call ?: coreContext.core.currentCall ?: coreContext.core.calls[0] @@ -89,22 +112,7 @@ class AudioUtils { Log.w("$TAG No call found, setting audio route on Core") null } - - if (!skipTelecom) { - val callId = currentCall?.callLog?.callId.orEmpty() - Log.i("$TAG Trying to change audio endpoint using Telecom Manager APIs") - val success = coreContext.telecomManager.applyAudioRouteToCallWithId(types, callId) - if (!success) { - Log.w("$TAG Failed to change audio endpoint to [$types] for call ID [$callId]") - applyAudioRouteChange(currentCall, types, output, skipTelecom = true) - } else { - Log.i("$TAG It seems audio endpoint update using Telecom Manager was successful") - return - } - } else { - Log.i("$TAG Trying to change audio endpoint directly in Linphone SDK") - applyAudioRouteChangeInLinphone(currentCall, types, output) - } + applyAudioRouteChangeInLinphone(currentCall, types, output) } fun applyAudioRouteChangeInLinphone( @@ -219,7 +227,6 @@ class AudioUtils { // In case no headset/hearing aid/bluetooth is connected, use microphone sound card // If none are available, default one will be used var headsetCard: AudioDevice? = null - var bluetoothCard: AudioDevice? = null var microphoneCard: AudioDevice? = null for (device in coreContext.core.audioDevices) { if (device.hasCapability(AudioDevice.Capabilities.CapabilityRecord)) { @@ -227,9 +234,6 @@ class AudioUtils { AudioDevice.Type.Headphones, AudioDevice.Type.Headset -> { headsetCard = device } - AudioDevice.Type.Bluetooth, AudioDevice.Type.HearingAid -> { - bluetoothCard = device - } AudioDevice.Type.Microphone -> { microphoneCard = device } @@ -238,9 +242,9 @@ class AudioUtils { } } Log.i( - "$TAG Found headset/headphones sound card [$headsetCard], bluetooth/hearingAid sound card [$bluetoothCard] and microphone card [$microphoneCard]" + "$TAG Found headset/headphones sound card [$headsetCard] and microphone card [$microphoneCard]" ) - return headsetCard ?: bluetoothCard ?: microphoneCard + return headsetCard ?: microphoneCard } @AnyThread diff --git a/app/src/main/java/org/linphone/utils/ConfirmationDialogModel.kt b/app/src/main/java/org/linphone/utils/ConfirmationDialogModel.kt index 30e9c119b1..57c5d2cc35 100644 --- a/app/src/main/java/org/linphone/utils/ConfirmationDialogModel.kt +++ b/app/src/main/java/org/linphone/utils/ConfirmationDialogModel.kt @@ -31,7 +31,7 @@ class ConfirmationDialogModel val dismissEvent = MutableLiveData>() - val cancelEvent = MutableLiveData>() + val alternativeChoiceEvent = MutableLiveData>() val confirmEvent = MutableLiveData>() @@ -45,8 +45,8 @@ class ConfirmationDialogModel } @UiThread - fun cancel() { - cancelEvent.value = Event(true) + fun alternativeChoice() { + alternativeChoiceEvent.value = Event(true) } @UiThread diff --git a/app/src/main/java/org/linphone/utils/DataBindingUtils.kt b/app/src/main/java/org/linphone/utils/DataBindingUtils.kt index a299011b73..4e3a9aece9 100644 --- a/app/src/main/java/org/linphone/utils/DataBindingUtils.kt +++ b/app/src/main/java/org/linphone/utils/DataBindingUtils.kt @@ -26,12 +26,14 @@ import android.graphics.drawable.AnimatedVectorDrawable import android.text.Editable import android.text.TextWatcher import android.view.LayoutInflater +import android.view.MotionEvent import android.view.TextureView import android.view.View import android.view.ViewGroup import android.view.inputmethod.EditorInfo import android.widget.EditText import android.widget.ImageView +import android.widget.SeekBar import androidx.annotation.ColorInt import androidx.annotation.ColorRes import androidx.annotation.DimenRes @@ -62,13 +64,14 @@ import coil3.transform.RoundedCornersTransformation import coil3.video.videoFrameMillis import com.google.android.flexbox.FlexboxLayout import org.linphone.BR +import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.R import org.linphone.contacts.AbstractAvatarModel -import org.linphone.contacts.AvatarGenerator import org.linphone.core.ConsolidatedPresence import org.linphone.core.tools.Log import org.linphone.ui.NotoSansFont import org.linphone.ui.call.conference.model.ConferenceParticipantDeviceModel +import org.linphone.ui.call.view.VuMeterView import org.linphone.ui.call.view.RoundCornersTextureView /** @@ -221,6 +224,25 @@ fun View.setKeyboardInsetListener(lambda: (visible: Boolean) -> Unit) { } } +@UiThread +@BindingAdapter("android:onContextClick") +fun View.setContextListener(longClickListener: View.OnLongClickListener?) { + longClickListener ?: return + + this.setOnContextClickListener { + longClickListener.onLongClick(this) + } +} + +@UiThread +@BindingAdapter("android:onContextClick") +fun View.setContextListener(lambda: () -> Unit) { + this.setOnContextClickListener { + lambda.invoke() + true + } +} + @UiThread @BindingAdapter("android:src") fun ImageView.setSourceImageResource(resource: Int) { @@ -230,8 +252,10 @@ fun ImageView.setSourceImageResource(resource: Int) { @UiThread @BindingAdapter("android:drawableTint") fun AppCompatTextView.setDrawableTint(@ColorInt color: Int) { - for (drawable in compoundDrawablesRelative) { - drawable?.setTint(color) + coreContext.postOnMainThread { + for (drawable in compoundDrawablesRelative) { + drawable?.setTint(color) + } } } @@ -429,14 +453,6 @@ fun ImageView.loadCallAvatarWithCoil(model: AbstractAvatarModel?) { loadContactPictureWithCoil(this, model, size = size, textSize = initialsSize) } -@UiThread -@BindingAdapter("coilInitials") -fun ImageView.loadInitialsAvatarWithCoil(initials: String?) { - val builder = AvatarGenerator(context) - builder.setInitials(initials.orEmpty()) - load(builder.buildDrawable()) -} - @SuppressLint("ResourceType") private fun loadContactPictureWithCoil( imageView: ImageView, @@ -488,7 +504,7 @@ private fun getErrorImageLoader( R.drawable.inset_user_circle } } else { - ImageUtils.getGeneratedAvatar(context, size, textSize, initials) + ImageUtils.generatedAvatarIfNeededAndReturnPath(context, initials) } } @@ -501,6 +517,12 @@ fun setParticipantTextureView( model.setTextureView(textureView) } +@UiThread +@BindingAdapter("vuMeterPercentage") +fun setVuMeterPercentage(view: VuMeterView, percentage: Float) { + view.setVuMeterPercentage(percentage) +} + @UiThread @BindingAdapter("onValueChanged") fun AppCompatEditText.editTextSetting(lambda: () -> Unit) { @@ -616,6 +638,11 @@ fun setFlexboxLayoutWrapBefore(view: View, wrap: Boolean = false) { view.layoutParams = params } +@BindingAdapter("seekBarListener") +fun setSeekBarListener(seekBar: SeekBar, listener: SeekBar.OnSeekBarChangeListener) { + seekBar.setOnSeekBarChangeListener(listener) +} + @BindingAdapter("emojiPickedListener") fun EmojiPickerView.setEmojiPickedListener(listener: EmojiPickedListener) { setOnEmojiPickedListener { emoji -> @@ -626,3 +653,21 @@ fun EmojiPickerView.setEmojiPickedListener(listener: EmojiPickedListener) { interface EmojiPickedListener { fun onEmojiPicked(item: EmojiViewItem) } + +@SuppressLint("ClickableViewAccessibility") +@BindingAdapter("onTouchListener") +fun View.setTouchListener(listener: TouchListener) { + setOnTouchListener { view, event -> + return@setOnTouchListener when (event.action) { + MotionEvent.ACTION_DOWN -> listener.onPressed(view) + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> listener.onReleased(view) + else -> false + } + } +} + +interface TouchListener { + fun onPressed(view: View): Boolean + + fun onReleased(view: View): Boolean +} diff --git a/app/src/main/java/org/linphone/utils/DialogUtils.kt b/app/src/main/java/org/linphone/utils/DialogUtils.kt index 0b7b1bb872..100da80fc6 100644 --- a/app/src/main/java/org/linphone/utils/DialogUtils.kt +++ b/app/src/main/java/org/linphone/utils/DialogUtils.kt @@ -42,7 +42,6 @@ import org.linphone.databinding.DialogConfirmTurningOnVfsBinding import org.linphone.databinding.DialogContactConfirmTrustCallBinding import org.linphone.databinding.DialogContactTrustProcessBinding import org.linphone.databinding.DialogDeleteContactBinding -import org.linphone.databinding.DialogDeleteMeetingBinding import org.linphone.databinding.DialogKickFromConferenceBinding import org.linphone.databinding.DialogManageAccountInternationalPrefixHelpBinding import org.linphone.databinding.DialogMergeCallsIntoConferenceBinding @@ -67,6 +66,13 @@ import org.linphone.ui.main.contacts.model.ContactTrustDialogModel import org.linphone.ui.main.contacts.model.NumberOrAddressPickerDialogModel import org.linphone.ui.main.model.GroupSetOrEditSubjectDialogModel import androidx.core.graphics.drawable.toDrawable +import org.linphone.databinding.DialogAssistantCreateAccountPhoneNumberValidationNotAvailableBinding +import org.linphone.databinding.DialogDeleteMeetingBinding +import org.linphone.databinding.DialogLeaveGroupConversationBinding +import org.linphone.databinding.DialogManageAccountOutboundProxyHelpBinding +import org.linphone.databinding.DialogRemoveCallLogBinding +import org.linphone.databinding.DialogRemoveConversationBinding +import org.linphone.databinding.DialogRemoveParticipantFromGroupBinding class DialogUtils { companion object { @@ -103,6 +109,22 @@ class DialogUtils { return getDialog(context, binding) } + @UiThread + fun getAccountCreationPhoneNumberValidationNotAvailableDialog( + context: Context, + viewModel: ConfirmationDialogModel + ): Dialog { + val binding: DialogAssistantCreateAccountPhoneNumberValidationNotAvailableBinding = DataBindingUtil.inflate( + LayoutInflater.from(context), + R.layout.dialog_assistant_create_account_phone_number_validation_not_available, + null, + false + ) + binding.viewModel = viewModel + + return getDialog(context, binding) + } + @UiThread fun getAccountInternationalPrefixHelpDialog(context: Context): Dialog { val binding: DialogManageAccountInternationalPrefixHelpBinding = DataBindingUtil.inflate( @@ -120,6 +142,23 @@ class DialogUtils { return dialog } + @UiThread + fun getAccountOutboundProxyHelpDialog(context: Context): Dialog { + val binding: DialogManageAccountOutboundProxyHelpBinding = DataBindingUtil.inflate( + LayoutInflater.from(context), + R.layout.dialog_manage_account_outbound_proxy_help, + null, + false + ) + val dialog = getDialog(context, binding) + + binding.setDismissClickListener { + dialog.dismiss() + } + + return dialog + } + @UiThread fun getConfirmAccountRemovalDialog( context: Context, @@ -245,6 +284,22 @@ class DialogUtils { return getDialog(context, binding) } + @UiThread + fun getRemoveCallLogConfirmationDialog( + context: Context, + viewModel: ConfirmationDialogModel + ): Dialog { + val binding: DialogRemoveCallLogBinding = DataBindingUtil.inflate( + LayoutInflater.from(context), + R.layout.dialog_remove_call_log, + null, + false + ) + binding.viewModel = viewModel + + return getDialog(context, binding) + } + @UiThread fun getRemoveAllCallLogsConfirmationDialog( context: Context, @@ -313,6 +368,54 @@ class DialogUtils { return getDialog(context, binding) } + @UiThread + fun getConfirmRemoveParticipantDialog( + context: Context, + viewModel: ConfirmationDialogModel + ): Dialog { + val binding: DialogRemoveParticipantFromGroupBinding = DataBindingUtil.inflate( + LayoutInflater.from(context), + R.layout.dialog_remove_participant_from_group, + null, + false + ) + binding.viewModel = viewModel + + return getDialog(context, binding) + } + + @UiThread + fun getLeaveConversationConfirmationDialog( + context: Context, + viewModel: ConfirmationDialogModel + ): Dialog { + val binding: DialogLeaveGroupConversationBinding = DataBindingUtil.inflate( + LayoutInflater.from(context), + R.layout.dialog_leave_group_conversation, + null, + false + ) + binding.viewModel = viewModel + + return getDialog(context, binding) + } + + @UiThread + fun getDeleteConversationConfirmationDialog( + context: Context, + viewModel: ConfirmationDialogModel + ): Dialog { + val binding: DialogRemoveConversationBinding = DataBindingUtil.inflate( + LayoutInflater.from(context), + R.layout.dialog_remove_conversation, + null, + false + ) + binding.viewModel = viewModel + + return getDialog(context, binding) + } + @UiThread fun getDeleteConversationHistoryConfirmationDialog( context: Context, diff --git a/app/src/main/java/org/linphone/utils/FileUtils.kt b/app/src/main/java/org/linphone/utils/FileUtils.kt index 8500de8e74..79a6e791d1 100644 --- a/app/src/main/java/org/linphone/utils/FileUtils.kt +++ b/app/src/main/java/org/linphone/utils/FileUtils.kt @@ -413,16 +413,20 @@ class FileUtils { } @AnyThread - fun storeBitmap(bitmap: Bitmap, fileName: String): String { - val path = getFileStorageCacheDir("$fileName.jpg", true) - FileOutputStream(path).use { outputStream -> + fun storeBitmap(bitmap: Bitmap, file: File): String { + val format = if (file.absolutePath.endsWith(".png")) { + Bitmap.CompressFormat.PNG + } else { + Bitmap.CompressFormat.JPEG + } + FileOutputStream(file).use { outputStream -> bitmap.compress( - Bitmap.CompressFormat.JPEG, + format, 100, outputStream ) } - return path.absolutePath + return file.absolutePath } @AnyThread diff --git a/app/src/main/java/org/linphone/utils/ImageUtils.kt b/app/src/main/java/org/linphone/utils/ImageUtils.kt index 52144be88a..1eb957ac6e 100644 --- a/app/src/main/java/org/linphone/utils/ImageUtils.kt +++ b/app/src/main/java/org/linphone/utils/ImageUtils.kt @@ -20,6 +20,7 @@ package org.linphone.utils import android.content.Context +import android.content.res.Configuration import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.ImageDecoder @@ -27,7 +28,6 @@ import android.graphics.Paint import android.graphics.PorterDuff import android.graphics.PorterDuffXfermode import android.graphics.Rect -import android.graphics.drawable.BitmapDrawable import androidx.annotation.AnyThread import androidx.annotation.WorkerThread import java.io.FileNotFoundException @@ -35,24 +35,31 @@ import org.linphone.contacts.AvatarGenerator import org.linphone.core.tools.Log import androidx.core.net.toUri import androidx.core.graphics.createBitmap +import org.linphone.R class ImageUtils { companion object { private const val TAG = "[Image Utils]" @AnyThread - fun getGeneratedAvatar(context: Context, size: Int = 0, textSize: Int = 0, initials: String): BitmapDrawable { + fun generatedAvatarIfNeededAndReturnPath(context: Context, initials: String): String { + val darkMode = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES + val suffix = if (darkMode) "_dark" else "_light" + + val generatedAvatarPath = FileUtils.getFileStorageCacheDir("$initials$suffix.png", overrideExisting = true) + if (generatedAvatarPath.exists()) { + val path = generatedAvatarPath.absolutePath + return path + } + val builder = AvatarGenerator(context) builder.setInitials(initials) - if (size > 0) { - builder.setAvatarSize( - AppUtils.getDimension(size).toInt() - ) - } - if (textSize > 0) { - builder.setTextSize(AppUtils.getDimension(textSize)) - } - return builder.buildDrawable() + builder.setAvatarSize(AppUtils.getDimension(R.dimen.avatar_big_size).toInt()) + builder.setTextSize(AppUtils.getDimension(R.dimen.avatar_initials_call_text_size)) + + val bitmap = builder.buildBitmap(false) + val path = FileUtils.storeBitmap(bitmap, generatedAvatarPath) + return path } @WorkerThread diff --git a/app/src/main/java/org/linphone/utils/LinphoneUtils.kt b/app/src/main/java/org/linphone/utils/LinphoneUtils.kt index ec1fcc9973..dd2298050f 100644 --- a/app/src/main/java/org/linphone/utils/LinphoneUtils.kt +++ b/app/src/main/java/org/linphone/utils/LinphoneUtils.kt @@ -37,6 +37,7 @@ import org.linphone.R import org.linphone.contacts.getListOfSipAddresses import org.linphone.core.Account import org.linphone.core.Address +import org.linphone.core.AudioDevice import org.linphone.core.Call import org.linphone.core.Call.Dir import org.linphone.core.Call.Status @@ -114,6 +115,19 @@ class LinphoneUtils { return "$scheme:$username@${address.domain}" } + @WorkerThread + fun getDisplayAddress(address: Address): String { + val username = address.username + if (!corePreferences.onlyDisplaySipUriUsername || username.isNullOrEmpty()) { + return getAddressAsCleanStringUriOnly(address) + } + val homeDomain = getDefaultAccount()?.params?.domain + if (address.domain == homeDomain || address.domain == corePreferences.defaultDomain) { + return username + } + return getAddressAsCleanStringUriOnly(address) + } + @WorkerThread fun getDisplayName(address: Address?): String { if (address == null) return "[null]" @@ -163,7 +177,7 @@ class LinphoneUtils { } val defaultDomain = corePreferences.defaultDomain - val currentDomain = friend.core.defaultAccount?.params?.identityAddress?.domain + val currentDomain = getDefaultAccount()?.params?.identityAddress?.domain if (defaultDomain != currentDomain) return null var defaultDomainAddressesCount = 0 @@ -216,6 +230,21 @@ class LinphoneUtils { return null } + @WorkerThread + fun isSipAddressLinkedToPhoneNumberByPresence(friend: Friend, sipAddress: String): Boolean { + for (phoneNumber in friend.phoneNumbers) { + val presenceModel = friend.getPresenceModelForUriOrTel(phoneNumber) + if (presenceModel != null) { + val contact = presenceModel.contact + if (contact == sipAddress) { + Log.i("$TAG SIP address [$sipAddress] is presence contact address for phone number [$phoneNumber]") + return true + } + } + } + return false + } + @AnyThread fun isCallIncoming(callState: Call.State): Boolean { return when (callState) { @@ -250,6 +279,14 @@ class LinphoneUtils { } } + @AnyThread + fun isCallActive(callState: Call.State): Boolean { + return when (callState) { + Call.State.Connected, Call.State.StreamsRunning, Call.State.UpdatedByRemote, Call.State.Updating -> true + else -> false + } + } + @WorkerThread fun getCallErrorInfoToast(call: Call): String { val errorInfo = call.errorInfo @@ -482,7 +519,7 @@ class LinphoneUtils { ChatMessage.State.InProgress, ChatMessage.State.FileTransferInProgress -> { R.drawable.animated_in_progress } - ChatMessage.State.PendingDelivery -> { + ChatMessage.State.PendingDelivery, ChatMessage.State.Queued -> { R.drawable.hourglass } else -> { @@ -491,6 +528,38 @@ class LinphoneUtils { } } + @WorkerThread + fun getComposingIconAndText(chatRoom: ChatRoom): Pair { + val composing = chatRoom.isRemoteComposing + if (!composing) { + return Pair(0, "") + } + + val icon = R.drawable.chat_teardrop_dots + val composingFriends = arrayListOf() + var label = "" + for (participant in chatRoom.composingParticipants) { + val address = participant.address + val avatar = coreContext.contactsManager.getContactAvatarModelForAddress(address) + val name = avatar.name.value ?: getDisplayName(address) + composingFriends.add(name) + label += "$name, " + } + if (composingFriends.isNotEmpty()) { + label = label.dropLast(2) + + // TODO: use voice recording content type to change icon/text + val format = AppUtils.getStringWithPlural( + R.plurals.conversation_composing_label, + composingFriends.size, + label + ) + return Pair(icon, format) + } + + return Pair(icon, label) + } + @AnyThread fun formatEphemeralExpiration(duration: Long): String { return when (duration) { @@ -596,8 +665,18 @@ class LinphoneUtils { @WorkerThread private fun getTextDescribingMessage(message: ChatMessage): Pair { + // Check if message is empty (when deleted by its sender, for everyone) + if (message.isRetracted) { + val text = if (message.isOutgoing) { + AppUtils.getString(R.string.conversation_message_content_deleted_by_us_label) + } else { + AppUtils.getString(R.string.conversation_message_content_deleted_label) + } + return Pair(text, "") + } + // If message contains text, then use that - var text = message.contents.find { content -> content.isText }?.utf8Text ?: "" + var text = message.contents.find { content -> content.isText }?.utf8Text.orEmpty() var contentDescription = "" if (text.isEmpty()) { @@ -645,7 +724,9 @@ class LinphoneUtils { if (text.isNotEmpty()) { text += ", " } - text += content.name + if (!content.name.isNullOrEmpty()) { + text += content.name + } } } } @@ -689,5 +770,42 @@ class LinphoneUtils { return avatarModel } + + @WorkerThread + fun getAudioDeviceName(device: AudioDevice): String { + return when (device.type) { + AudioDevice.Type.Microphone -> { + AppUtils.getString(R.string.call_audio_device_type_microphone) + } + AudioDevice.Type.Earpiece -> { + AppUtils.getString(R.string.call_audio_device_type_earpiece) + } + AudioDevice.Type.Speaker -> { + AppUtils.getString(R.string.call_audio_device_type_speaker) + } + AudioDevice.Type.Headset -> { + AppUtils.getString(R.string.call_audio_device_type_headset) + } + AudioDevice.Type.Headphones -> { + AppUtils.getString(R.string.call_audio_device_type_headphones) + } + AudioDevice.Type.Bluetooth -> { + AppUtils.getFormattedString( + R.string.call_audio_device_type_bluetooth, + device.deviceName + ) + } + AudioDevice.Type.HearingAid -> { + AppUtils.getFormattedString( + R.string.call_audio_device_type_hearing_aid, + device.deviceName + ) + } + AudioDevice.Type.Hdmi -> { + AppUtils.getString(R.string.call_audio_device_type_hdmi) + } + else -> "${device.deviceName}(${device.type})" + } + } } } diff --git a/app/src/main/java/org/linphone/utils/PatternClickableSpan.kt b/app/src/main/java/org/linphone/utils/PatternClickableSpan.kt index 13ac79337c..58b28f3f5b 100644 --- a/app/src/main/java/org/linphone/utils/PatternClickableSpan.kt +++ b/app/src/main/java/org/linphone/utils/PatternClickableSpan.kt @@ -32,7 +32,7 @@ import java.util.regex.Pattern class PatternClickableSpan { private var patterns: ArrayList = ArrayList() - inner class SpannablePatternItem( + class SpannablePatternItem( var pattern: Pattern, var listener: SpannableClickedListener ) diff --git a/app/src/main/java/org/linphone/utils/ShortcutUtils.kt b/app/src/main/java/org/linphone/utils/ShortcutUtils.kt index 381a1baefd..2be768383e 100644 --- a/app/src/main/java/org/linphone/utils/ShortcutUtils.kt +++ b/app/src/main/java/org/linphone/utils/ShortcutUtils.kt @@ -23,6 +23,7 @@ import android.content.Context import android.content.Intent import android.content.pm.ShortcutInfo import android.os.Bundle +import androidx.annotation.AnyThread import androidx.annotation.WorkerThread import androidx.collection.ArraySet import androidx.core.app.Person @@ -52,53 +53,49 @@ class ShortcutUtils { } @WorkerThread - fun createShortcutsToChatRooms(context: Context) { - if (ShortcutManagerCompat.isRateLimitingActive(context)) { - Log.e("$TAG Rate limiting is active, aborting") - return + fun removeLegacyShortcuts(context: Context) { + val dynamicShortcutsToRemove = arrayListOf() + for (shortcut in ShortcutManagerCompat.getDynamicShortcuts(context)) { + val id = shortcut.id + Log.d("$TAG Found dynamic shortcut with ID [$id]") + if (id.contains("#~#")) { + Log.w("$TAG Found legacy dynamic shortcut with ID [$id] detected, removing it") + dynamicShortcutsToRemove.add(id) + } } - - Log.i("$TAG Creating dynamic shortcuts for conversations") - val defaultAccount = coreContext.core.defaultAccount - if (defaultAccount == null) { - Log.w("$TAG No default account found, skipping...") - return + if (dynamicShortcutsToRemove.isNotEmpty()) { + ShortcutManagerCompat.removeDynamicShortcuts(context, dynamicShortcutsToRemove) } - var count = 0 - for (chatRoom in defaultAccount.chatRooms) { - if (defaultAccount.params.instantMessagingEncryptionMandatory && - !chatRoom.hasCapability(ChatRoom.Capabilities.Encrypted.toInt()) - ) { - Log.w( - "$TAG Account is in secure mode, skipping not encrypted conversation [${LinphoneUtils.getConversationId( - chatRoom - )}]" - ) - continue - } - - if (count >= 4) { - Log.i("$TAG We already created [$count] shortcuts, stopping here") - break + // Check for non-dynamic cached legacy shortcuts + // Warning: on Android >= 10 dynamic shortcuts will still be returned! + val flags = ShortcutManagerCompat.FLAG_MATCH_MANIFEST or ShortcutManagerCompat.FLAG_MATCH_PINNED or ShortcutManagerCompat.FLAG_MATCH_CACHED + val cachedShortcutsToRemove = arrayListOf() + for (shortcut in ShortcutManagerCompat.getShortcuts(context, flags)) { + val id = shortcut.id + val dynamic = shortcut.isDynamic + val cached = shortcut.isCached + if (!dynamic && cached && id.contains("#~#")) { + Log.i("$TAG Found cached legacy shortcut with ID [$id], removing it") + cachedShortcutsToRemove.add(id) } + } + if (cachedShortcutsToRemove.isNotEmpty()) { + ShortcutManagerCompat.removeLongLivedShortcuts(context, cachedShortcutsToRemove) + } + } - val shortcut: ShortcutInfoCompat? = createChatRoomShortcut(context, chatRoom) - if (shortcut != null) { - Log.i("$TAG Created dynamic shortcut for ${shortcut.shortLabel}") - try { - val keepGoing = ShortcutManagerCompat.pushDynamicShortcut(context, shortcut) - if (keepGoing) { - count += 1 - } else { - break - } - } catch (e: Exception) { - Log.e("$TAG Failed to push dynamic shortcut for ${shortcut.shortLabel}: $e") - } + @WorkerThread + fun createDynamicShortcutToChatRoom(context: Context, chatRoom: ChatRoom) { + val shortcut: ShortcutInfoCompat? = createChatRoomShortcut(context, chatRoom) + if (shortcut != null) { + Log.i("$TAG Created dynamic shortcut for ${shortcut.shortLabel}, pushing it") + try { + ShortcutManagerCompat.pushDynamicShortcut(context, shortcut) + } catch (e: Exception) { + Log.e("$TAG Failed to push dynamic shortcut for ${shortcut.shortLabel}: $e") } } - Log.i("$TAG Created $count dynamic shortcuts") } @WorkerThread @@ -112,6 +109,7 @@ class ShortcutUtils { val personsList = arrayListOf() val subject: String + var isGroup = false val icon: IconCompat = if (chatRoom.hasCapability( ChatRoom.Capabilities.Basic.toInt() ) @@ -141,7 +139,8 @@ class ShortcutUtils { AppUtils.getInitials(subject) ).buildIcon() } else { - subject = chatRoom.subject.orEmpty() + isGroup = true + subject = chatRoom.subjectUtf8.orEmpty() AvatarGenerator(context).setInitials(AppUtils.getInitials(subject)).buildIcon() } @@ -157,15 +156,26 @@ class ShortcutUtils { intent.putExtra(ARGUMENTS_CHAT, true) intent.putExtra(ARGUMENTS_CONVERSATION_ID, id) - return ShortcutInfoCompat.Builder(context, id) + val builder = ShortcutInfoCompat.Builder(context, id) .setShortLabel(subject) .setIcon(icon) .setPersons(persons) .setCategories(categories) .setIntent(intent) + .setIsConversation() .setLongLived(Version.sdkAboveOrEqual(Version.API30_ANDROID_11)) .setLocusId(LocusIdCompat(id)) - .build() + + // See https://developer.android.com/training/sharing/direct-share-targets#track-shortcut-usage-comms-apps + if (isGroup) { + builder.addCapabilityBinding("actions.intent.SEND_MESSAGE", "message.recipient.@type", listOf("Audience")) + builder.addCapabilityBinding("actions.intent.RECEIVE_MESSAGE", "message.sender.@type", listOf("Audience")) + } else { + builder.addCapabilityBinding("actions.intent.SEND_MESSAGE") + builder.addCapabilityBinding("actions.intent.RECEIVE_MESSAGE") + } + + return builder.build() } catch (e: NumberFormatException) { Log.e("$TAG createChatRoomShortcut for id [$id] exception: $e") } @@ -179,7 +189,50 @@ class ShortcutUtils { val found = ShortcutManagerCompat.getDynamicShortcuts(context).find { it.id == id } + Log.d("$TAG Dynamic shortcut for chat room with ID [$id] ${if (found != null) "exists" else "doesn't exists"}") return found != null } + + @WorkerThread + fun createOrUpdateChatRoomShortcut(context: Context, chatRoom: ChatRoom) { + val id = LinphoneUtils.getConversationId(chatRoom) + val found = ShortcutManagerCompat.getDynamicShortcuts(context).find { + it.id == id + } + val shortcut: ShortcutInfoCompat? = createChatRoomShortcut(context, chatRoom) + if (shortcut == null) { + Log.e("$TAG Failed to create shortcut info for chat room [$id]") + return + } + + if (found == null) { + Log.i("$TAG Created dynamic shortcut for ${shortcut.shortLabel}, pushing it") + try { + ShortcutManagerCompat.pushDynamicShortcut(context, shortcut) + } catch (e: Exception) { + Log.e("$TAG Failed to push dynamic shortcut for ${shortcut.shortLabel}: $e") + } + } else { + Log.i("$TAG Updating dynamic shortcut for ${shortcut.shortLabel}") + try { + ShortcutManagerCompat.updateShortcuts(context, listOf(shortcut)) + } catch (e: Exception) { + Log.e("$TAG Failed to update dynamic shortcut for ${shortcut.shortLabel}: $e") + } + } + } + + @AnyThread + fun reportChatRoomShortcutHasBeenUsed(context: Context, chatRoomId: String) { + val found = ShortcutManagerCompat.getDynamicShortcuts(context).find { + it.id == chatRoomId + } + if (found != null) { + Log.i("$TAG Reporting shortcut for chat room [$chatRoomId] was used") + ShortcutManagerCompat.reportShortcutUsed(context, chatRoomId) + } else { + Log.w("$TAG No shortcut was found for chat room [$chatRoomId]") + } + } } } diff --git a/app/src/main/java/org/linphone/utils/TimestampUtils.kt b/app/src/main/java/org/linphone/utils/TimestampUtils.kt index 098300030f..d0736c6f0e 100644 --- a/app/src/main/java/org/linphone/utils/TimestampUtils.kt +++ b/app/src/main/java/org/linphone/utils/TimestampUtils.kt @@ -84,7 +84,7 @@ class TimestampUtils { it.toString() } } ?: "?" - val shorten = upperCased.substring(0, 3) + val shorten = upperCased.take(3) return "$shorten." } diff --git a/app/src/main/res/color/danger_500.xml b/app/src/main/res/color/danger_500.xml index bb77921219..3a40735a8b 100644 --- a/app/src/main/res/color/danger_500.xml +++ b/app/src/main/res/color/danger_500.xml @@ -1,4 +1,7 @@ + + + diff --git a/app/src/main/res/color/icon_color_selector.xml b/app/src/main/res/color/icon_color_selector.xml index 93c5547b12..4e8e23e2af 100644 --- a/app/src/main/res/color/icon_color_selector.xml +++ b/app/src/main/res/color/icon_color_selector.xml @@ -2,5 +2,6 @@ + \ No newline at end of file diff --git a/app/src/main/res/color/icon_primary_color_selector.xml b/app/src/main/res/color/icon_primary_color_selector.xml index f05cdbb0e6..c13e2e2b23 100644 --- a/app/src/main/res/color/icon_primary_color_selector.xml +++ b/app/src/main/res/color/icon_primary_color_selector.xml @@ -2,5 +2,6 @@ + \ No newline at end of file diff --git a/app/src/main/res/color/in_call_button_background_tint_color.xml b/app/src/main/res/color/in_call_button_background_tint_color.xml index 67e4952cbd..0becb21f87 100644 --- a/app/src/main/res/color/in_call_button_background_tint_color.xml +++ b/app/src/main/res/color/in_call_button_background_tint_color.xml @@ -2,5 +2,6 @@ + diff --git a/app/src/main/res/color/in_call_button_color.xml b/app/src/main/res/color/in_call_button_color.xml index 4fa2d9bb3e..92cd270cfd 100644 --- a/app/src/main/res/color/in_call_button_color.xml +++ b/app/src/main/res/color/in_call_button_color.xml @@ -2,6 +2,8 @@ + diff --git a/app/src/main/res/color/in_call_button_tint_color.xml b/app/src/main/res/color/in_call_button_tint_color.xml index 3c8402d0a9..898f65ef22 100644 --- a/app/src/main/res/color/in_call_button_tint_color.xml +++ b/app/src/main/res/color/in_call_button_tint_color.xml @@ -2,5 +2,6 @@ + diff --git a/app/src/main/res/color/main_top_bar_icon_color.xml b/app/src/main/res/color/main_top_bar_icon_color.xml new file mode 100644 index 0000000000..57692ccc35 --- /dev/null +++ b/app/src/main/res/color/main_top_bar_icon_color.xml @@ -0,0 +1,11 @@ + + + + + + + diff --git a/app/src/main/res/color/primary_button_label_color.xml b/app/src/main/res/color/primary_button_label_color.xml index 31aa017d9a..508123094b 100644 --- a/app/src/main/res/color/primary_button_label_color.xml +++ b/app/src/main/res/color/primary_button_label_color.xml @@ -1,9 +1,11 @@ + android:color="?attr/color_bottom_sheet_disabled_menu" /> + android:color="?attr/color_main1_500" /> + diff --git a/app/src/main/res/color/secondary_button_label_color.xml b/app/src/main/res/color/secondary_button_label_color.xml index 4788ca05b1..8dc895ca96 100644 --- a/app/src/main/res/color/secondary_button_label_color.xml +++ b/app/src/main/res/color/secondary_button_label_color.xml @@ -1,9 +1,11 @@ + android:color="?attr/color_bottom_sheet_disabled_menu" /> + android:color="?attr/color_on_main" /> + diff --git a/app/src/main/res/color/tertiary_button_label_color.xml b/app/src/main/res/color/tertiary_button_label_color.xml index 0dfdda8ab7..cbd4ea6416 100644 --- a/app/src/main/res/color/tertiary_button_label_color.xml +++ b/app/src/main/res/color/tertiary_button_label_color.xml @@ -1,6 +1,7 @@ - - + + + diff --git a/app/src/main/res/drawable/account_list_background.xml b/app/src/main/res/drawable/account_list_background.xml new file mode 100644 index 0000000000..af86707b14 --- /dev/null +++ b/app/src/main/res/drawable/account_list_background.xml @@ -0,0 +1,11 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/action_background_bottom.xml b/app/src/main/res/drawable/action_background_bottom.xml index f044482aaa..1d7579047a 100644 --- a/app/src/main/res/drawable/action_background_bottom.xml +++ b/app/src/main/res/drawable/action_background_bottom.xml @@ -4,6 +4,8 @@ android:drawable="@drawable/shape_squircle_main2_100_background_bottom" /> + \ No newline at end of file diff --git a/app/src/main/res/drawable/action_background_full.xml b/app/src/main/res/drawable/action_background_full.xml index 25ac4f2409..45b7089914 100644 --- a/app/src/main/res/drawable/action_background_full.xml +++ b/app/src/main/res/drawable/action_background_full.xml @@ -4,6 +4,8 @@ android:drawable="@drawable/shape_squircle_main2_100_r15_background" /> + \ No newline at end of file diff --git a/app/src/main/res/drawable/action_background_middle.xml b/app/src/main/res/drawable/action_background_middle.xml index fbc9e3cc0d..af86707b14 100644 --- a/app/src/main/res/drawable/action_background_middle.xml +++ b/app/src/main/res/drawable/action_background_middle.xml @@ -4,6 +4,8 @@ android:drawable="@drawable/shape_rectangle_gray_background" /> + \ No newline at end of file diff --git a/app/src/main/res/drawable/action_background_top.xml b/app/src/main/res/drawable/action_background_top.xml index 0c2549c10d..7aa7926d30 100644 --- a/app/src/main/res/drawable/action_background_top.xml +++ b/app/src/main/res/drawable/action_background_top.xml @@ -4,6 +4,8 @@ android:drawable="@drawable/shape_squircle_main2_100_background_top" /> + \ No newline at end of file diff --git a/app/src/main/res/drawable/address_book_fill.xml b/app/src/main/res/drawable/address_book_fill.xml new file mode 100644 index 0000000000..80bd09ea37 --- /dev/null +++ b/app/src/main/res/drawable/address_book_fill.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/arrow_green.xml b/app/src/main/res/drawable/arrow_green.xml deleted file mode 100644 index 27f31c09c8..0000000000 --- a/app/src/main/res/drawable/arrow_green.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - diff --git a/app/src/main/res/drawable/arrow_red.xml b/app/src/main/res/drawable/arrow_red.xml deleted file mode 100644 index 6fd056270e..0000000000 --- a/app/src/main/res/drawable/arrow_red.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - diff --git a/app/src/main/res/drawable/chat_teardrop_dots.xml b/app/src/main/res/drawable/chat_teardrop_dots.xml new file mode 100644 index 0000000000..bcb1be9802 --- /dev/null +++ b/app/src/main/res/drawable/chat_teardrop_dots.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/lock_simple_bold.xml b/app/src/main/res/drawable/chat_teardrop_text_fill.xml similarity index 53% rename from app/src/main/res/drawable/lock_simple_bold.xml rename to app/src/main/res/drawable/chat_teardrop_text_fill.xml index d8cbf80262..901ca36e31 100644 --- a/app/src/main/res/drawable/lock_simple_bold.xml +++ b/app/src/main/res/drawable/chat_teardrop_text_fill.xml @@ -4,6 +4,6 @@ android:viewportWidth="256" android:viewportHeight="256"> diff --git a/app/src/main/res/drawable/circle_light_blue_button_background.xml b/app/src/main/res/drawable/circle_light_blue_button_background.xml index 88b35c53af..88d1b1574c 100644 --- a/app/src/main/res/drawable/circle_light_blue_button_background.xml +++ b/app/src/main/res/drawable/circle_light_blue_button_background.xml @@ -4,6 +4,8 @@ android:drawable="@drawable/shape_circle_light_blue_disabled_background" /> + \ No newline at end of file diff --git a/app/src/main/res/drawable/circle_transparent_button_background.xml b/app/src/main/res/drawable/circle_transparent_button_background.xml new file mode 100644 index 0000000000..902014908c --- /dev/null +++ b/app/src/main/res/drawable/circle_transparent_button_background.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/circle_transparent_dark_button_background.xml b/app/src/main/res/drawable/circle_transparent_dark_button_background.xml new file mode 100644 index 0000000000..54af3cfe30 --- /dev/null +++ b/app/src/main/res/drawable/circle_transparent_dark_button_background.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/circle_transparent_light_button_background.xml b/app/src/main/res/drawable/circle_transparent_light_button_background.xml new file mode 100644 index 0000000000..2957e4ec3c --- /dev/null +++ b/app/src/main/res/drawable/circle_transparent_light_button_background.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/circle_white_button_background.xml b/app/src/main/res/drawable/circle_white_button_background.xml index 83326874b8..5e3a9d11d7 100644 --- a/app/src/main/res/drawable/circle_white_button_background.xml +++ b/app/src/main/res/drawable/circle_white_button_background.xml @@ -4,6 +4,8 @@ android:drawable="@drawable/shape_primary_button_disabled_background" /> + \ No newline at end of file diff --git a/app/src/main/res/drawable/files.xml b/app/src/main/res/drawable/files.xml deleted file mode 100644 index 41d445997c..0000000000 --- a/app/src/main/res/drawable/files.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/in_call_button_background_green.xml b/app/src/main/res/drawable/in_call_button_background_green.xml index 4f8efb4b0b..10f6199170 100644 --- a/app/src/main/res/drawable/in_call_button_background_green.xml +++ b/app/src/main/res/drawable/in_call_button_background_green.xml @@ -6,6 +6,8 @@ android:drawable="@drawable/shape_round_in_call_selected_green_button_background" /> + \ No newline at end of file diff --git a/app/src/main/res/drawable/in_call_button_background_red.xml b/app/src/main/res/drawable/in_call_button_background_red.xml index ae3b842f97..d6bf319d1c 100644 --- a/app/src/main/res/drawable/in_call_button_background_red.xml +++ b/app/src/main/res/drawable/in_call_button_background_red.xml @@ -6,6 +6,8 @@ android:drawable="@drawable/shape_round_in_call_selected_red_button_background" /> + \ No newline at end of file diff --git a/app/src/main/res/drawable/in_call_button_background_transparent.xml b/app/src/main/res/drawable/in_call_button_background_transparent.xml new file mode 100644 index 0000000000..1096ed16d9 --- /dev/null +++ b/app/src/main/res/drawable/in_call_button_background_transparent.xml @@ -0,0 +1,13 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/in_call_numpad_button_background.xml b/app/src/main/res/drawable/in_call_numpad_button_background.xml new file mode 100644 index 0000000000..57ac431b88 --- /dev/null +++ b/app/src/main/res/drawable/in_call_numpad_button_background.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/lock_simple_open.xml b/app/src/main/res/drawable/lock_simple_open.xml new file mode 100644 index 0000000000..0bf8456789 --- /dev/null +++ b/app/src/main/res/drawable/lock_simple_open.xml @@ -0,0 +1,20 @@ + + + + diff --git a/app/src/main/res/drawable/lock_simple_open_bold.xml b/app/src/main/res/drawable/lock_simple_open_bold.xml deleted file mode 100644 index 29f7aea353..0000000000 --- a/app/src/main/res/drawable/lock_simple_open_bold.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/media_player_seekbar.xml b/app/src/main/res/drawable/media_player_seekbar.xml new file mode 100644 index 0000000000..345aae3e19 --- /dev/null +++ b/app/src/main/res/drawable/media_player_seekbar.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/media_player_seekbar_thumb.xml b/app/src/main/res/drawable/media_player_seekbar_thumb.xml new file mode 100644 index 0000000000..22291800fc --- /dev/null +++ b/app/src/main/res/drawable/media_player_seekbar_thumb.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/menu_item_background.xml b/app/src/main/res/drawable/menu_item_background.xml index 72c6a78a24..46c287b5c9 100644 --- a/app/src/main/res/drawable/menu_item_background.xml +++ b/app/src/main/res/drawable/menu_item_background.xml @@ -4,6 +4,8 @@ android:drawable="@drawable/shape_rectangle_gray_background" /> + \ No newline at end of file diff --git a/app/src/main/res/drawable/monitor.xml b/app/src/main/res/drawable/monitor.xml new file mode 100644 index 0000000000..c36f919cb3 --- /dev/null +++ b/app/src/main/res/drawable/monitor.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/phone_fill.xml b/app/src/main/res/drawable/phone_fill.xml new file mode 100644 index 0000000000..b47bd61b76 --- /dev/null +++ b/app/src/main/res/drawable/phone_fill.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/popup_menu_button_background.xml b/app/src/main/res/drawable/popup_menu_button_background.xml new file mode 100644 index 0000000000..3d60bd9aa3 --- /dev/null +++ b/app/src/main/res/drawable/popup_menu_button_background.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/primary_button_background.xml b/app/src/main/res/drawable/primary_button_background.xml index 69ae79fba1..fffc810d2e 100644 --- a/app/src/main/res/drawable/primary_button_background.xml +++ b/app/src/main/res/drawable/primary_button_background.xml @@ -4,6 +4,8 @@ android:drawable="@drawable/shape_primary_button_disabled_background" /> + \ No newline at end of file diff --git a/app/src/main/res/drawable/primary_cell_background.xml b/app/src/main/res/drawable/primary_cell_background.xml index 2f9a9ca585..45386dfcb2 100644 --- a/app/src/main/res/drawable/primary_cell_background.xml +++ b/app/src/main/res/drawable/primary_cell_background.xml @@ -4,6 +4,8 @@ android:drawable="@drawable/color_main2_100" /> + \ No newline at end of file diff --git a/app/src/main/res/drawable/primary_cell_background_alt.xml b/app/src/main/res/drawable/primary_cell_background_alt.xml index 0625945d07..f012c7c4c2 100644 --- a/app/src/main/res/drawable/primary_cell_background_alt.xml +++ b/app/src/main/res/drawable/primary_cell_background_alt.xml @@ -4,6 +4,8 @@ android:drawable="@drawable/color_main2_100" /> + \ No newline at end of file diff --git a/app/src/main/res/drawable/primary_cell_r10_background.xml b/app/src/main/res/drawable/primary_cell_r10_background.xml index e3a99d27e3..7b96644e53 100644 --- a/app/src/main/res/drawable/primary_cell_r10_background.xml +++ b/app/src/main/res/drawable/primary_cell_r10_background.xml @@ -4,6 +4,8 @@ android:drawable="@drawable/shape_squircle_gray_main2_100_r10_background" /> + \ No newline at end of file diff --git a/app/src/main/res/drawable/secondary_button_background.xml b/app/src/main/res/drawable/secondary_button_background.xml index d002e4e5c1..10ac3e62b1 100644 --- a/app/src/main/res/drawable/secondary_button_background.xml +++ b/app/src/main/res/drawable/secondary_button_background.xml @@ -4,6 +4,8 @@ android:drawable="@drawable/shape_secondary_button_disabled_background" /> + \ No newline at end of file diff --git a/app/src/main/res/drawable/secondary_cell_background.xml b/app/src/main/res/drawable/secondary_cell_background.xml index 8d2657fced..ea60a6ac25 100644 --- a/app/src/main/res/drawable/secondary_cell_background.xml +++ b/app/src/main/res/drawable/secondary_cell_background.xml @@ -4,6 +4,8 @@ android:drawable="@drawable/color_main2_100" /> + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_empty.xml b/app/src/main/res/drawable/shape_call_sliding_controls_background.xml similarity index 62% rename from app/src/main/res/drawable/shape_empty.xml rename to app/src/main/res/drawable/shape_call_sliding_controls_background.xml index c3783b5ab3..91323b4f48 100644 --- a/app/src/main/res/drawable/shape_empty.xml +++ b/app/src/main/res/drawable/shape_call_sliding_controls_background.xml @@ -1,4 +1,5 @@ - + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_circle_background.xml b/app/src/main/res/drawable/shape_circle_background.xml new file mode 100644 index 0000000000..2dddedb2ed --- /dev/null +++ b/app/src/main/res/drawable/shape_circle_background.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_circle_dark_pressed_background.xml b/app/src/main/res/drawable/shape_circle_dark_pressed_background.xml new file mode 100644 index 0000000000..f17492a370 --- /dev/null +++ b/app/src/main/res/drawable/shape_circle_dark_pressed_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_circle_light_pressed_background.xml b/app/src/main/res/drawable/shape_circle_light_pressed_background.xml new file mode 100644 index 0000000000..fb7df193e4 --- /dev/null +++ b/app/src/main/res/drawable/shape_circle_light_pressed_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_circle_transparent_background.xml b/app/src/main/res/drawable/shape_circle_transparent_background.xml new file mode 100644 index 0000000000..74724ad757 --- /dev/null +++ b/app/src/main/res/drawable/shape_circle_transparent_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_dark_red_outlined_button_background.xml b/app/src/main/res/drawable/shape_dark_red_outlined_button_background.xml new file mode 100644 index 0000000000..1082d564da --- /dev/null +++ b/app/src/main/res/drawable/shape_dark_red_outlined_button_background.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_desktop_nav_back_background.xml b/app/src/main/res/drawable/shape_desktop_nav_back_background.xml new file mode 100644 index 0000000000..dc26636f75 --- /dev/null +++ b/app/src/main/res/drawable/shape_desktop_nav_back_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_primary_button_disabled_background.xml b/app/src/main/res/drawable/shape_primary_button_disabled_background.xml index 64e0de18ad..c91ceb2477 100644 --- a/app/src/main/res/drawable/shape_primary_button_disabled_background.xml +++ b/app/src/main/res/drawable/shape_primary_button_disabled_background.xml @@ -1,5 +1,6 @@ - + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_primary_button_pressed_background.xml b/app/src/main/res/drawable/shape_primary_button_pressed_background.xml index d6f55a8dda..00e88a9e7c 100644 --- a/app/src/main/res/drawable/shape_primary_button_pressed_background.xml +++ b/app/src/main/res/drawable/shape_primary_button_pressed_background.xml @@ -1,5 +1,6 @@ - + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_red_outlined_button_background.xml b/app/src/main/res/drawable/shape_red_outlined_button_background.xml index a7cb8b9784..a3b7d55c9b 100644 --- a/app/src/main/res/drawable/shape_red_outlined_button_background.xml +++ b/app/src/main/res/drawable/shape_red_outlined_button_background.xml @@ -2,5 +2,5 @@ - + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_squircle_main2_200.xml b/app/src/main/res/drawable/shape_round_popup_menu_pressed_background.xml similarity index 60% rename from app/src/main/res/drawable/shape_squircle_main2_200.xml rename to app/src/main/res/drawable/shape_round_popup_menu_pressed_background.xml index 3dfc17f177..6a04a3d9fa 100644 --- a/app/src/main/res/drawable/shape_squircle_main2_200.xml +++ b/app/src/main/res/drawable/shape_round_popup_menu_pressed_background.xml @@ -1,5 +1,5 @@ - - + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_round_transparent_background.xml b/app/src/main/res/drawable/shape_round_transparent_background.xml new file mode 100644 index 0000000000..b9942d93ef --- /dev/null +++ b/app/src/main/res/drawable/shape_round_transparent_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_secondary_button_disabled_background.xml b/app/src/main/res/drawable/shape_secondary_button_disabled_background.xml index 41e28b7e1a..c91ceb2477 100644 --- a/app/src/main/res/drawable/shape_secondary_button_disabled_background.xml +++ b/app/src/main/res/drawable/shape_secondary_button_disabled_background.xml @@ -1,5 +1,6 @@ - + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_squircle_light_blue_background.xml b/app/src/main/res/drawable/shape_squircle_light_blue_background.xml new file mode 100644 index 0000000000..74239dad33 --- /dev/null +++ b/app/src/main/res/drawable/shape_squircle_light_blue_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_red_button_background.xml b/app/src/main/res/drawable/shape_squircle_numpad_background.xml similarity index 60% rename from app/src/main/res/drawable/shape_red_button_background.xml rename to app/src/main/res/drawable/shape_squircle_numpad_background.xml index e94a9096f7..e0b7f99808 100644 --- a/app/src/main/res/drawable/shape_red_button_background.xml +++ b/app/src/main/res/drawable/shape_squircle_numpad_background.xml @@ -1,5 +1,5 @@ - - + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_squircle_numpad_pressed_background.xml b/app/src/main/res/drawable/shape_squircle_numpad_pressed_background.xml new file mode 100644 index 0000000000..c030117151 --- /dev/null +++ b/app/src/main/res/drawable/shape_squircle_numpad_pressed_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_squircle_gray_200_r15_background.xml b/app/src/main/res/drawable/shape_squircle_transparent_background.xml similarity index 64% rename from app/src/main/res/drawable/shape_squircle_gray_200_r15_background.xml rename to app/src/main/res/drawable/shape_squircle_transparent_background.xml index 2b7fce2504..2f69733cb8 100644 --- a/app/src/main/res/drawable/shape_squircle_gray_200_r15_background.xml +++ b/app/src/main/res/drawable/shape_squircle_transparent_background.xml @@ -1,5 +1,5 @@ - + - + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_squircle_white_border_background.xml b/app/src/main/res/drawable/shape_squircle_white_border_background.xml new file mode 100644 index 0000000000..8584f68279 --- /dev/null +++ b/app/src/main/res/drawable/shape_squircle_white_border_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_tertiary_button_pressed_background.xml b/app/src/main/res/drawable/shape_tertiary_button_pressed_background.xml index 812b57ca43..15b76f8ab7 100644 --- a/app/src/main/res/drawable/shape_tertiary_button_pressed_background.xml +++ b/app/src/main/res/drawable/shape_tertiary_button_pressed_background.xml @@ -1,5 +1,6 @@ - + + \ No newline at end of file diff --git a/app/src/main/res/drawable/squircle_emoji_reaction_button_background.xml b/app/src/main/res/drawable/squircle_emoji_reaction_button_background.xml new file mode 100644 index 0000000000..33400bcc18 --- /dev/null +++ b/app/src/main/res/drawable/squircle_emoji_reaction_button_background.xml @@ -0,0 +1,11 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/squircle_green_button_background.xml b/app/src/main/res/drawable/squircle_green_button_background.xml index 34552986ee..d002cf8dc6 100644 --- a/app/src/main/res/drawable/squircle_green_button_background.xml +++ b/app/src/main/res/drawable/squircle_green_button_background.xml @@ -4,6 +4,8 @@ android:drawable="@drawable/shape_squircle_green_disabled_background" /> + \ No newline at end of file diff --git a/app/src/main/res/drawable/squircle_red_button_background.xml b/app/src/main/res/drawable/squircle_red_button_background.xml index 0669a1a3eb..a7a250075b 100644 --- a/app/src/main/res/drawable/squircle_red_button_background.xml +++ b/app/src/main/res/drawable/squircle_red_button_background.xml @@ -4,6 +4,8 @@ android:drawable="@drawable/shape_squircle_red_disabled_background" /> + \ No newline at end of file diff --git a/app/src/main/res/drawable/squircle_red_outlined_button_background.xml b/app/src/main/res/drawable/squircle_red_outlined_button_background.xml new file mode 100644 index 0000000000..622ab215f2 --- /dev/null +++ b/app/src/main/res/drawable/squircle_red_outlined_button_background.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/squircle_transparent_button_background.xml b/app/src/main/res/drawable/squircle_transparent_button_background.xml new file mode 100644 index 0000000000..d089794b3e --- /dev/null +++ b/app/src/main/res/drawable/squircle_transparent_button_background.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/squircle_transparent_button_background_2.xml b/app/src/main/res/drawable/squircle_transparent_button_background_2.xml new file mode 100644 index 0000000000..d21a76e33c --- /dev/null +++ b/app/src/main/res/drawable/squircle_transparent_button_background_2.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/tertiary_button_background.xml b/app/src/main/res/drawable/tertiary_button_background.xml index 2c40a0d99f..9f96c0943a 100644 --- a/app/src/main/res/drawable/tertiary_button_background.xml +++ b/app/src/main/res/drawable/tertiary_button_background.xml @@ -4,6 +4,8 @@ android:drawable="@drawable/shape_tertiary_button_disabled_background" /> + \ No newline at end of file diff --git a/app/src/main/res/drawable/transparent_background.xml b/app/src/main/res/drawable/transparent_background.xml new file mode 100644 index 0000000000..c6c6f2da1a --- /dev/null +++ b/app/src/main/res/drawable/transparent_background.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/trash.xml b/app/src/main/res/drawable/trash.xml new file mode 100644 index 0000000000..1a17130c7e --- /dev/null +++ b/app/src/main/res/drawable/trash.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/video_conference_fill.xml b/app/src/main/res/drawable/video_conference_fill.xml new file mode 100644 index 0000000000..b738875f10 --- /dev/null +++ b/app/src/main/res/drawable/video_conference_fill.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/voice_recording_gradient_progress.xml b/app/src/main/res/drawable/voice_recording_gradient_progress.xml index 41d9172f99..75d088561f 100644 --- a/app/src/main/res/drawable/voice_recording_gradient_progress.xml +++ b/app/src/main/res/drawable/voice_recording_gradient_progress.xml @@ -5,7 +5,7 @@ diff --git a/app/src/main/res/layout-land/bottom_nav_bar.xml b/app/src/main/res/layout-land/bottom_nav_bar.xml index 8ddb35647c..4c0a561013 100644 --- a/app/src/main/res/layout-land/bottom_nav_bar.xml +++ b/app/src/main/res/layout-land/bottom_nav_bar.xml @@ -15,6 +15,7 @@ android:id="@+id/bottom_nav_bar" android:layout_width="@dimen/landscape_nav_bar_width" android:layout_height="match_parent" + android:elevation="16dp" android:background="?attr/color_background_contrast_in_dark_mode"> @@ -219,6 +220,7 @@ android:layout_height="wrap_content" android:paddingBottom="15dp" android:text="@string/call_action_start_new_call" + android:labelFor="@id/new_call" app:layout_constraintEnd_toStartOf="@id/calls_list_label" app:layout_constraintStart_toEndOf="@id/transfer_label" app:layout_constraintTop_toBottomOf="@id/new_call"/> @@ -231,6 +233,7 @@ android:layout_height="wrap_content" android:paddingBottom="15dp" android:text="@string/call_action_go_to_calls_list" + android:labelFor="@id/calls_list" app:layout_constraintEnd_toStartOf="@id/numpad_label" app:layout_constraintStart_toEndOf="@id/new_call_label" app:layout_constraintTop_toBottomOf="@id/calls_list"/> @@ -244,6 +247,7 @@ android:layout_height="wrap_content" android:paddingBottom="15dp" android:text="@string/call_action_show_dialer" + android:labelFor="@id/numpad" app:layout_constraintEnd_toStartOf="@id/chat_label" app:layout_constraintStart_toEndOf="@id/calls_list_label" app:layout_constraintTop_toBottomOf="@id/numpad" /> @@ -256,6 +260,7 @@ android:layout_height="wrap_content" android:paddingBottom="15dp" android:text="@string/call_action_show_messages" + android:labelFor="@id/chat" android:enabled="@{!viewModel.operationInProgress}" app:layout_constraintEnd_toStartOf="@id/pause_call_label" app:layout_constraintStart_toEndOf="@id/numpad_label" @@ -271,6 +276,7 @@ android:enabled="@{viewModel.canBePaused}" android:selected="@{viewModel.isPaused}" android:text="@{viewModel.isPaused ? @string/call_action_resume_call : @string/call_action_pause_call, default=@string/call_action_pause_call}" + android:labelFor="@id/pause_call" app:layout_constraintEnd_toStartOf="@id/record_call_label" app:layout_constraintStart_toEndOf="@id/chat_label" app:layout_constraintTop_toBottomOf="@id/pause_call" /> @@ -284,6 +290,7 @@ android:layout_height="wrap_content" android:paddingBottom="15dp" android:text="@string/call_action_record_call" + android:labelFor="@id/record_call" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toEndOf="@id/pause_call_label" app:layout_constraintTop_toBottomOf="@id/record_call" /> diff --git a/app/src/main/res/layout-land/call_active_fragment.xml b/app/src/main/res/layout-land/call_active_fragment.xml index b59efee5d2..f1ac7d1be9 100644 --- a/app/src/main/res/layout-land/call_active_fragment.xml +++ b/app/src/main/res/layout-land/call_active_fragment.xml @@ -100,6 +100,7 @@ android:id="@+id/address" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:visibility="@{viewModel.hideSipAddresses ? View.GONE : View.VISIBLE}" android:text="@{viewModel.displayedAddress, default=`sip:johndoe@sip.linphone.org`}" android:textColor="@color/bc_white" android:textSize="14sp" /> @@ -134,6 +135,7 @@ android:adjustViewBounds="true" android:padding="15dp" android:src="@drawable/caret_left" + android:background="@drawable/circle_transparent_dark_button_background" android:contentDescription="@string/content_description_go_back_icon" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/hinge_top" @@ -154,7 +156,7 @@ app:layout_constraintStart_toEndOf="@id/back" app:layout_constraintEnd_toStartOf="@id/separator" app:layout_constraintTop_toTopOf="@id/back" - app:layout_constraintBottom_toBottomOf="@id/back"/> + app:layout_constraintBottom_toTopOf="@id/call_media_encryption_info"/> @@ -202,9 +205,12 @@ android:layout_marginStart="5dp" android:layout_marginEnd="5dp" android:src="@drawable/camera_rotate" + android:adjustViewBounds="true" android:contentDescription="@string/content_description_change_camera" + android:background="@drawable/circle_transparent_dark_button_background" android:visibility="@{!viewModel.fullScreenMode && !viewModel.pipMode && viewModel.isSendingVideo && viewModel.showSwitchCamera ? View.VISIBLE : View.GONE}" app:tint="@color/bc_white" + app:layout_constraintDimensionRatio="1:1" app:layout_constraintTop_toTopOf="@id/back" app:layout_constraintBottom_toBottomOf="@id/back" app:layout_constraintEnd_toStartOf="@+id/paused" /> @@ -214,10 +220,14 @@ android:layout_width="@dimen/big_icon_size" android:layout_height="0dp" android:padding="10dp" + android:layout_marginStart="5dp" + android:layout_marginEnd="5dp" android:src="@drawable/pause" + android:adjustViewBounds="true" android:contentDescription="@string/content_description_paused_call" android:visibility="@{!viewModel.fullScreenMode && !viewModel.pipMode && (viewModel.isPaused || viewModel.isPausedByRemote) ? View.VISIBLE : View.GONE, default=gone}" app:tint="?attr/color_main1_500" + app:layout_constraintDimensionRatio="1:1" app:layout_constraintTop_toTopOf="@id/back" app:layout_constraintBottom_toBottomOf="@id/back" app:layout_constraintEnd_toStartOf="@id/stats" /> @@ -229,9 +239,12 @@ android:layout_height="0dp" android:padding="10dp" android:src="@{viewModel.qualityIcon, default=@drawable/cell_signal_full}" + android:adjustViewBounds="true" android:contentDescription="@string/content_description_show_call_statistics" + android:background="@drawable/circle_transparent_dark_button_background" android:visibility="@{!viewModel.fullScreenMode && !viewModel.pipMode && !(viewModel.isPaused || viewModel.isPausedByRemote) ? View.VISIBLE : View.GONE}" android:tint="@{viewModel.qualityValue >= 2 ? @color/bc_white : viewModel.qualityValue >= 1 ? @color/orange_warning_600 : @color/red_danger_500, default=@color/bc_white}" + app:layout_constraintDimensionRatio="1:1" app:layout_constraintTop_toTopOf="@id/back" app:layout_constraintBottom_toBottomOf="@id/back" app:layout_constraintEnd_toEndOf="parent" @@ -245,6 +258,7 @@ bind:viewModel="@{viewModel}" bind:callMediaEncryptionStatisticsClickListener="@{callMediaEncryptionStatisticsClickListener}" app:layout_constraintTop_toBottomOf="@id/name" + app:layout_constraintBottom_toBottomOf="@id/back" app:layout_constraintStart_toStartOf="@id/name" app:layout_constraintEnd_toEndOf="parent"/> @@ -290,6 +304,7 @@ @@ -200,6 +201,7 @@ android:layout_height="wrap_content" android:paddingBottom="15dp" android:ellipsize="middle" + android:labelFor="@id/participants" android:text="@{viewModel.conferenceModel.participantsLabel, default=@string/conference_action_show_participants}" app:layout_constraintTop_toBottomOf="@id/participants" app:layout_constraintStart_toEndOf="@id/screen_sharing_label" @@ -213,6 +215,7 @@ android:layout_height="wrap_content" android:paddingBottom="15dp" android:text="@string/call_action_go_to_calls_list" + android:labelFor="@id/calls_list" app:layout_constraintTop_toBottomOf="@id/calls_list" app:layout_constraintStart_toEndOf="@id/participants_label" app:layout_constraintEnd_toStartOf="@id/layout_label" /> @@ -225,6 +228,7 @@ android:layout_height="wrap_content" android:paddingBottom="15dp" android:text="@string/call_action_change_layout" + android:labelFor="@id/layout" app:layout_constraintTop_toBottomOf="@id/layout" app:layout_constraintStart_toEndOf="@id/calls_list_label" app:layout_constraintEnd_toStartOf="@id/chat_label" /> @@ -237,6 +241,7 @@ android:layout_height="wrap_content" android:paddingBottom="15dp" android:text="@string/call_action_show_messages" + android:labelFor="@id/chat" android:enabled="@{viewModel.conferenceModel.isConversationAvailable}" app:layout_constraintTop_toBottomOf="@id/chat" app:layout_constraintStart_toEndOf="@id/layout_label" @@ -251,6 +256,7 @@ android:enabled="@{viewModel.canBePaused}" android:selected="@{viewModel.isPaused}" android:text="@{viewModel.isPaused ? @string/call_action_resume_call : @string/call_action_pause_call, default=@string/call_action_pause_call}" + android:labelFor="@id/pause_call" app:layout_constraintTop_toBottomOf="@id/pause_call" app:layout_constraintStart_toEndOf="@id/chat_label" app:layout_constraintEnd_toStartOf="@id/record_call_label" /> @@ -262,6 +268,7 @@ android:layout_height="wrap_content" android:paddingBottom="15dp" android:text="@string/call_action_record_call" + android:labelFor="@id/record_call" android:enabled="false" app:layout_constraintTop_toBottomOf="@id/record_call" app:layout_constraintStart_toEndOf="@id/pause_call_label" diff --git a/app/src/main/res/layout-land/call_ended_fragment.xml b/app/src/main/res/layout-land/call_ended_fragment.xml index 519db770bd..bb6b3b2087 100644 --- a/app/src/main/res/layout-land/call_ended_fragment.xml +++ b/app/src/main/res/layout-land/call_ended_fragment.xml @@ -21,16 +21,16 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginStart="10dp" - android:layout_marginTop="10dp" android:text="@{viewModel.terminatedByUser ? @string/call_locally_ended : @string/call_remotely_ended, default=@string/call_locally_ended}" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent"/> + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toTopOf="@id/call_media_encryption_info" /> + android:textSize="14sp" + android:visibility="@{viewModel.hideSipAddresses ? View.GONE : View.VISIBLE}" /> diff --git a/app/src/main/res/layout-land/call_incoming_fragment.xml b/app/src/main/res/layout-land/call_incoming_fragment.xml index a78ff719d9..6f15737941 100644 --- a/app/src/main/res/layout-land/call_incoming_fragment.xml +++ b/app/src/main/res/layout-land/call_incoming_fragment.xml @@ -54,12 +54,12 @@ + android:textSize="14sp" + android:visibility="@{viewModel.hideSipAddresses || viewModel.conferenceModel.isCurrentCallInConference ? View.GONE : View.VISIBLE}" /> @@ -102,8 +102,6 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginStart="10dp" - android:layout_marginTop="10dp" - android:layout_marginBottom="10dp" android:text="@{viewModel.incomingCallTitle, default=@string/call_video_incoming}" app:layout_constraintStart_toEndOf="@id/call_direction_icon" app:layout_constraintTop_toTopOf="parent"/> diff --git a/app/src/main/res/layout-land/call_media_encryption_stats_bottom_sheet.xml b/app/src/main/res/layout-land/call_media_encryption_stats_bottom_sheet.xml index ec5cc2e901..6b43d9184c 100644 --- a/app/src/main/res/layout-land/call_media_encryption_stats_bottom_sheet.xml +++ b/app/src/main/res/layout-land/call_media_encryption_stats_bottom_sheet.xml @@ -24,7 +24,7 @@ app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior"> @@ -138,12 +138,13 @@ + android:visibility="@{model.isMediaEncryptionZrtp && !viewModel.conferenceModel.isCurrentCallInConference() ? View.VISIBLE : View.GONE}" + app:layout_columnSpan="2" + app:layout_gravity="center_horizontal"/> diff --git a/app/src/main/res/layout-land/call_numpad_bottom_sheet.xml b/app/src/main/res/layout-land/call_numpad_bottom_sheet.xml index bb8a340db4..ca38d7b800 100644 --- a/app/src/main/res/layout-land/call_numpad_bottom_sheet.xml +++ b/app/src/main/res/layout-land/call_numpad_bottom_sheet.xml @@ -11,6 +11,9 @@ + diff --git a/app/src/main/res/layout-land/call_outgoing_fragment.xml b/app/src/main/res/layout-land/call_outgoing_fragment.xml index 808ffb6f68..8db5468a42 100644 --- a/app/src/main/res/layout-land/call_outgoing_fragment.xml +++ b/app/src/main/res/layout-land/call_outgoing_fragment.xml @@ -40,8 +40,6 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginStart="10dp" - android:layout_marginTop="10dp" - android:layout_marginBottom="10dp" android:text="@string/call_outgoing" app:layout_constraintStart_toEndOf="@id/call_direction_icon" app:layout_constraintTop_toTopOf="parent"/> @@ -83,12 +81,12 @@ + android:textSize="14sp" + android:visibility="@{viewModel.hideSipAddresses ? View.GONE : View.VISIBLE}" /> @@ -119,6 +117,7 @@ app:layout_constraintTop_toTopOf="@id/name_address" app:layout_constraintBottom_toBottomOf="@id/name_address" /> + + + + + + + + + - - + app:layout_constraintTop_toBottomOf="@id/top_bar" + app:layout_constraintBottom_toTopOf="@id/no_conversation_label" /> + app:layout_constraintTop_toBottomOf="@id/no_conversation_image" + app:layout_constraintBottom_toTopOf="@id/show_accounts_list"/> + + + + diff --git a/app/src/main/res/layout-land/contact_new_or_edit_fragment.xml b/app/src/main/res/layout-land/contact_new_or_edit_fragment.xml index b517e027ee..b569fae36b 100644 --- a/app/src/main/res/layout-land/contact_new_or_edit_fragment.xml +++ b/app/src/main/res/layout-land/contact_new_or_edit_fragment.xml @@ -25,15 +25,13 @@ android:background="?attr/color_background_contrast_in_dark_mode"> - - - - - - - - - - - - - - + - - + android:paddingBottom="@dimen/screen_bottom_margin"> - - - - - - - - - + + + + + + + + + + + + + + - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + - - - - + android:background="@drawable/shape_squircle_white_background" + app:layout_constraintWidth_max="@dimen/section_max_width" + app:layout_constraintTop_toBottomOf="@id/avatar_content" + app:layout_constraintStart_toEndOf="@id/left_column" + app:layout_constraintEnd_toEndOf="parent"> + + + + + + + + + + - + - + @@ -103,11 +104,32 @@ android:layout_marginTop="16dp" android:text="@string/contacts_list_all_contacts_title"/> - + + + android:layout_marginTop="4dp"> + + + + @@ -128,10 +150,11 @@ @@ -154,6 +177,7 @@ android:background="@drawable/secondary_button_background" android:visibility="@{viewModel.contactsList.empty && !viewModel.areAllContactsDisplayed ? View.VISIBLE : View.GONE}" app:layout_constraintTop_toBottomOf="@id/no_contacts_label" + app:layout_constraintBottom_toTopOf="@id/lists" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toEndOf="@id/bottom_nav_bar"/> @@ -178,12 +202,20 @@ android:layout_margin="16dp" android:src="@drawable/user_plus" android:contentDescription="@string/content_description_contact_create" + android:visibility="@{viewModel.disableAddContact ? View.GONE : View.VISIBLE}" app:tint="?attr/color_on_main" app:backgroundTint="?attr/color_main1_500" app:shapeAppearanceOverlay="@style/rounded" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintBottom_toBottomOf="parent" /> + + diff --git a/app/src/main/res/layout-land/dialog_zrtp_sas_validation.xml b/app/src/main/res/layout-land/dialog_zrtp_sas_validation.xml index c0e819ce3f..fea8ed62ad 100644 --- a/app/src/main/res/layout-land/dialog_zrtp_sas_validation.xml +++ b/app/src/main/res/layout-land/dialog_zrtp_sas_validation.xml @@ -66,6 +66,7 @@ android:text="@string/call_zrtp_sas_validation_skip" android:textSize="13sp" android:textColor="@color/bc_white" + android:background="@drawable/squircle_transparent_button_background" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="parent"/> @@ -119,8 +120,9 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@{viewModel.localToken, default=`ZZ`}" - android:textSize="18sp" + android:textSize="32sp" android:textColor="@color/gray_main2_600" + android:includeFontPadding="false" app:layout_constraintStart_toStartOf="@id/local_code_label" app:layout_constraintEnd_toEndOf="@id/local_code_label" app:layout_constraintTop_toBottomOf="@id/local_code_label" @@ -236,23 +238,24 @@ android:onClick="@{() -> viewModel.notFound()}" style="@style/default_text_style_600" android:id="@+id/nothing_matches" - android:layout_width="0dp" + android:layout_width="wrap_content" android:layout_height="wrap_content" + android:paddingStart="15dp" + android:paddingEnd="15dp" android:layout_marginTop="15dp" android:layout_marginBottom="15dp" - android:layout_marginStart="30dp" - android:layout_marginEnd="30dp" + android:layout_marginEnd="16dp" android:paddingBottom="@dimen/primary_secondary_buttons_label_padding" android:paddingTop="@dimen/primary_secondary_buttons_label_padding" android:gravity="center" - android:background="@drawable/shape_red_outlined_button_background" + android:background="@drawable/squircle_red_outlined_button_background" android:text="@string/call_dialog_zrtp_validate_trust_letters_do_not_match" - android:textSize="18sp" + android:textSize="13sp" android:textColor="?attr/color_danger_500" android:maxLines="1" android:ellipsize="end" + app:layout_constrainedWidth="true" app:layout_constraintWidth_max="@dimen/button_max_width" - app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@id/remote_tokens" app:layout_constraintBottom_toBottomOf="parent"/> diff --git a/app/src/main/res/layout-land/dialog_zrtp_security_alert.xml b/app/src/main/res/layout-land/dialog_zrtp_security_alert.xml index c8ba7cf0a7..9caca0a26e 100644 --- a/app/src/main/res/layout-land/dialog_zrtp_security_alert.xml +++ b/app/src/main/res/layout-land/dialog_zrtp_security_alert.xml @@ -112,23 +112,24 @@ style="@style/default_text_style_600" android:id="@+id/hang_up" android:onClick="@{() -> viewModel.hangUp()}" - android:layout_width="0dp" + android:layout_width="wrap_content" android:layout_height="wrap_content" + android:paddingStart="15dp" + android:paddingEnd="15dp" android:layout_marginTop="15dp" android:layout_marginBottom="15dp" - android:layout_marginStart="30dp" - android:layout_marginEnd="30dp" + android:layout_marginEnd="16dp" android:paddingBottom="@dimen/primary_secondary_buttons_label_padding" android:paddingTop="@dimen/primary_secondary_buttons_label_padding" android:gravity="center" - android:background="@drawable/shape_red_button_background" + android:background="@drawable/squircle_red_button_background" android:text="@string/call_action_hang_up" - android:textSize="18sp" + android:textSize="13sp" android:textColor="@color/bc_white" android:maxLines="1" android:ellipsize="end" + app:layout_constrainedWidth="true" app:layout_constraintWidth_max="@dimen/button_max_width" - app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@id/try_again" app:layout_constraintBottom_toBottomOf="parent"/> diff --git a/app/src/main/res/layout-land/history_list_fragment.xml b/app/src/main/res/layout-land/history_list_fragment.xml index e279286d03..a17331ce5d 100644 --- a/app/src/main/res/layout-land/history_list_fragment.xml +++ b/app/src/main/res/layout-land/history_list_fragment.xml @@ -12,7 +12,7 @@ name="onConversationsClicked" type="View.OnClickListener" /> - - + app:layout_constraintTop_toBottomOf="@id/top_bar" /> + app:layout_constraintTop_toBottomOf="@id/no_calls_image" + app:layout_constraintBottom_toTopOf="@id/show_accounts_list"/> + + + + diff --git a/app/src/main/res/layout-land/main_activity_top_bar.xml b/app/src/main/res/layout-land/main_activity_top_bar.xml index b7191466ec..c348e3f083 100644 --- a/app/src/main/res/layout-land/main_activity_top_bar.xml +++ b/app/src/main/res/layout-land/main_activity_top_bar.xml @@ -6,7 +6,7 @@ + @@ -47,25 +50,23 @@ app:barrierDirection="bottom" /> + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toBottomOf="parent" /> + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toBottomOf="parent" /> + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toBottomOf="parent" /> + app:layout_constraintTop_toTopOf="@id/search" /> + app:layout_constraintTop_toTopOf="@id/search" /> diff --git a/app/src/main/res/layout-land/meeting_waiting_room_fragment.xml b/app/src/main/res/layout-land/meeting_waiting_room_fragment.xml index 100d1974ec..6191fed25c 100644 --- a/app/src/main/res/layout-land/meeting_waiting_room_fragment.xml +++ b/app/src/main/res/layout-land/meeting_waiting_room_fragment.xml @@ -26,12 +26,11 @@ app:layout_constraintGuide_percent="0.50" /> + + - - + + + + + + + bind:visibility="@{viewModel.cancelMeetingViewModel.operationInProgress}" /> diff --git a/app/src/main/res/layout-land/welcome_activity.xml b/app/src/main/res/layout-land/welcome_activity.xml index e6daa7030f..4e8860346b 100644 --- a/app/src/main/res/layout-land/welcome_activity.xml +++ b/app/src/main/res/layout-land/welcome_activity.xml @@ -99,8 +99,6 @@ android:layout_marginStart="16dp" android:layout_marginEnd="16dp" android:layout_marginBottom="16dp" - android:paddingStart="20dp" - android:paddingEnd="20dp" android:text="@string/next" app:layout_constraintWidth_max="@dimen/button_max_width" app:layout_constraintTop_toBottomOf="@id/dots_indicator" diff --git a/app/src/main/res/layout-sw600dp-land/bottom_nav_bar.xml b/app/src/main/res/layout-sw600dp-land/bottom_nav_bar.xml new file mode 100644 index 0000000000..ee930d4750 --- /dev/null +++ b/app/src/main/res/layout-sw600dp-land/bottom_nav_bar.xml @@ -0,0 +1,154 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout-sw600dp-land/main_activity_top_bar.xml b/app/src/main/res/layout-sw600dp-land/main_activity_top_bar.xml new file mode 100644 index 0000000000..0060fc1c63 --- /dev/null +++ b/app/src/main/res/layout-sw600dp-land/main_activity_top_bar.xml @@ -0,0 +1,173 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout-sw600dp-land/sliding_pane_separator.xml b/app/src/main/res/layout-sw600dp-land/sliding_pane_separator.xml new file mode 100644 index 0000000000..84f5de7608 --- /dev/null +++ b/app/src/main/res/layout-sw600dp-land/sliding_pane_separator.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/layout-sw600dp/assistant_landing_fragment.xml b/app/src/main/res/layout-sw600dp/assistant_landing_fragment.xml index cbb0b1ebd4..8e5c02417a 100644 --- a/app/src/main/res/layout-sw600dp/assistant_landing_fragment.xml +++ b/app/src/main/res/layout-sw600dp/assistant_landing_fragment.xml @@ -1,6 +1,7 @@ + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:bind="http://schemas.android.com/tools"> @@ -28,280 +29,288 @@ type="org.linphone.ui.assistant.viewmodel.AccountLoginViewModel" /> - - + android:layout_height="match_parent" + android:fillViewport="true"> - + - - - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout-sw600dp/assistant_permissions_fragment.xml b/app/src/main/res/layout-sw600dp/assistant_permissions_fragment.xml index 87e0400c79..159807eaa4 100644 --- a/app/src/main/res/layout-sw600dp/assistant_permissions_fragment.xml +++ b/app/src/main/res/layout-sw600dp/assistant_permissions_fragment.xml @@ -47,6 +47,7 @@ android:padding="15dp" android:src="@drawable/caret_left" android:contentDescription="@string/content_description_go_back_icon" + android:background="@drawable/circle_transparent_button_background" app:tint="?attr/color_main2_500" app:layout_constraintTop_toTopOf="@id/title" app:layout_constraintStart_toStartOf="parent" @@ -213,8 +214,6 @@ android:layout_marginStart="16dp" android:layout_marginEnd="16dp" android:layout_marginTop="32dp" - android:paddingStart="20dp" - android:paddingEnd="20dp" android:text="@string/assistant_permissions_skip_permissions" app:layout_constraintWidth_max="@dimen/button_max_width" app:layout_constraintVertical_bias="1" @@ -232,8 +231,6 @@ android:layout_marginStart="16dp" android:layout_marginEnd="16dp" android:layout_marginTop="16dp" - android:paddingStart="20dp" - android:paddingEnd="20dp" android:text="@string/assistant_permissions_grant_all_of_them" app:layout_constraintWidth_max="@dimen/button_max_width" app:layout_constraintTop_toBottomOf="@id/skip" diff --git a/app/src/main/res/layout-sw600dp/assistant_register_confirm_sms_code_fragment.xml b/app/src/main/res/layout-sw600dp/assistant_register_confirm_sms_code_fragment.xml index 6f3941395d..104d30f0bf 100644 --- a/app/src/main/res/layout-sw600dp/assistant_register_confirm_sms_code_fragment.xml +++ b/app/src/main/res/layout-sw600dp/assistant_register_confirm_sms_code_fragment.xml @@ -44,6 +44,7 @@ android:padding="15dp" android:src="@drawable/caret_left" android:contentDescription="@string/content_description_go_back_icon" + android:background="@drawable/circle_transparent_button_background" android:visibility="invisible" app:tint="?attr/color_main2_500" app:layout_constraintTop_toTopOf="@id/title" diff --git a/app/src/main/res/layout-sw600dp/assistant_register_fragment.xml b/app/src/main/res/layout-sw600dp/assistant_register_fragment.xml index b427549722..2bbc5fae84 100644 --- a/app/src/main/res/layout-sw600dp/assistant_register_fragment.xml +++ b/app/src/main/res/layout-sw600dp/assistant_register_fragment.xml @@ -71,6 +71,7 @@ android:padding="15dp" android:src="@drawable/caret_left" android:contentDescription="@string/content_description_go_back_icon" + android:background="@drawable/circle_transparent_button_background" app:tint="?attr/color_main2_500" app:layout_constraintTop_toTopOf="@id/title" app:layout_constraintStart_toStartOf="parent" @@ -152,6 +153,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:layout_marginTop="16dp" + android:labelFor="@id/username" android:text="@{@string/username + `*`}" app:layout_constraintVertical_chainStyle="packed" app:layout_constraintTop_toBottomOf="@id/title" @@ -201,6 +203,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:layout_marginTop="16dp" + android:labelFor="@id/phone_number" android:text="@{@string/phone_number + `*`}" app:layout_constraintTop_toBottomOf="@id/username_error" app:layout_constraintBottom_toTopOf="@id/phone_number" @@ -291,6 +294,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:layout_marginTop="16dp" + android:labelFor="@id/password" android:text="@{@string/password + `*`}" app:layout_constraintTop_toBottomOf="@id/phone_number_error" app:layout_constraintBottom_toTopOf="@id/password" @@ -420,8 +424,6 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="40dp" - android:paddingStart="20dp" - android:paddingEnd="20dp" android:text="@string/assistant_account_login" app:layout_constraintVertical_bias="1" app:layout_constraintEnd_toEndOf="parent" diff --git a/app/src/main/res/layout-sw600dp/assistant_third_party_sip_account_login_fragment.xml b/app/src/main/res/layout-sw600dp/assistant_third_party_sip_account_login_fragment.xml index 19598da8d7..812c628128 100644 --- a/app/src/main/res/layout-sw600dp/assistant_third_party_sip_account_login_fragment.xml +++ b/app/src/main/res/layout-sw600dp/assistant_third_party_sip_account_login_fragment.xml @@ -1,6 +1,7 @@ + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:bind="http://schemas.android.com/tools"> @@ -8,381 +9,447 @@ + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:layout_height="match_parent" + android:fillViewport="true"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout-sw600dp/assistant_third_party_sip_account_warning_fragment.xml b/app/src/main/res/layout-sw600dp/assistant_third_party_sip_account_warning_fragment.xml index 50ccdbef0b..580007b650 100644 --- a/app/src/main/res/layout-sw600dp/assistant_third_party_sip_account_warning_fragment.xml +++ b/app/src/main/res/layout-sw600dp/assistant_third_party_sip_account_warning_fragment.xml @@ -51,6 +51,7 @@ android:padding="15dp" android:src="@drawable/caret_left" android:contentDescription="@string/content_description_go_back_icon" + android:background="@drawable/circle_transparent_button_background" app:tint="?attr/color_main2_500" app:layout_constraintTop_toTopOf="@id/title" app:layout_constraintStart_toStartOf="parent" @@ -147,8 +148,6 @@ android:layout_marginTop="32dp" android:layout_marginStart="16dp" android:layout_marginEnd="16dp" - android:paddingStart="20dp" - android:paddingEnd="20dp" android:text="@string/assistant_third_party_sip_account_create_linphone_account" app:layout_constraintWidth_max="@dimen/button_max_width" app:layout_constraintVertical_bias="1" @@ -166,8 +165,6 @@ android:layout_marginTop="16dp" android:layout_marginStart="16dp" android:layout_marginEnd="16dp" - android:paddingStart="20dp" - android:paddingEnd="20dp" android:text="@string/assistant_third_party_sip_account_warning_ok" app:layout_constraintWidth_max="@dimen/button_max_width" app:layout_constraintTop_toBottomOf="@id/create_linphone_account" diff --git a/app/src/main/res/layout-sw600dp/welcome_activity.xml b/app/src/main/res/layout-sw600dp/welcome_activity.xml index 4ae60e3888..7124a3715f 100644 --- a/app/src/main/res/layout-sw600dp/welcome_activity.xml +++ b/app/src/main/res/layout-sw600dp/welcome_activity.xml @@ -101,8 +101,6 @@ android:layout_marginStart="16dp" android:layout_marginEnd="16dp" android:layout_marginBottom="16dp" - android:paddingStart="20dp" - android:paddingEnd="20dp" android:text="@string/next" app:layout_constraintWidth_max="@dimen/button_max_width" app:layout_constraintTop_toBottomOf="@id/dots_indicator" diff --git a/app/src/main/res/layout/account_advanced_settings.xml b/app/src/main/res/layout/account_advanced_settings.xml index 4151858ae5..cb40b556e4 100644 --- a/app/src/main/res/layout/account_advanced_settings.xml +++ b/app/src/main/res/layout/account_advanced_settings.xml @@ -8,6 +8,9 @@ + @@ -19,48 +22,7 @@ android:background="@drawable/shape_squircle_white_background"> - - - - - - @@ -87,35 +50,59 @@ android:text="@={viewModel.sipProxyServer}" android:inputType="text|textUri" android:hint="@string/account_settings_sip_proxy_url_title" + app:layout_constraintHorizontal_bias="0" + app:layout_constraintWidth_max="@dimen/text_input_max_width" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@id/sip_proxy_title"/> - - + app:layout_constraintEnd_toEndOf="parent"/> + + + + + app:layout_constraintTop_toBottomOf="@id/outbound_proxy" /> @@ -225,12 +216,14 @@ android:text="@={viewModel.expire}" android:inputType="number" android:hint="@string/account_settings_expire_title" + app:layout_constraintHorizontal_bias="0" + app:layout_constraintWidth_max="@dimen/text_input_max_width" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@id/expire_title"/> @@ -258,12 +252,14 @@ android:text="@={viewModel.conferenceFactoryUri}" android:inputType="text|textUri" android:hint="@string/account_settings_conference_factory_uri_title" + app:layout_constraintHorizontal_bias="0" + app:layout_constraintWidth_max="@dimen/text_input_max_width" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@id/conference_factory_uri_title"/> @@ -290,12 +287,14 @@ android:text="@={viewModel.audioVideoConferenceFactoryUri}" android:inputType="text|textUri" android:hint="@string/account_settings_audio_video_conference_factory_uri_title" + app:layout_constraintHorizontal_bias="0" + app:layout_constraintWidth_max="@dimen/text_input_max_width" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@id/audio_video_conference_factory_uri_title"/> @@ -322,12 +322,14 @@ android:text="@={viewModel.ccmpServerUrl}" android:inputType="text|textUri" android:hint="@string/account_settings_ccmp_server_url_title" + app:layout_constraintHorizontal_bias="0" + app:layout_constraintWidth_max="@dimen/text_input_max_width" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@id/ccmp_server_url_title"/> @@ -355,10 +361,51 @@ android:text="@={viewModel.limeServerUrl}" android:inputType="text|textUri" android:hint="@string/account_settings_lime_server_url_title" + app:layout_constraintHorizontal_bias="0" + app:layout_constraintWidth_max="@dimen/text_input_max_width" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@id/lime_server_title" - app:layout_constraintBottom_toTopOf="@id/update_password"/> + app:layout_constraintBottom_toTopOf="@id/lime_algo_title"/> + + + + diff --git a/app/src/main/res/layout/account_list_cell.xml b/app/src/main/res/layout/account_list_cell.xml index 96ad6242cf..70534a7d5f 100644 --- a/app/src/main/res/layout/account_list_cell.xml +++ b/app/src/main/res/layout/account_list_cell.xml @@ -15,7 +15,8 @@ android:onClick="@{() -> model.setAsDefault()}" android:layout_width="match_parent" android:layout_height="wrap_content" - android:background="@{model.isDefault ? @drawable/color_main2_100 : @drawable/color_main2_000, default=@drawable/color_main2_100}"> + android:selected="@{model.isDefault}" + android:background="@drawable/account_list_background"> diff --git a/app/src/main/res/layout/account_nat_policy_settings.xml b/app/src/main/res/layout/account_nat_policy_settings.xml index c8a7d09f68..e6ab58bc56 100644 --- a/app/src/main/res/layout/account_nat_policy_settings.xml +++ b/app/src/main/res/layout/account_nat_policy_settings.xml @@ -17,7 +17,7 @@ android:background="@drawable/shape_squircle_white_background"> @@ -44,6 +45,8 @@ android:text="@={viewModel.stunServer}" android:inputType="text|textUri" android:hint="@string/account_settings_stun_server_url_title" + app:layout_constraintHorizontal_bias="0" + app:layout_constraintWidth_max="@dimen/text_input_max_width" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@id/stun_server_title"/> @@ -69,6 +72,7 @@ android:text="@string/account_settings_enable_ice_title" android:maxLines="2" android:ellipsize="end" + android:labelFor="@id/ice_switch" app:layout_constraintTop_toTopOf="@id/ice_switch" app:layout_constraintBottom_toBottomOf="@id/ice_switch" app:layout_constraintStart_toStartOf="parent" @@ -95,13 +99,14 @@ android:text="@string/account_settings_enable_turn_title" android:maxLines="2" android:ellipsize="end" + android:labelFor="@id/turn_switch" app:layout_constraintTop_toTopOf="@id/turn_switch" app:layout_constraintBottom_toBottomOf="@id/turn_switch" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toStartOf="@id/turn_switch"/> @@ -127,14 +133,16 @@ android:paddingStart="20dp" android:paddingEnd="20dp" android:text="@={viewModel.turnUsername}" - android:inputType="text|textPersonName" + android:inputType="text|textUri|textNoSuggestions" android:hint="@string/account_settings_turn_username_title" + app:layout_constraintHorizontal_bias="0" + app:layout_constraintWidth_max="@dimen/text_input_max_width" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@id/turn_username_title" /> @@ -162,6 +171,8 @@ android:text="@={viewModel.turnPassword}" android:hint="@string/account_settings_turn_password_title" android:inputType="@{viewModel.showTurnPassword ? InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD : InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD, default=textPassword}" + app:layout_constraintHorizontal_bias="0" + app:layout_constraintWidth_max="@dimen/text_input_max_width" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@id/turn_password_title" diff --git a/app/src/main/res/layout/account_popup_menu.xml b/app/src/main/res/layout/account_popup_menu.xml deleted file mode 100644 index 7892928b0d..0000000000 --- a/app/src/main/res/layout/account_popup_menu.xml +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/account_profile_device_list_cell.xml b/app/src/main/res/layout/account_profile_device_list_cell.xml index 22b363c66e..72af82d8c5 100644 --- a/app/src/main/res/layout/account_profile_device_list_cell.xml +++ b/app/src/main/res/layout/account_profile_device_list_cell.xml @@ -13,7 +13,7 @@ @@ -35,10 +35,6 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:background="@drawable/tertiary_button_background" - android:paddingStart="12dp" - android:paddingEnd="12dp" - android:paddingTop="6dp" - android:paddingBottom="6dp" android:gravity="center" android:text="@string/manage_account_device_remove" android:maxLines="1" diff --git a/app/src/main/res/layout/account_profile_fragment.xml b/app/src/main/res/layout/account_profile_fragment.xml index 50e6706ca9..de59711c2a 100644 --- a/app/src/main/res/layout/account_profile_fragment.xml +++ b/app/src/main/res/layout/account_profile_fragment.xml @@ -5,6 +5,7 @@ + @@ -43,12 +44,11 @@ android:background="?attr/color_background_contrast_in_dark_mode"> - - - - - - - - - - - + app:layout_constraintTop_toTopOf="parent"> + + + + + + + + + + + app:layout_constraintTop_toBottomOf="@id/avatar_content"/> - - - - - - - - - + android:background="@drawable/shape_squircle_white_background" + android:visibility="@{viewModel.expandDetails ? View.VISIBLE : View.GONE}" + app:layout_constraintWidth_max="@dimen/section_max_width" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/details"> + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - + app:layout_constraintTop_toBottomOf="@id/details_content"/> - + android:background="@drawable/shape_squircle_white_background" + app:layout_constraintWidth_max="@dimen/section_max_width" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/registration_status"> + + + + + + + + + + + app:layout_constraintTop_toBottomOf="@id/connection_content"/> @@ -513,6 +554,7 @@ android:text="@string/manage_account_settings" android:drawableStart="@drawable/gear" android:visibility="@{viewModel.hideAccountSettings ? View.GONE : View.VISIBLE}" + app:layout_constraintWidth_max="@dimen/section_max_width" app:layout_constraintTop_toBottomOf="@id/actions" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent"/> @@ -525,10 +567,10 @@ android:layout_height="wrap_content" android:layout_marginStart="16dp" android:layout_marginEnd="16dp" - android:layout_marginBottom="@dimen/screen_bottom_margin" android:background="@drawable/action_background_bottom" android:text="@string/manage_account_delete" android:drawableStart="@drawable/sign_out" + app:layout_constraintWidth_max="@dimen/section_max_width" app:layout_constraintTop_toBottomOf="@id/action_settings" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent"/> @@ -539,6 +581,7 @@ android:layout_marginStart="16dp" android:layout_marginEnd="16dp" android:background="?attr/color_separator" + android:importantForAccessibility="no" app:layout_constraintStart_toStartOf="@id/action_settings" app:layout_constraintEnd_toEndOf="@id/action_settings" app:layout_constraintTop_toBottomOf="@+id/action_settings"/> diff --git a/app/src/main/res/layout/account_profile_secure_mode_fragment.xml b/app/src/main/res/layout/account_profile_secure_mode_fragment.xml index 31e509773a..cc7640827c 100644 --- a/app/src/main/res/layout/account_profile_secure_mode_fragment.xml +++ b/app/src/main/res/layout/account_profile_secure_mode_fragment.xml @@ -28,12 +28,11 @@ android:background="?attr/color_main2_000"> + @@ -22,12 +25,11 @@ android:background="?attr/color_background_contrast_in_dark_mode"> - + android:paddingBottom="@dimen/screen_bottom_margin"> + android:background="@drawable/shape_squircle_white_background" + app:layout_constraintWidth_max="@dimen/section_max_width" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent"> @@ -160,12 +170,14 @@ android:text="@={viewModel.voicemailUri}" android:inputType="text|textUri" android:hint="@string/account_settings_voicemail_uri_title" + app:layout_constraintHorizontal_bias="0" + app:layout_constraintWidth_max="@dimen/text_input_max_width" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@id/voicemail_uri_title" /> @@ -192,6 +205,8 @@ android:text="@={viewModel.mwiUri}" android:inputType="text|textUri" android:hint="@string/account_settings_mwi_uri_title" + app:layout_constraintHorizontal_bias="0" + app:layout_constraintWidth_max="@dimen/text_input_max_width" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@id/mwi_uri_title" @@ -207,6 +222,7 @@ android:text="@string/account_settings_apply_international_prefix_title" android:maxLines="2" android:ellipsize="end" + android:labelFor="@id/apply_international_switch" app:layout_constraintTop_toTopOf="@id/apply_international_switch" app:layout_constraintBottom_toBottomOf="@id/apply_international_switch" app:layout_constraintStart_toStartOf="parent" @@ -217,7 +233,7 @@ android:id="@+id/apply_international_switch" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginTop="16dp" + android:layout_marginTop="20dp" android:layout_marginEnd="16dp" android:checked="@={viewModel.applyPrefix}" app:layout_constraintTop_toBottomOf="@id/mwi_uri" @@ -234,6 +250,7 @@ android:text="@string/account_settings_replace_plus_by_00_title" android:maxLines="2" android:ellipsize="end" + android:labelFor="@id/replace_plus_by_00_switch" app:layout_constraintTop_toTopOf="@id/replace_plus_by_00_switch" app:layout_constraintBottom_toBottomOf="@id/replace_plus_by_00_switch" app:layout_constraintStart_toStartOf="parent" @@ -244,7 +261,7 @@ android:id="@+id/replace_plus_by_00_switch" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginTop="16dp" + android:layout_marginTop="20dp" android:layout_marginEnd="16dp" android:layout_marginBottom="20dp" android:checked="@={viewModel.replacePlusBy00}" @@ -258,56 +275,71 @@ style="@style/section_header_style" android:id="@+id/nat_policy_settings_header" android:onClick="@{() -> viewModel.toggleNatPolicySettingsExpand()}" - android:layout_width="match_parent" + android:layout_width="0dp" android:layout_height="wrap_content" - android:padding="5dp" - android:layout_marginStart="26dp" - android:layout_marginEnd="26dp" + android:padding="10dp" + android:layout_marginStart="16dp" + android:layout_marginEnd="16dp" android:layout_marginTop="16dp" - android:layout_marginBottom="10dp" android:text="@string/account_settings_nat_policy_title" android:drawableEnd="@{viewModel.expandNatPolicySettings ? @drawable/caret_up : @drawable/caret_down, default=@drawable/caret_up}" - android:drawableTint="?attr/color_main2_600"/> + android:drawableTint="?attr/color_main2_600" + android:background="@drawable/squircle_transparent_button_background" + app:layout_constraintWidth_max="@dimen/section_max_width" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/account_settings" /> + bind:viewModel="@{viewModel}" + app:layout_constraintWidth_max="@dimen/section_max_width" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/nat_policy_settings_header"/> + android:drawableTint="?attr/color_main2_600" + android:background="@drawable/squircle_transparent_button_background" + app:layout_constraintWidth_max="@dimen/section_max_width" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/nat_policy_settings" /> + bind:outboundProxyTooltipClickListener="@{outboundProxyTooltipClickListener}" + bind:viewModel="@{viewModel}" + app:layout_constraintWidth_max="@dimen/section_max_width" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/advanced_settings_header"/> - + diff --git a/app/src/main/res/layout/address_selected_list_cell.xml b/app/src/main/res/layout/address_selected_list_cell.xml index c5a0dbdbce..92d47191bd 100644 --- a/app/src/main/res/layout/address_selected_list_cell.xml +++ b/app/src/main/res/layout/address_selected_list_cell.xml @@ -13,8 +13,7 @@ + android:padding="5dp"> + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:bind="http://schemas.android.com/tools"> @@ -28,306 +29,313 @@ type="org.linphone.ui.assistant.viewmodel.AccountLoginViewModel" /> - - + android:layout_height="match_parent" + android:fillViewport="true"> - - - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/assistant_permissions_fragment.xml b/app/src/main/res/layout/assistant_permissions_fragment.xml index af5d54c6d8..c55297b425 100644 --- a/app/src/main/res/layout/assistant_permissions_fragment.xml +++ b/app/src/main/res/layout/assistant_permissions_fragment.xml @@ -26,6 +26,9 @@ + @@ -100,6 +104,7 @@ android:text="@string/assistant_permissions_post_notifications_title" android:maxLines="2" android:ellipsize="end" + android:visibility="@{viewModel.postNotificationsPermissionGranted ? View.GONE : View.VISIBLE}" app:layout_constraintTop_toTopOf="@id/post_notifications_icon" app:layout_constraintStart_toEndOf="@id/post_notifications_icon" app:layout_constraintBottom_toBottomOf="@id/post_notifications_icon" @@ -115,6 +120,7 @@ android:padding="12dp" android:src="@drawable/address_book" android:contentDescription="@null" + android:visibility="@{viewModel.readContactsPermissionGranted ? View.GONE : View.VISIBLE}" app:tint="?attr/color_main2_500" app:layout_constraintTop_toBottomOf="@id/post_notifications_icon" app:layout_constraintStart_toStartOf="parent"/> @@ -129,6 +135,7 @@ android:text="@string/assistant_permissions_read_contacts_title" android:maxLines="2" android:ellipsize="end" + android:visibility="@{viewModel.readContactsPermissionGranted ? View.GONE : View.VISIBLE}" app:layout_constraintTop_toTopOf="@id/read_contacts_icon" app:layout_constraintStart_toEndOf="@id/read_contacts_icon" app:layout_constraintBottom_toBottomOf="@id/read_contacts_icon" @@ -144,6 +151,7 @@ android:padding="12dp" android:src="@drawable/microphone" android:contentDescription="@null" + android:visibility="@{viewModel.recordAudioPermissionGranted ? View.GONE : View.VISIBLE}" app:tint="?attr/color_main2_500" app:layout_constraintTop_toBottomOf="@id/read_contacts_icon" app:layout_constraintStart_toStartOf="parent"/> @@ -158,6 +166,7 @@ android:text="@string/assistant_permissions_record_audio_title" android:maxLines="2" android:ellipsize="end" + android:visibility="@{viewModel.recordAudioPermissionGranted ? View.GONE : View.VISIBLE}" app:layout_constraintTop_toTopOf="@id/record_audio_icon" app:layout_constraintStart_toEndOf="@id/record_audio_icon" app:layout_constraintBottom_toBottomOf="@id/record_audio_icon" @@ -173,6 +182,7 @@ android:padding="12dp" android:src="@drawable/video_camera" android:contentDescription="@null" + android:visibility="@{viewModel.cameraPermissionGranted ? View.GONE : View.VISIBLE}" app:tint="?attr/color_main2_500" app:layout_constraintTop_toBottomOf="@id/record_audio_icon" app:layout_constraintStart_toStartOf="parent"/> @@ -187,6 +197,7 @@ android:text="@string/assistant_permissions_access_camera_title" android:maxLines="2" android:ellipsize="end" + android:visibility="@{viewModel.cameraPermissionGranted ? View.GONE : View.VISIBLE}" app:layout_constraintTop_toTopOf="@id/access_camera_icon" app:layout_constraintStart_toEndOf="@id/access_camera_icon" app:layout_constraintBottom_toBottomOf="@id/access_camera_icon" @@ -202,8 +213,6 @@ android:layout_marginStart="16dp" android:layout_marginEnd="16dp" android:layout_marginBottom="20dp" - android:paddingStart="20dp" - android:paddingEnd="20dp" android:text="@string/assistant_permissions_skip_permissions" app:layout_constraintWidth_max="@dimen/button_max_width" app:layout_constraintVertical_bias="1" @@ -222,8 +231,6 @@ android:layout_marginStart="16dp" android:layout_marginEnd="16dp" android:layout_marginBottom="@dimen/screen_bottom_margin" - android:paddingStart="20dp" - android:paddingEnd="20dp" android:text="@string/assistant_permissions_grant_all_of_them" app:layout_constraintWidth_max="@dimen/button_max_width" app:layout_constraintTop_toBottomOf="@id/skip" diff --git a/app/src/main/res/layout/assistant_qr_code_scanner_fragment.xml b/app/src/main/res/layout/assistant_qr_code_scanner_fragment.xml index 195abe9617..eb546cdc5a 100644 --- a/app/src/main/res/layout/assistant_qr_code_scanner_fragment.xml +++ b/app/src/main/res/layout/assistant_qr_code_scanner_fragment.xml @@ -30,11 +30,11 @@ app:layout_constraintTop_toTopOf="parent" /> + type="org.linphone.ui.assistant.viewmodel.AccountCreationViewModel" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/assistant_recover_phone_account_fragment.xml b/app/src/main/res/layout/assistant_recover_phone_account_fragment.xml deleted file mode 100644 index 8b0a602fc2..0000000000 --- a/app/src/main/res/layout/assistant_recover_phone_account_fragment.xml +++ /dev/null @@ -1,212 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/assistant_register_confirm_sms_code_fragment.xml b/app/src/main/res/layout/assistant_register_confirm_sms_code_fragment.xml index 1f50cc150a..b36efaf28a 100644 --- a/app/src/main/res/layout/assistant_register_confirm_sms_code_fragment.xml +++ b/app/src/main/res/layout/assistant_register_confirm_sms_code_fragment.xml @@ -23,11 +23,11 @@ android:layout_height="match_parent"> @@ -160,6 +161,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:layout_marginTop="16dp" + android:labelFor="@id/phone_number" android:text="@{@string/phone_number + `*`}" app:layout_constraintTop_toBottomOf="@id/username_error" app:layout_constraintStart_toStartOf="@id/prefix"/> @@ -247,6 +249,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:layout_marginTop="16dp" + android:labelFor="@id/password" android:text="@{@string/password + `*`}" app:layout_constraintTop_toBottomOf="@id/phone_number_error" app:layout_constraintStart_toStartOf="@id/password"/> @@ -374,8 +377,6 @@ android:layout_marginTop="34dp" android:layout_marginStart="20dp" android:layout_marginBottom="@dimen/screen_bottom_margin" - android:paddingStart="20dp" - android:paddingEnd="20dp" android:text="@string/assistant_account_login" app:layout_constraintVertical_bias="1" app:layout_constraintEnd_toEndOf="parent" diff --git a/app/src/main/res/layout/assistant_third_party_sip_account_login_fragment.xml b/app/src/main/res/layout/assistant_third_party_sip_account_login_fragment.xml index 224bb3f38e..53a18588b1 100644 --- a/app/src/main/res/layout/assistant_third_party_sip_account_login_fragment.xml +++ b/app/src/main/res/layout/assistant_third_party_sip_account_login_fragment.xml @@ -1,6 +1,7 @@ + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:bind="http://schemas.android.com/tools"> @@ -8,352 +9,417 @@ + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:layout_height="match_parent" + android:fillViewport="true"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/assistant_third_party_sip_account_warning_fragment.xml b/app/src/main/res/layout/assistant_third_party_sip_account_warning_fragment.xml index b9256979e1..5d382797c9 100644 --- a/app/src/main/res/layout/assistant_third_party_sip_account_warning_fragment.xml +++ b/app/src/main/res/layout/assistant_third_party_sip_account_warning_fragment.xml @@ -30,11 +30,11 @@ android:layout_height="wrap_content"> @@ -217,6 +218,7 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:text="@string/call_action_start_new_call" + android:labelFor="@id/new_call" app:layout_constraintTop_toBottomOf="@id/new_call" app:layout_constraintStart_toEndOf="@id/transfer_label" app:layout_constraintEnd_toStartOf="@id/calls_list_label" /> @@ -228,6 +230,7 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:text="@string/call_action_go_to_calls_list" + android:labelFor="@id/calls_list" app:layout_constraintTop_toBottomOf="@id/calls_list" app:layout_constraintStart_toEndOf="@id/new_call_label" app:layout_constraintEnd_toStartOf="@id/layout_label" /> @@ -240,7 +243,8 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:text="@string/call_action_show_dialer" - app:layout_constraintTop_toBottomOf="@id/layout" + android:labelFor="@id/numpad" + app:layout_constraintTop_toBottomOf="@id/numpad" app:layout_constraintStart_toEndOf="@id/calls_list_label" app:layout_constraintEnd_toEndOf="parent" /> @@ -252,6 +256,7 @@ android:layout_height="wrap_content" android:paddingBottom="15dp" android:text="@string/call_action_show_messages" + android:labelFor="@id/chat" android:enabled="@{!viewModel.operationInProgress}" app:layout_constraintTop_toBottomOf="@id/chat" app:layout_constraintStart_toStartOf="@id/transfer_label" @@ -267,6 +272,7 @@ android:enabled="@{viewModel.canBePaused}" android:selected="@{viewModel.isPaused}" android:text="@{viewModel.isPaused ? @string/call_action_resume_call : @string/call_action_pause_call, default=@string/call_action_pause_call}" + android:labelFor="@id/pause_call" app:layout_constraintTop_toBottomOf="@id/pause_call" app:layout_constraintStart_toStartOf="@id/new_call_label" app:layout_constraintEnd_toEndOf="@id/new_call_label" /> @@ -279,7 +285,8 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:paddingBottom="15dp" - android:text="@string/call_action_record_call" + android:text="@{viewModel.isRecording ? @string/call_action_stop_record_call : @string/call_action_record_call, default=@string/call_action_record_call}" + android:labelFor="@id/record_call" app:layout_constraintTop_toBottomOf="@id/record_call" app:layout_constraintStart_toStartOf="@id/calls_list_label" app:layout_constraintEnd_toEndOf="@id/calls_list_label" /> diff --git a/app/src/main/res/layout/call_actions_generic.xml b/app/src/main/res/layout/call_actions_generic.xml index 778bb4f049..b1cfe50dd1 100644 --- a/app/src/main/res/layout/call_actions_generic.xml +++ b/app/src/main/res/layout/call_actions_generic.xml @@ -4,6 +4,7 @@ + @@ -52,8 +53,8 @@ android:layout_height="@dimen/call_button_size" android:layout_marginEnd="16dp" android:padding="@dimen/call_button_icon_padding" - android:enabled="@{!viewModel.isPaused && !viewModel.isPausedByRemote && !viewModel.videoUpdateInProgress}" - android:visibility="@{View.GONE}" + android:enabled="@{!viewModel.isPaused && !viewModel.isPausedByRemote && !viewModel.videoUpdateInProgress && (!viewModel.conferenceModel.isCurrentCallInConference || viewModel.conferenceModel.conferenceLayout != ConferenceViewModel.AUDIO_ONLY_LAYOUT)}" + android:visibility="@{viewModel.hideVideo ? View.GONE : View.VISIBLE}" android:src="@{viewModel.isSendingVideo ? @drawable/video_camera : @drawable/video_camera_slash, default=@drawable/video_camera}" android:background="@drawable/in_call_button_background_red" android:contentDescription="@string/content_description_toggle_video" @@ -69,7 +70,7 @@ android:id="@+id/video_update_in_progress" android:layout_width="0dp" android:layout_height="0dp" - android:visibility="@{View.GONE}" + android:visibility="@{viewModel.videoUpdateInProgress ? View.VISIBLE : View.GONE}" android:indeterminate="true" app:indicatorColor="?attr/color_main1_500" app:layout_constraintTop_toTopOf="@id/toggle_video" @@ -77,16 +78,25 @@ app:layout_constraintStart_toStartOf="@id/toggle_video" app:layout_constraintEnd_toEndOf="@id/toggle_video"/> - + + - + + @@ -98,7 +99,7 @@ app:layout_constraintStart_toEndOf="@id/back" app:layout_constraintEnd_toStartOf="@id/separator" app:layout_constraintTop_toTopOf="@id/back" - app:layout_constraintBottom_toBottomOf="@id/back"/> + app:layout_constraintBottom_toTopOf="@id/call_media_encryption_info"/> @@ -128,7 +130,7 @@ android:layout_height="wrap_content" android:layout_marginStart="5dp" android:text="@string/call_state_paused" - android:visibility="@{conferenceViewModel.isPaused ? View.VISIBLE : View.GONE, default=gone}" + android:visibility="@{!viewModel.fullScreenMode && !viewModel.pipMode && conferenceViewModel.isPaused ? View.VISIBLE : View.GONE, default=gone}" app:layout_constraintHorizontal_bias="0" app:layout_constraintStart_toEndOf="@id/chronometer" app:layout_constraintEnd_toStartOf="@id/switch_camera" @@ -143,8 +145,11 @@ android:layout_marginStart="5dp" android:layout_marginEnd="5dp" android:src="@drawable/camera_rotate" + android:adjustViewBounds="true" android:contentDescription="@string/content_description_change_camera" + android:background="@drawable/circle_transparent_dark_button_background" android:visibility="@{!viewModel.fullScreenMode && !viewModel.pipMode && viewModel.showSwitchCamera && conferenceViewModel.isMeParticipantSendingVideo ? View.VISIBLE : View.GONE}" + app:layout_constraintDimensionRatio="1:1" app:tint="@color/bc_white" app:layout_constraintTop_toTopOf="@id/back" app:layout_constraintBottom_toBottomOf="@id/back" @@ -170,9 +175,12 @@ android:layout_height="0dp" android:padding="10dp" android:src="@{viewModel.qualityIcon, default=@drawable/cell_signal_full}" + android:adjustViewBounds="true" android:contentDescription="@string/content_description_show_call_statistics" + android:background="@drawable/circle_transparent_dark_button_background" android:visibility="@{!viewModel.fullScreenMode && !viewModel.pipMode && !conferenceViewModel.isPaused ? View.VISIBLE : View.GONE}" android:tint="@{viewModel.qualityValue >= 2 ? @color/bc_white : viewModel.qualityValue >= 1 ? @color/orange_warning_600 : @color/red_danger_500, default=@color/bc_white}" + app:layout_constraintDimensionRatio="1:1" app:layout_constraintTop_toTopOf="@id/back" app:layout_constraintBottom_toBottomOf="@id/back" app:layout_constraintEnd_toEndOf="parent" @@ -187,6 +195,7 @@ bind:viewModel="@{viewModel}" bind:callMediaEncryptionStatisticsClickListener="@{callMediaEncryptionStatisticsClickListener}" app:layout_constraintTop_toBottomOf="@id/conference_subject" + app:layout_constraintBottom_toBottomOf="@id/back" app:layout_constraintStart_toStartOf="@id/conference_subject" app:layout_constraintEnd_toEndOf="parent"/> @@ -211,7 +220,7 @@ + + + + + + + + + app:layout_constraintBottom_toTopOf="@id/call_media_encryption_info"/> @@ -200,8 +202,11 @@ android:layout_marginStart="5dp" android:layout_marginEnd="5dp" android:src="@drawable/camera_rotate" + android:adjustViewBounds="true" + android:background="@drawable/circle_transparent_dark_button_background" android:contentDescription="@string/content_description_change_camera" android:visibility="@{!viewModel.fullScreenMode && !viewModel.pipMode && viewModel.isSendingVideo && viewModel.showSwitchCamera ? View.VISIBLE : View.GONE}" + app:layout_constraintDimensionRatio="1:1" app:tint="@color/bc_white" app:layout_constraintTop_toTopOf="@id/back" app:layout_constraintBottom_toBottomOf="@id/back" @@ -212,10 +217,14 @@ android:layout_width="@dimen/big_icon_size" android:layout_height="0dp" android:padding="10dp" + android:layout_marginStart="5dp" + android:layout_marginEnd="5dp" android:src="@drawable/pause" + android:adjustViewBounds="true" android:contentDescription="@string/content_description_paused_call" android:visibility="@{!viewModel.fullScreenMode && !viewModel.pipMode && (viewModel.isPaused || viewModel.isPausedByRemote) ? View.VISIBLE : View.GONE, default=gone}" app:tint="?attr/color_main1_500" + app:layout_constraintDimensionRatio="1:1" app:layout_constraintTop_toTopOf="@id/back" app:layout_constraintBottom_toBottomOf="@id/back" app:layout_constraintEnd_toStartOf="@id/stats" /> @@ -227,23 +236,27 @@ android:layout_height="0dp" android:padding="10dp" android:src="@{viewModel.qualityIcon, default=@drawable/cell_signal_full}" + android:adjustViewBounds="true" android:contentDescription="@string/content_description_show_call_statistics" + android:background="@drawable/circle_transparent_dark_button_background" android:visibility="@{!viewModel.fullScreenMode && !viewModel.pipMode && !(viewModel.isPaused || viewModel.isPausedByRemote) ? View.VISIBLE : View.GONE}" android:tint="@{viewModel.qualityValue >= 2 ? @color/bc_white : viewModel.qualityValue >= 1 ? @color/orange_warning_600 : @color/red_danger_500, default=@color/bc_white}" + app:layout_constraintDimensionRatio="1:1" app:layout_constraintTop_toTopOf="@id/back" app:layout_constraintBottom_toBottomOf="@id/back" app:layout_constraintEnd_toEndOf="parent" bind:ignore="UseAppTint" /> @@ -289,11 +302,12 @@ diff --git a/app/src/main/res/layout/call_activity_other_calls_top_bar.xml b/app/src/main/res/layout/call_activity_other_calls_top_bar.xml index c4cded125d..af0093fe9d 100644 --- a/app/src/main/res/layout/call_activity_other_calls_top_bar.xml +++ b/app/src/main/res/layout/call_activity_other_calls_top_bar.xml @@ -13,7 +13,7 @@ @@ -198,6 +199,7 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:ellipsize="middle" + android:labelFor="@id/participants" android:text="@{viewModel.conferenceModel.participantsLabel, default=@string/conference_action_show_participants}" app:layout_constraintTop_toBottomOf="@id/participants" app:layout_constraintStart_toEndOf="@id/screen_sharing_label" @@ -210,6 +212,7 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:text="@string/call_action_go_to_calls_list" + android:labelFor="@id/calls_list" app:layout_constraintTop_toBottomOf="@id/calls_list" app:layout_constraintStart_toEndOf="@id/participants_label" app:layout_constraintEnd_toStartOf="@id/layout_label" /> @@ -221,6 +224,7 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:text="@string/call_action_change_layout" + android:labelFor="@id/layout" app:layout_constraintTop_toBottomOf="@id/layout" app:layout_constraintStart_toEndOf="@id/calls_list_label" app:layout_constraintEnd_toEndOf="parent" /> @@ -233,6 +237,7 @@ android:layout_height="wrap_content" android:paddingBottom="15dp" android:text="@string/call_action_show_messages" + android:labelFor="@id/chat" android:enabled="@{viewModel.conferenceModel.isConversationAvailable}" app:layout_constraintTop_toBottomOf="@id/chat" app:layout_constraintStart_toStartOf="@id/screen_sharing_label" @@ -247,6 +252,7 @@ android:enabled="@{viewModel.canBePaused}" android:selected="@{viewModel.isPaused}" android:text="@{viewModel.isPaused ? @string/call_action_resume_call : @string/call_action_pause_call, default=@string/call_action_pause_call}" + android:labelFor="@id/pause_call" app:layout_constraintTop_toBottomOf="@id/pause_call" app:layout_constraintStart_toStartOf="@id/participants_label" app:layout_constraintEnd_toEndOf="@id/participants_label" /> @@ -258,6 +264,7 @@ android:layout_height="wrap_content" android:paddingBottom="15dp" android:text="@string/call_action_record_call" + android:labelFor="@id/record_call" android:enabled="false" app:layout_constraintTop_toBottomOf="@id/record_call" app:layout_constraintStart_toStartOf="@id/calls_list_label" diff --git a/app/src/main/res/layout/call_conference_participant_list_cell.xml b/app/src/main/res/layout/call_conference_participant_list_cell.xml index 76cb288231..e05c36de05 100644 --- a/app/src/main/res/layout/call_conference_participant_list_cell.xml +++ b/app/src/main/res/layout/call_conference_participant_list_cell.xml @@ -19,10 +19,9 @@ diff --git a/app/src/main/res/layout/call_conference_participants_list_fragment.xml b/app/src/main/res/layout/call_conference_participants_list_fragment.xml index bd47367581..ddd42515dc 100644 --- a/app/src/main/res/layout/call_conference_participants_list_fragment.xml +++ b/app/src/main/res/layout/call_conference_participants_list_fragment.xml @@ -24,12 +24,11 @@ android:background="?attr/color_background_contrast_in_dark_mode"> - + app:layout_constraintEnd_toEndOf="parent"> - + + + + + + + + + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toTopOf="@id/call_media_encryption_info" /> @@ -11,186 +12,33 @@ - - - - + + - - - - - - - - - - - - - - - - - - - - + + - - + app:layout_constraintBottom_toBottomOf="parent"/> diff --git a/app/src/main/res/layout/call_incoming_actions_screen_locked.xml b/app/src/main/res/layout/call_incoming_actions_screen_locked.xml new file mode 100644 index 0000000000..d3f4952874 --- /dev/null +++ b/app/src/main/res/layout/call_incoming_actions_screen_locked.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/call_incoming_actions_screen_unlocked.xml b/app/src/main/res/layout/call_incoming_actions_screen_unlocked.xml new file mode 100644 index 0000000000..64b37c8283 --- /dev/null +++ b/app/src/main/res/layout/call_incoming_actions_screen_unlocked.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/call_incoming_fragment.xml b/app/src/main/res/layout/call_incoming_fragment.xml index 523a763660..da1f195c45 100644 --- a/app/src/main/res/layout/call_incoming_fragment.xml +++ b/app/src/main/res/layout/call_incoming_fragment.xml @@ -57,12 +57,12 @@ + app:layout_constraintTop_toTopOf="parent" /> + diff --git a/app/src/main/res/layout/call_media_encryption_info.xml b/app/src/main/res/layout/call_media_encryption_info.xml index 05164e6fd6..8fc5cf59d1 100644 --- a/app/src/main/res/layout/call_media_encryption_info.xml +++ b/app/src/main/res/layout/call_media_encryption_info.xml @@ -74,7 +74,7 @@ android:adjustViewBounds="true" android:paddingTop="3dp" android:contentDescription="@null" - android:src="@drawable/lock_simple_open_bold" + android:src="@drawable/lock_simple_open" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="@id/no_media_encryption_label" app:layout_constraintBottom_toBottomOf="@id/no_media_encryption_label" @@ -144,7 +144,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginStart="5dp" - android:text="@{viewModel.isZrtp ? (viewModel.conferenceModel.isCurrentCallInConference ? @string/call_zrtp_point_to_point_encrypted : @string/call_zrtp_end_to_end_encrypted) : @string/call_srtp_point_to_point_encrypted, default=@string/call_zrtp_end_to_end_encrypted}" + android:text="@{viewModel.conferenceModel.isCurrentCallInConference ? (viewModel.conferenceModel.isEndToEndEncrypted ? @string/call_conference_end_to_end_encrypted : (viewModel.isZrtp ? @string/call_zrtp_point_to_point_encrypted : @string/call_srtp_point_to_point_encrypted)) : viewModel.isZrtp ? @string/call_zrtp_end_to_end_encrypted : @string/call_srtp_point_to_point_encrypted, default=@string/call_zrtp_end_to_end_encrypted}" android:textSize="12sp" android:textColor="@color/blue_info_500" android:maxLines="1" diff --git a/app/src/main/res/layout/call_media_encryption_stats_bottom_sheet.xml b/app/src/main/res/layout/call_media_encryption_stats_bottom_sheet.xml index b841202dd9..cb9cfea1d0 100644 --- a/app/src/main/res/layout/call_media_encryption_stats_bottom_sheet.xml +++ b/app/src/main/res/layout/call_media_encryption_stats_bottom_sheet.xml @@ -131,11 +131,12 @@ + android:layout_gravity="center_horizontal" + android:visibility="@{model.isMediaEncryptionZrtp && !viewModel.conferenceModel.isCurrentCallInConference() ? View.VISIBLE : View.GONE}"/> diff --git a/app/src/main/res/layout/call_numpad_bottom_sheet.xml b/app/src/main/res/layout/call_numpad_bottom_sheet.xml index ee2481b3f2..d1cd6cc7e7 100644 --- a/app/src/main/res/layout/call_numpad_bottom_sheet.xml +++ b/app/src/main/res/layout/call_numpad_bottom_sheet.xml @@ -11,6 +11,9 @@ + diff --git a/app/src/main/res/layout/call_numpad_digit.xml b/app/src/main/res/layout/call_numpad_digit.xml index 6824ff449b..0307b9235c 100644 --- a/app/src/main/res/layout/call_numpad_digit.xml +++ b/app/src/main/res/layout/call_numpad_digit.xml @@ -13,19 +13,11 @@ - - + android:layout_height="@dimen/call_dtmf_button_size" + android:background="@drawable/in_call_numpad_button_background"> - - + android:layout_height="@dimen/call_dtmf_button_size" + android:background="@drawable/in_call_numpad_button_background"> - - + android:layout_height="@dimen/call_dtmf_button_size" + android:background="@drawable/in_call_numpad_button_background"> - - + android:layout_height="@dimen/call_dtmf_button_size" + android:background="@drawable/in_call_numpad_button_background"> + + + + @@ -102,17 +101,18 @@ + + + + + + + + + - + app:layout_constraintEnd_toEndOf="parent"> - + - + - + - + - + - + - + - + - + - + - + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/calls_list_fragment.xml b/app/src/main/res/layout/calls_list_fragment.xml index 88bcc5d138..d1356db67d 100644 --- a/app/src/main/res/layout/calls_list_fragment.xml +++ b/app/src/main/res/layout/calls_list_fragment.xml @@ -21,11 +21,10 @@ android:background="?attr/color_background_contrast_in_dark_mode"> - + app:layout_constraintEnd_toEndOf="parent"> - + + + + + + + + - - diff --git a/app/src/main/res/layout/chat_bubble_incoming.xml b/app/src/main/res/layout/chat_bubble_incoming.xml index 765616b4ff..885b51b1c5 100644 --- a/app/src/main/res/layout/chat_bubble_incoming.xml +++ b/app/src/main/res/layout/chat_bubble_incoming.xml @@ -5,7 +5,6 @@ - @@ -230,10 +230,24 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@{model.text, default=`Lorem ipsum dolor sit amet`}" - android:textSize="14sp" + android:textSize="@{model.isTextEmoji ? @dimen/chat_bubble_emoji_only_text_size : @dimen/chat_bubble_text_size, default=@dimen/chat_bubble_text_size}" android:textColor="?attr/color_main2_800" + android:textIsSelectable="true" android:gravity="center_vertical|start" - android:visibility="@{model.text.length() > 0 ? View.VISIBLE : View.GONE}"/> + android:includeFontPadding="@{!model.isTextEmoji}" + android:visibility="@{model.text.length() > 0 && !model.hasBeenRetracted ? View.VISIBLE : View.GONE}"/> + + + + @@ -45,8 +48,10 @@ android:layout_marginStart="10dp" android:layout_marginEnd="10dp" android:layout_marginBottom="5dp" + app:layout_constrainedHeight="true" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintTop_toBottomOf="@id/emojis" app:layout_constraintBottom_toTopOf="@id/actions"> + app:layout_constraintEnd_toEndOf="parent"/> + app:layout_constraintEnd_toEndOf="parent"/> @@ -89,7 +92,6 @@ android:layout_marginStart="10dp" android:layout_marginEnd="10dp" android:layout_marginBottom="@dimen/screen_bottom_margin" - android:background="@drawable/shape_squircle_white_background" app:layout_constraintWidth_max="250dp" app:layout_constraintHorizontal_bias="@{viewModel.horizontalBias, default=0}" app:layout_constraintBottom_toBottomOf="parent" @@ -97,12 +99,13 @@ app:layout_constraintEnd_toEndOf="parent"> @@ -110,45 +113,69 @@ android:layout_width="match_parent" android:layout_height="1dp" android:background="?attr/color_separator" + android:importantForAccessibility="no" android:visibility="@{viewModel.isMessageInError && viewModel.isMessageOutgoing ? View.VISIBLE : View.GONE, default=gone}"/> + android:background="@{viewModel.isMessageInError && viewModel.isMessageOutgoing ? @drawable/action_background_middle : @drawable/action_background_top, default=@drawable/action_background_top}" + android:visibility="@{viewModel.isMessageOutgoing ? View.VISIBLE : View.GONE}" + android:drawableEnd="@drawable/info"/> + android:background="?attr/color_separator" + android:importantForAccessibility="no"/> + + + + + android:importantForAccessibility="no" + android:visibility="@{viewModel.isChatRoomReadOnly || viewModel.hasBeenRetracted ? View.GONE : View.VISIBLE}" /> @@ -156,32 +183,38 @@ android:layout_width="match_parent" android:layout_height="1dp" android:background="?attr/color_separator" + android:importantForAccessibility="no" android:visibility="@{viewModel.hideCopyTextToClipboard ? View.GONE : View.VISIBLE}"/> + android:visibility="@{viewModel.hideForward || viewModel.hasBeenRetracted ? View.GONE : View.VISIBLE}" /> + android:importantForAccessibility="no" + android:visibility="@{viewModel.hideForward || viewModel.hasBeenRetracted ? View.GONE : View.VISIBLE}" /> + android:textColor="?attr/color_danger_500" + android:background="@drawable/action_background_bottom" + android:drawableEnd="@drawable/trash_simple" + app:drawableTint="?attr/color_danger_500"/> diff --git a/app/src/main/res/layout/chat_bubble_meeting_invite_content.xml b/app/src/main/res/layout/chat_bubble_meeting_invite_content.xml index 178a57ad6e..b5db664076 100644 --- a/app/src/main/res/layout/chat_bubble_meeting_invite_content.xml +++ b/app/src/main/res/layout/chat_bubble_meeting_invite_content.xml @@ -235,10 +235,6 @@ android:layout_marginBottom="10dp" android:layout_marginTop="10dp" android:background="@drawable/tertiary_button_background" - android:paddingStart="12dp" - android:paddingEnd="12dp" - android:paddingTop="6dp" - android:paddingBottom="6dp" android:gravity="center" android:text="@string/meeting_waiting_room_join" android:maxLines="1" diff --git a/app/src/main/res/layout/chat_bubble_outgoing.xml b/app/src/main/res/layout/chat_bubble_outgoing.xml index 24bc86e165..61a4cc1dc1 100644 --- a/app/src/main/res/layout/chat_bubble_outgoing.xml +++ b/app/src/main/res/layout/chat_bubble_outgoing.xml @@ -5,7 +5,6 @@ - @@ -202,10 +202,24 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@{model.text, default=`Lorem ipsum dolor sit amet`}" - android:textSize="14sp" + android:textSize="@{model.isTextEmoji ? @dimen/chat_bubble_emoji_only_text_size : @dimen/chat_bubble_text_size, default=@dimen/chat_bubble_text_size}" android:textColor="?attr/color_main2_800" + android:textIsSelectable="true" android:gravity="center_vertical|start" - android:visibility="@{model.text.length() > 0 ? View.VISIBLE : View.GONE}"/> + android:includeFontPadding="@{!model.isTextEmoji}" + android:visibility="@{model.text.length() > 0 && !model.hasBeenRetracted ? View.VISIBLE : View.GONE}"/> + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/chat_conversation_file_pickers.xml b/app/src/main/res/layout/chat_conversation_file_pickers.xml index 02b70ee9ea..3253d6118d 100644 --- a/app/src/main/res/layout/chat_conversation_file_pickers.xml +++ b/app/src/main/res/layout/chat_conversation_file_pickers.xml @@ -25,6 +25,7 @@ android:layout_width="match_parent" android:layout_height="1dp" android:background="?attr/color_separator" + android:importantForAccessibility="no" app:layout_constraintTop_toTopOf="parent"/> @@ -70,18 +71,18 @@ android:visibility="@{viewModel.searchBarVisible ? View.VISIBLE : View.GONE, default=gone}" /> + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toTopOf="@id/events_list"/> + app:constraint_referenced_ids="unsecure, unsecure_label, muted, ephemeral_enabled, ephemeral_duration" /> + + + + @@ -160,44 +193,47 @@ android:paddingBottom="2dp" android:text="@{viewModel.ephemeralLifeTimeLabel, default=`1 day`}" android:textSize="12sp" + android:maxLines="1" + android:ellipsize="end" android:visibility="@{viewModel.ephemeralLifetime > 0L ? View.VISIBLE : View.GONE, default=gone}" app:layout_constraintStart_toEndOf="@id/ephemeral_enabled" app:layout_constraintTop_toTopOf="@id/ephemeral_enabled" app:layout_constraintBottom_toBottomOf="@id/ephemeral_enabled"/> - - @@ -294,19 +319,33 @@ app:layout_constraintStart_toStartOf="@id/events_list" app:layout_constraintEnd_toEndOf="@id/events_list" /> + + - + + + app:layout_constraintTop_toBottomOf="@id/participants_close" /> - + - + + app:layout_constraintEnd_toStartOf="@id/cancel_reply" /> + app:layout_constraintEnd_toStartOf="@id/cancel_reply" /> diff --git a/app/src/main/res/layout/chat_conversation_send_area.xml b/app/src/main/res/layout/chat_conversation_send_area.xml index 13339b0c44..0e19dfd7c7 100644 --- a/app/src/main/res/layout/chat_conversation_send_area.xml +++ b/app/src/main/res/layout/chat_conversation_send_area.xml @@ -35,10 +35,21 @@ + + + app:layout_constraintTop_toBottomOf="@id/edit_area" /> + app:tint="@color/icon_primary_color_selector" /> @@ -49,7 +49,7 @@ android:layout_marginEnd="15dp" android:text="@string/conversation_warning_disabled_because_not_secured_title" android:textSize="12sp" - android:textColor="@color/orange_warning_600" + android:textColor="?attr/color_plain_text_security_warning_600" app:layout_constraintStart_toEndOf="@id/disabled_icon" app:layout_constraintEnd_toEndOf="@id/disabled_background" app:layout_constraintTop_toTopOf="parent"/> diff --git a/app/src/main/res/layout/chat_conversation_unsafe_disabled_details_bottom_sheet.xml b/app/src/main/res/layout/chat_conversation_unsafe_disabled_details_bottom_sheet.xml index 18deddcbe9..1776fcf514 100644 --- a/app/src/main/res/layout/chat_conversation_unsafe_disabled_details_bottom_sheet.xml +++ b/app/src/main/res/layout/chat_conversation_unsafe_disabled_details_bottom_sheet.xml @@ -24,7 +24,7 @@ android:layout_marginStart="16dp" android:layout_marginEnd="16dp" android:gravity="center" - android:text="@string/conversation_warning_disabled_encrypted_bottom_sheet_title" + android:text="@string/conversation_warning_unsafe_bottom_sheet_title" android:textSize="16sp" app:layout_constraintTop_toTopOf="parent" app:layout_constraintStart_toStartOf="parent" @@ -51,7 +51,7 @@ android:layout_marginStart="16dp" android:layout_marginEnd="16dp" android:gravity="center" - android:text="@string/conversation_warning_disabled_encrypted_bottom_sheet_message" + android:text="@string/conversation_warning_unsafe_bottom_sheet_message" android:textSize="12sp" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" diff --git a/app/src/main/res/layout/chat_conversation_unsafe_first_event.xml b/app/src/main/res/layout/chat_conversation_unsafe_first_event.xml new file mode 100644 index 0000000000..16ec2b6c9b --- /dev/null +++ b/app/src/main/res/layout/chat_conversation_unsafe_first_event.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/chat_documents_fragment.xml b/app/src/main/res/layout/chat_documents_fragment.xml index 13e63aafea..98a2b17a43 100644 --- a/app/src/main/res/layout/chat_documents_fragment.xml +++ b/app/src/main/res/layout/chat_documents_fragment.xml @@ -23,11 +23,10 @@ android:background="?attr/color_background_contrast_in_dark_mode"> @@ -98,8 +99,6 @@ android:onClick="@{() -> viewModel.onValueSelected(60)}" android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_marginStart="16dp" - android:layout_marginEnd="16dp" android:paddingTop="10dp" android:paddingBottom="10dp" android:checked="@{viewModel.currentlySelectedValue == 60}" @@ -107,23 +106,21 @@ android:textSize="17sp" android:textColor="?attr/color_main2_500" android:textAlignment="textStart" + android:background="@drawable/action_background_middle" app:useMaterialThemeColors="false" app:buttonTint="?attr/color_main1_500" /> + android:background="?attr/color_separator" + android:importantForAccessibility="no"/> + android:background="?attr/color_separator" + android:importantForAccessibility="no"/> + android:background="?attr/color_separator" + android:importantForAccessibility="no"/> + android:background="?attr/color_separator" + android:importantForAccessibility="no"/> + android:background="?attr/color_separator" + android:importantForAccessibility="no"/> diff --git a/app/src/main/res/layout/chat_info_fragment.xml b/app/src/main/res/layout/chat_info_fragment.xml index 90d2d64ffc..8fef099476 100644 --- a/app/src/main/res/layout/chat_info_fragment.xml +++ b/app/src/main/res/layout/chat_info_fragment.xml @@ -24,6 +24,9 @@ + @@ -50,11 +53,10 @@ android:background="?attr/color_background_contrast_in_dark_mode"> - - - - - - - - - + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - + app:layout_constraintEnd_toEndOf="parent"> + + + + + + + + + + + + + + - - + app:layout_constraintTop_toBottomOf="@id/call_actions" /> - - - + app:layout_constraintEnd_toEndOf="parent"> + + + + + + + app:layout_constraintTop_toBottomOf="@id/participants_list" /> @@ -410,6 +454,7 @@ android:drawableStart="@drawable/file_pdf" android:onClick="@{goToSharedDocumentsClickListener}" android:text="@string/conversation_document_list_title" + app:layout_constraintWidth_max="@dimen/section_max_width" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/action_media" /> @@ -420,6 +465,7 @@ android:layout_marginStart="16dp" android:layout_marginEnd="16dp" android:background="?attr/color_separator" + android:importantForAccessibility="no" app:layout_constraintEnd_toEndOf="@id/action_media" app:layout_constraintStart_toStartOf="@id/action_media" app:layout_constraintTop_toBottomOf="@id/action_media" /> @@ -429,11 +475,12 @@ style="@style/section_header_style" android:layout_width="0dp" android:layout_height="wrap_content" - android:layout_marginStart="26dp" + android:layout_marginStart="16dp" android:layout_marginTop="16dp" - android:layout_marginEnd="26dp" - android:padding="5dp" + android:layout_marginEnd="16dp" + android:padding="10dp" android:text="@string/contact_details_actions_title" + app:layout_constraintWidth_max="@dimen/section_max_width" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/action_documents" /> @@ -445,12 +492,12 @@ android:layout_height="wrap_content" android:layout_marginStart="16dp" android:layout_marginEnd="16dp" - android:layout_marginTop="8dp" android:background="@drawable/action_background_top" android:drawableStart="@drawable/address_book" android:onClick="@{goToContactClickListener}" android:text="@string/conversation_info_menu_go_to_contact" android:visibility="@{!viewModel.isGroup && viewModel.friendAvailable ? View.VISIBLE : View.GONE, default=gone}" + app:layout_constraintWidth_max="@dimen/section_max_width" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/other_actions" /> @@ -466,7 +513,8 @@ android:drawableStart="@drawable/user_plus" android:onClick="@{addToContactsClickListener}" android:text="@string/conversation_info_menu_add_to_contacts" - android:visibility="@{!viewModel.isGroup && !viewModel.friendAvailable ? View.VISIBLE : View.GONE, default=gone}" + android:visibility="@{!viewModel.isGroup && !viewModel.friendAvailable && !viewModel.disableAddContact ? View.VISIBLE : View.GONE, default=gone}" + app:layout_constraintWidth_max="@dimen/section_max_width" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/action_see_contact" /> @@ -478,11 +526,12 @@ android:layout_height="wrap_content" android:layout_marginStart="16dp" android:layout_marginEnd="16dp" - android:background="@{!viewModel.isGroup ? @drawable/action_background_middle : @drawable/action_background_top, default=@drawable/action_background_top}" + android:background="@{viewModel.disableAddContact ? @drawable/action_background_top : !viewModel.isGroup ? @drawable/action_background_middle : @drawable/action_background_top, default=@drawable/action_background_top}" android:drawableStart="@drawable/clock_countdown" android:onClick="@{configureEphemeralMessagesClickListener}" android:text="@string/conversation_action_configure_ephemeral_messages" android:visibility="@{viewModel.isEndToEndEncrypted && !viewModel.isReadOnly ? View.VISIBLE : View.GONE}" + app:layout_constraintWidth_max="@dimen/section_max_width" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/action_add_to_contacts" /> @@ -496,9 +545,10 @@ android:layout_marginEnd="16dp" android:background="@{viewModel.isEndToEndEncrypted && !viewModel.isReadOnly ? @drawable/action_background_middle : @drawable/action_background_top, default=@drawable/action_background_middle}" android:drawableStart="@drawable/sign_out" - android:onClick="@{() -> viewModel.leaveGroup()}" + android:onClick="@{leaveGroupClickListener}" android:text="@string/conversation_action_leave_group" android:visibility="@{viewModel.isGroup && !viewModel.isReadOnly ? View.VISIBLE : View.GONE, default=gone}" + app:layout_constraintWidth_max="@dimen/section_max_width" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/action_ephemeral_messages" /> @@ -514,6 +564,7 @@ android:drawableStart="@drawable/trash_simple" android:onClick="@{deleteHistoryClickListener}" android:text="@string/conversation_info_delete_history_action" + app:layout_constraintWidth_max="@dimen/section_max_width" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/action_leave_group" /> @@ -524,6 +575,7 @@ android:layout_marginStart="16dp" android:layout_marginEnd="16dp" android:background="?attr/color_separator" + android:importantForAccessibility="no" android:visibility="@{!viewModel.isGroup && !viewModel.friendAvailable ? View.VISIBLE : View.GONE, default=gone}" app:layout_constraintEnd_toEndOf="@id/action_add_to_contacts" app:layout_constraintStart_toStartOf="@id/action_add_to_contacts" @@ -535,6 +587,7 @@ android:layout_marginStart="16dp" android:layout_marginEnd="16dp" android:background="?attr/color_separator" + android:importantForAccessibility="no" android:visibility="@{!viewModel.isGroup && viewModel.friendAvailable ? View.VISIBLE : View.GONE, default=gone}" app:layout_constraintEnd_toEndOf="@id/action_see_contact" app:layout_constraintStart_toStartOf="@id/action_see_contact" @@ -546,6 +599,7 @@ android:layout_marginStart="16dp" android:layout_marginEnd="16dp" android:background="?attr/color_separator" + android:importantForAccessibility="no" android:visibility="@{viewModel.isEndToEndEncrypted && !viewModel.isReadOnly ? View.VISIBLE : View.GONE}" app:layout_constraintEnd_toEndOf="@id/action_ephemeral_messages" app:layout_constraintStart_toStartOf="@id/action_ephemeral_messages" @@ -557,6 +611,7 @@ android:layout_marginStart="16dp" android:layout_marginEnd="16dp" android:background="?attr/color_separator" + android:importantForAccessibility="no" android:visibility="@{viewModel.isGroup && !viewModel.isReadOnly ? View.VISIBLE : View.GONE, default=gone}" app:layout_constraintEnd_toEndOf="@id/action_leave_group" app:layout_constraintStart_toStartOf="@id/action_leave_group" diff --git a/app/src/main/res/layout/chat_list_cell.xml b/app/src/main/res/layout/chat_list_cell.xml index 05c02d9b97..726fdf1fd2 100644 --- a/app/src/main/res/layout/chat_list_cell.xml +++ b/app/src/main/res/layout/chat_list_cell.xml @@ -20,6 +20,7 @@ - - + + @@ -200,13 +203,13 @@ android:layout_height="@dimen/small_icon_size" android:layout_marginStart="5dp" android:layout_marginEnd="10dp" - android:src="@drawable/lock_simple_open_bold" + android:src="@drawable/lock_simple_open" android:contentDescription="@string/content_description_chat_unsecured" android:visibility="@{model.isEncrypted || !model.isEncryptionAvailable ? View.GONE : View.VISIBLE}" app:layout_constraintTop_toBottomOf="@id/date_time" app:layout_constraintBottom_toTopOf="@id/separator" app:layout_constraintEnd_toStartOf="@id/ephemeral" - app:tint="?attr/color_warning_600" /> + app:tint="?attr/color_plain_text_security_warning_600" /> diff --git a/app/src/main/res/layout/chat_list_contact_suggestion_cell.xml b/app/src/main/res/layout/chat_list_contact_suggestion_cell.xml new file mode 100644 index 0000000000..c6ff593116 --- /dev/null +++ b/app/src/main/res/layout/chat_list_contact_suggestion_cell.xml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/chat_list_fragment.xml b/app/src/main/res/layout/chat_list_fragment.xml index 36a07c6e33..7e6dfcc3c0 100644 --- a/app/src/main/res/layout/chat_list_fragment.xml +++ b/app/src/main/res/layout/chat_list_fragment.xml @@ -81,14 +81,36 @@ + + diff --git a/app/src/main/res/layout/chat_message_delete_menu.xml b/app/src/main/res/layout/chat_message_delete_menu.xml new file mode 100644 index 0000000000..bdd4f27144 --- /dev/null +++ b/app/src/main/res/layout/chat_message_delete_menu.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/chat_message_forward_fragment.xml b/app/src/main/res/layout/chat_message_forward_fragment.xml index 665be0cf5b..73d9e01d40 100644 --- a/app/src/main/res/layout/chat_message_forward_fragment.xml +++ b/app/src/main/res/layout/chat_message_forward_fragment.xml @@ -23,11 +23,10 @@ android:background="?attr/color_background_contrast_in_dark_mode"> - + app:layout_constraintEnd_toEndOf="parent"> - + - - - + + + + + + + + + diff --git a/app/src/main/res/layout/chat_participant_admin_popup_menu.xml b/app/src/main/res/layout/chat_participant_admin_popup_menu.xml index 813bd3de4b..dc1bb5a28a 100644 --- a/app/src/main/res/layout/chat_participant_admin_popup_menu.xml +++ b/app/src/main/res/layout/chat_participant_admin_popup_menu.xml @@ -32,6 +32,9 @@ + diff --git a/app/src/main/res/layout/contact_device_trust_list_cell.xml b/app/src/main/res/layout/contact_device_trust_list_cell.xml index 97a2c9a86c..c994439127 100644 --- a/app/src/main/res/layout/contact_device_trust_list_cell.xml +++ b/app/src/main/res/layout/contact_device_trust_list_cell.xml @@ -60,10 +60,6 @@ android:layout_height="wrap_content" android:layout_marginEnd="10dp" android:background="@drawable/tertiary_button_background" - android:paddingStart="12dp" - android:paddingEnd="12dp" - android:paddingTop="6dp" - android:paddingBottom="6dp" android:gravity="center" android:drawableStart="@drawable/warning_circle" android:drawablePadding="8dp" diff --git a/app/src/main/res/layout/contact_favourite_list_cell.xml b/app/src/main/res/layout/contact_favourite_list_cell.xml index 8b6b582dea..761efb40fe 100644 --- a/app/src/main/res/layout/contact_favourite_list_cell.xml +++ b/app/src/main/res/layout/contact_favourite_list_cell.xml @@ -19,6 +19,7 @@ + + @@ -30,11 +36,10 @@ android:background="?attr/color_background_contrast_in_dark_mode"> - - - + android:visibility="@{viewModel.existingConversationId.empty ? View.GONE : View.VISIBLE, default=gone}" + app:constraint_referenced_ids="action_media, action_documents, media_documents_actions, media_documents_separator" /> - - - - - - - + + + + + + + + + + - - - - - - - - - - - - + app:layout_constraintEnd_toEndOf="parent"> + + + + + + + + + + + + + + + app:layout_constraintTop_toBottomOf="@id/call_actions"/> - - - - - - - - - + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toEndOf="parent"> + + + + + + + + + + + app:layout_constraintStart_toStartOf="@id/devices_trust" + app:layout_constraintTop_toBottomOf="@id/contact_job_info"/> - - - - + + + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toEndOf="parent"> + + + + + + + + + + + + + + + + + android:padding="10dp" + android:text="@string/conversation_details_media_documents_title" + app:layout_constraintWidth_max="@dimen/section_max_width" + app:layout_constraintBottom_toTopOf="@id/action_media" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/devices_trust" /> - - - + android:background="@drawable/action_background_top" + android:drawableStart="@drawable/image" + android:onClick="@{goToSharedMediaClickListener}" + android:text="@string/conversation_media_list_title" + app:layout_constraintWidth_max="@dimen/section_max_width" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/media_documents_actions" /> - - + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/action_media" /> - + android:background="?attr/color_separator" + android:importantForAccessibility="no" + app:layout_constraintEnd_toEndOf="@id/action_media" + app:layout_constraintStart_toStartOf="@id/action_media" + app:layout_constraintTop_toBottomOf="@id/action_media" /> + app:layout_constraintTop_toBottomOf="@id/action_documents"/> @@ -570,7 +681,8 @@ android:layout_marginStart="16dp" android:layout_marginEnd="16dp" android:background="?attr/color_separator" - android:visibility="@{viewModel.isStored ? View.VISIBLE : View.GONE}" + android:importantForAccessibility="no" + android:visibility="@{viewModel.isStored && !viewModel.isReadOnly ? View.VISIBLE : View.GONE}" app:layout_constraintEnd_toEndOf="@id/action_edit" app:layout_constraintStart_toStartOf="@id/action_edit" app:layout_constraintTop_toBottomOf="@+id/action_edit"/> @@ -581,7 +693,8 @@ android:layout_marginStart="16dp" android:layout_marginEnd="16dp" android:background="?attr/color_separator" - android:visibility="@{viewModel.isStored ? View.VISIBLE : View.GONE}" + android:importantForAccessibility="no" + android:visibility="@{viewModel.isStored && !viewModel.isReadOnly ? View.VISIBLE : View.GONE}" app:layout_constraintEnd_toEndOf="@id/action_favorite" app:layout_constraintStart_toStartOf="@id/action_favorite" app:layout_constraintTop_toBottomOf="@+id/action_favorite"/> @@ -592,7 +705,8 @@ android:layout_marginStart="16dp" android:layout_marginEnd="16dp" android:background="?attr/color_separator" - android:visibility="@{viewModel.isStored ? View.VISIBLE : View.GONE}" + android:importantForAccessibility="no" + android:visibility="@{viewModel.isStored && !viewModel.isReadOnly && !viewModel.isNative() ? View.VISIBLE : View.GONE}" app:layout_constraintEnd_toEndOf="@id/action_share" app:layout_constraintStart_toStartOf="@id/action_share" app:layout_constraintTop_toBottomOf="@+id/action_share"/> diff --git a/app/src/main/res/layout/contact_list_cell.xml b/app/src/main/res/layout/contact_list_cell.xml index 1bcb294915..89031a693c 100644 --- a/app/src/main/res/layout/contact_list_cell.xml +++ b/app/src/main/res/layout/contact_list_cell.xml @@ -22,24 +22,14 @@ - - + android:background="@drawable/primary_cell_background"> diff --git a/app/src/main/res/layout/contact_new_or_edit_cell.xml b/app/src/main/res/layout/contact_new_or_edit_cell.xml index d7dab0d485..a71f56de05 100644 --- a/app/src/main/res/layout/contact_new_or_edit_cell.xml +++ b/app/src/main/res/layout/contact_new_or_edit_cell.xml @@ -40,12 +40,14 @@ android:onClick="@{() -> model.remove()}" android:id="@+id/remove" android:visibility="@{model.showRemoveButton ? View.VISIBLE : View.INVISIBLE, default=invisible}" - android:layout_width="@dimen/icon_size" - android:layout_height="@dimen/icon_size" + android:layout_width="@dimen/large_icon_size" + android:layout_height="@dimen/large_icon_size" + android:padding="5dp" android:layout_marginStart="10dp" android:src="@drawable/x" + android:background="@drawable/squircle_transparent_button_background" android:contentDescription="@string/content_description_contact_remove_field" - app:tint="?attr/color_main2_700" + app:tint="?attr/color_main2_600" app:layout_constraintStart_toEndOf="@id/field" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="@id/field" diff --git a/app/src/main/res/layout/contact_new_or_edit_fragment.xml b/app/src/main/res/layout/contact_new_or_edit_fragment.xml index 24f148d569..88dc9bba8b 100644 --- a/app/src/main/res/layout/contact_new_or_edit_fragment.xml +++ b/app/src/main/res/layout/contact_new_or_edit_fragment.xml @@ -25,12 +25,11 @@ android:background="?attr/color_background_contrast_in_dark_mode"> - - - - - - - - - - - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:paddingBottom="@dimen/screen_bottom_margin"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -89,8 +92,7 @@ android:id="@+id/favourites_contacts_list" android:visibility="@{viewModel.showFavourites && !viewModel.isListFiltered && viewModel.favouritesList.size() > 0 ? View.VISIBLE : View.GONE}" android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginTop="4dp" /> + android:layout_height="wrap_content" /> - + android:layout_height="wrap_content" + android:padding="5dp" + android:text="@string/contacts_list_search_results_limit_reached_label" + android:textColor="?attr/color_main2_600" + android:textSize="12sp" + android:maxLines="2" + android:ellipsize="end" + android:gravity="center" + android:visibility="@{viewModel.showResultsLimitReached ? View.VISIBLE : View.GONE, default=gone}"/> + + + + + + @@ -129,9 +152,10 @@ + + @@ -66,7 +72,7 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:text="@string/contact_details_delete" - android:visibility="@{isStored ? View.VISIBLE : View.GONE}" + android:visibility="@{isStored && !isReadOnly && !isNative ? View.VISIBLE : View.GONE}" android:background="@drawable/menu_item_background" android:layout_marginBottom="1dp" android:drawableStart="@drawable/trash_simple" diff --git a/app/src/main/res/layout/dialog_assistant_accept_conditions_and_policy.xml b/app/src/main/res/layout/dialog_assistant_accept_conditions_and_policy.xml index a4963a9401..2c68e0bc6b 100644 --- a/app/src/main/res/layout/dialog_assistant_accept_conditions_and_policy.xml +++ b/app/src/main/res/layout/dialog_assistant_accept_conditions_and_policy.xml @@ -63,40 +63,43 @@ app:layout_constraintTop_toBottomOf="@id/title" /> + app:layout_constraintEnd_toStartOf="@id/cancel" + app:layout_constraintTop_toTopOf="@id/cancel" + app:layout_constraintBottom_toBottomOf="@id/cancel"/> diff --git a/app/src/main/res/layout/dialog_assistant_create_account_confirm_phone_number.xml b/app/src/main/res/layout/dialog_assistant_create_account_confirm_phone_number.xml index ef0df9768b..ad59fff4f1 100644 --- a/app/src/main/res/layout/dialog_assistant_create_account_confirm_phone_number.xml +++ b/app/src/main/res/layout/dialog_assistant_create_account_confirm_phone_number.xml @@ -60,40 +60,43 @@ app:layout_constraintTop_toBottomOf="@id/title" /> + app:layout_constraintEnd_toStartOf="@id/cancel" + app:layout_constraintTop_toTopOf="@id/cancel" + app:layout_constraintBottom_toBottomOf="@id/cancel"/> diff --git a/app/src/main/res/layout/dialog_assistant_create_account_phone_number_validation_not_available.xml b/app/src/main/res/layout/dialog_assistant_create_account_phone_number_validation_not_available.xml new file mode 100644 index 0000000000..059010a2a5 --- /dev/null +++ b/app/src/main/res/layout/dialog_assistant_create_account_phone_number_validation_not_available.xml @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_call_confirm_transfer.xml b/app/src/main/res/layout/dialog_call_confirm_transfer.xml index 2faa4f119e..877f30dd7a 100644 --- a/app/src/main/res/layout/dialog_call_confirm_transfer.xml +++ b/app/src/main/res/layout/dialog_call_confirm_transfer.xml @@ -11,7 +11,7 @@ @@ -60,40 +60,43 @@ app:layout_constraintTop_toBottomOf="@id/title" /> + app:layout_constraintEnd_toStartOf="@id/cancel" + app:layout_constraintTop_toTopOf="@id/cancel" + app:layout_constraintBottom_toBottomOf="@id/cancel"/> diff --git a/app/src/main/res/layout/dialog_cancel_contact_changes.xml b/app/src/main/res/layout/dialog_cancel_contact_changes.xml index 47c5b77542..42693bc63c 100644 --- a/app/src/main/res/layout/dialog_cancel_contact_changes.xml +++ b/app/src/main/res/layout/dialog_cancel_contact_changes.xml @@ -60,40 +60,43 @@ app:layout_constraintTop_toBottomOf="@id/title" /> + app:layout_constraintEnd_toStartOf="@id/cancel" + app:layout_constraintTop_toTopOf="@id/cancel" + app:layout_constraintBottom_toBottomOf="@id/cancel"/> diff --git a/app/src/main/res/layout/dialog_cancel_meeting.xml b/app/src/main/res/layout/dialog_cancel_meeting.xml index 289c90956b..c924559755 100644 --- a/app/src/main/res/layout/dialog_cancel_meeting.xml +++ b/app/src/main/res/layout/dialog_cancel_meeting.xml @@ -37,7 +37,7 @@ android:layout_height="wrap_content" android:layout_marginStart="15dp" android:paddingTop="@dimen/dialog_top_bottom_margin" - android:text="@string/meeting_schedule_cancel_dialog_title" + android:text="@string/meeting_schedule_notify_cancel_dialog_title" app:layout_constraintVertical_chainStyle="packed" app:layout_constraintBottom_toTopOf="@id/message" app:layout_constraintStart_toStartOf="@id/dialog_background" @@ -52,7 +52,7 @@ android:layout_marginStart="15dp" android:layout_marginEnd="15dp" android:layout_marginTop="10dp" - android:text="@string/meeting_schedule_cancel_dialog_message" + android:text="@string/meeting_schedule_notify_cancel_dialog_message" android:textSize="14sp" app:layout_constraintBottom_toTopOf="@id/cancel" app:layout_constraintStart_toStartOf="@id/dialog_background" @@ -60,40 +60,59 @@ app:layout_constraintTop_toBottomOf="@id/title" /> + + + app:layout_constraintBottom_toTopOf="@id/anchor"/> diff --git a/app/src/main/res/layout/dialog_confirm_turning_on_vfs.xml b/app/src/main/res/layout/dialog_confirm_turning_on_vfs.xml index 133663db09..b6421cdbee 100644 --- a/app/src/main/res/layout/dialog_confirm_turning_on_vfs.xml +++ b/app/src/main/res/layout/dialog_confirm_turning_on_vfs.xml @@ -11,7 +11,7 @@ @@ -60,40 +60,43 @@ app:layout_constraintTop_toBottomOf="@id/title" /> + app:layout_constraintEnd_toStartOf="@id/cancel" + app:layout_constraintTop_toTopOf="@id/cancel" + app:layout_constraintBottom_toBottomOf="@id/cancel"/> diff --git a/app/src/main/res/layout/dialog_contact_confirm_trust_call.xml b/app/src/main/res/layout/dialog_contact_confirm_trust_call.xml index 6ceab24e0c..57ea833929 100644 --- a/app/src/main/res/layout/dialog_contact_confirm_trust_call.xml +++ b/app/src/main/res/layout/dialog_contact_confirm_trust_call.xml @@ -60,33 +60,36 @@ app:layout_constraintTop_toBottomOf="@id/title" /> + app:layout_constraintEnd_toStartOf="@id/cancel" + app:layout_constraintTop_toTopOf="@id/cancel" + app:layout_constraintBottom_toBottomOf="@id/cancel"/> diff --git a/app/src/main/res/layout/dialog_delete_contact.xml b/app/src/main/res/layout/dialog_delete_contact.xml index 9c711e26e5..2daece143a 100644 --- a/app/src/main/res/layout/dialog_delete_contact.xml +++ b/app/src/main/res/layout/dialog_delete_contact.xml @@ -60,40 +60,43 @@ app:layout_constraintTop_toBottomOf="@id/title" /> + app:layout_constraintEnd_toStartOf="@id/cancel" + app:layout_constraintTop_toTopOf="@id/cancel" + app:layout_constraintBottom_toBottomOf="@id/cancel"/> diff --git a/app/src/main/res/layout/dialog_delete_meeting.xml b/app/src/main/res/layout/dialog_delete_meeting.xml index 3ea8a90762..c8d20e218d 100644 --- a/app/src/main/res/layout/dialog_delete_meeting.xml +++ b/app/src/main/res/layout/dialog_delete_meeting.xml @@ -60,40 +60,43 @@ app:layout_constraintTop_toBottomOf="@id/title" /> + app:layout_constraintEnd_toStartOf="@id/cancel" + app:layout_constraintTop_toTopOf="@id/cancel" + app:layout_constraintBottom_toBottomOf="@id/cancel"/> diff --git a/app/src/main/res/layout/dialog_kick_from_conference.xml b/app/src/main/res/layout/dialog_kick_from_conference.xml index a92db6ba39..7c64b5411f 100644 --- a/app/src/main/res/layout/dialog_kick_from_conference.xml +++ b/app/src/main/res/layout/dialog_kick_from_conference.xml @@ -60,40 +60,43 @@ app:layout_constraintTop_toBottomOf="@id/title" /> + app:layout_constraintEnd_toStartOf="@id/cancel" + app:layout_constraintTop_toTopOf="@id/cancel" + app:layout_constraintBottom_toBottomOf="@id/cancel"/> diff --git a/app/src/main/res/layout/dialog_leave_group_conversation.xml b/app/src/main/res/layout/dialog_leave_group_conversation.xml new file mode 100644 index 0000000000..2fdaf25ad6 --- /dev/null +++ b/app/src/main/res/layout/dialog_leave_group_conversation.xml @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_manage_account_international_prefix_help.xml b/app/src/main/res/layout/dialog_manage_account_international_prefix_help.xml index beeb1f250a..d269499029 100644 --- a/app/src/main/res/layout/dialog_manage_account_international_prefix_help.xml +++ b/app/src/main/res/layout/dialog_manage_account_international_prefix_help.xml @@ -61,15 +61,14 @@ diff --git a/app/src/main/res/layout/dialog_manage_account_outbound_proxy_help.xml b/app/src/main/res/layout/dialog_manage_account_outbound_proxy_help.xml new file mode 100644 index 0000000000..013370d481 --- /dev/null +++ b/app/src/main/res/layout/dialog_manage_account_outbound_proxy_help.xml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_merge_calls_into_conference.xml b/app/src/main/res/layout/dialog_merge_calls_into_conference.xml index 505cd00706..f1e6f99c7a 100644 --- a/app/src/main/res/layout/dialog_merge_calls_into_conference.xml +++ b/app/src/main/res/layout/dialog_merge_calls_into_conference.xml @@ -45,40 +45,43 @@ app:layout_constraintTop_toTopOf="parent" /> + app:layout_constraintEnd_toStartOf="@id/cancel" + app:layout_constraintTop_toTopOf="@id/cancel" + app:layout_constraintBottom_toBottomOf="@id/cancel"/> diff --git a/app/src/main/res/layout/dialog_number_address_list_cell.xml b/app/src/main/res/layout/dialog_number_address_list_cell.xml index f138e636c2..931fe6a22c 100644 --- a/app/src/main/res/layout/dialog_number_address_list_cell.xml +++ b/app/src/main/res/layout/dialog_number_address_list_cell.xml @@ -52,6 +52,7 @@ android:layout_marginTop="10dp" android:background="?attr/color_separator" android:visibility="gone" + android:importantForAccessibility="no" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@+id/number_or_address" diff --git a/app/src/main/res/layout/dialog_open_export_file.xml b/app/src/main/res/layout/dialog_open_export_file.xml index 5a156e916f..a62cff38de 100644 --- a/app/src/main/res/layout/dialog_open_export_file.xml +++ b/app/src/main/res/layout/dialog_open_export_file.xml @@ -60,40 +60,62 @@ app:layout_constraintTop_toBottomOf="@id/title" /> + app:layout_constraintEnd_toStartOf="@id/save" + app:layout_constraintTop_toTopOf="@id/cancel" + app:layout_constraintBottom_toBottomOf="@id/cancel"/> + + diff --git a/app/src/main/res/layout/dialog_open_plain_text.xml b/app/src/main/res/layout/dialog_open_plain_text.xml index 51d32173d9..f9538ee91c 100644 --- a/app/src/main/res/layout/dialog_open_plain_text.xml +++ b/app/src/main/res/layout/dialog_open_plain_text.xml @@ -60,40 +60,43 @@ app:layout_constraintTop_toBottomOf="@id/title" /> + app:layout_constraintEnd_toStartOf="@id/cancel" + app:layout_constraintTop_toTopOf="@id/cancel" + app:layout_constraintBottom_toBottomOf="@id/cancel"/> diff --git a/app/src/main/res/layout/dialog_remove_account.xml b/app/src/main/res/layout/dialog_remove_account.xml index c0f5528d6c..169af2979d 100644 --- a/app/src/main/res/layout/dialog_remove_account.xml +++ b/app/src/main/res/layout/dialog_remove_account.xml @@ -62,40 +62,43 @@ app:layout_constraintTop_toBottomOf="@id/title" /> + app:layout_constraintEnd_toStartOf="@id/cancel" + app:layout_constraintTop_toTopOf="@id/cancel" + app:layout_constraintBottom_toBottomOf="@id/cancel"/> diff --git a/app/src/main/res/layout/dialog_remove_all_call_logs.xml b/app/src/main/res/layout/dialog_remove_all_call_logs.xml index 87446e7245..76a8bed451 100644 --- a/app/src/main/res/layout/dialog_remove_all_call_logs.xml +++ b/app/src/main/res/layout/dialog_remove_all_call_logs.xml @@ -60,40 +60,43 @@ app:layout_constraintTop_toBottomOf="@id/title" /> + app:layout_constraintEnd_toStartOf="@id/cancel" + app:layout_constraintTop_toTopOf="@id/cancel" + app:layout_constraintBottom_toBottomOf="@id/cancel"/> diff --git a/app/src/main/res/layout/dialog_remove_call_log.xml b/app/src/main/res/layout/dialog_remove_call_log.xml new file mode 100644 index 0000000000..0cd69b8482 --- /dev/null +++ b/app/src/main/res/layout/dialog_remove_call_log.xml @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_remove_call_logs.xml b/app/src/main/res/layout/dialog_remove_call_logs.xml index c708ab7916..5474359346 100644 --- a/app/src/main/res/layout/dialog_remove_call_logs.xml +++ b/app/src/main/res/layout/dialog_remove_call_logs.xml @@ -60,40 +60,43 @@ app:layout_constraintTop_toBottomOf="@id/title" /> + app:layout_constraintEnd_toStartOf="@id/cancel" + app:layout_constraintTop_toTopOf="@id/cancel" + app:layout_constraintBottom_toBottomOf="@id/cancel"/> diff --git a/app/src/main/res/layout/dialog_remove_conversation.xml b/app/src/main/res/layout/dialog_remove_conversation.xml new file mode 100644 index 0000000000..47a558cf83 --- /dev/null +++ b/app/src/main/res/layout/dialog_remove_conversation.xml @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_remove_conversation_history.xml b/app/src/main/res/layout/dialog_remove_conversation_history.xml index 4a1d7a43e6..d1e4f8b674 100644 --- a/app/src/main/res/layout/dialog_remove_conversation_history.xml +++ b/app/src/main/res/layout/dialog_remove_conversation_history.xml @@ -60,40 +60,43 @@ app:layout_constraintTop_toBottomOf="@id/title" /> + app:layout_constraintEnd_toStartOf="@id/cancel" + app:layout_constraintTop_toTopOf="@id/cancel" + app:layout_constraintBottom_toBottomOf="@id/cancel"/> diff --git a/app/src/main/res/layout/dialog_remove_participant_from_group.xml b/app/src/main/res/layout/dialog_remove_participant_from_group.xml new file mode 100644 index 0000000000..93f0711e6c --- /dev/null +++ b/app/src/main/res/layout/dialog_remove_participant_from_group.xml @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_set_or_edit_group_subject.xml b/app/src/main/res/layout/dialog_set_or_edit_group_subject.xml index 41c3d7c68f..37ce674e27 100644 --- a/app/src/main/res/layout/dialog_set_or_edit_group_subject.xml +++ b/app/src/main/res/layout/dialog_set_or_edit_group_subject.xml @@ -44,6 +44,7 @@ android:layout_height="wrap_content" android:layout_marginStart="15dp" android:paddingTop="@dimen/dialog_top_bottom_margin" + android:labelFor="@id/subject" android:text="@{viewModel.isGroupConversation ? viewModel.isEdit ? @string/conversation_dialog_edit_subject : @string/conversation_dialog_set_subject : @string/history_group_call_start_dialog_set_subject, default=@string/history_group_call_start_dialog_set_subject}" app:layout_constraintVertical_chainStyle="packed" app:layout_constraintBottom_toTopOf="@id/subject" @@ -89,41 +90,44 @@ app:layout_constraintEnd_toEndOf="@id/subject"/> + app:layout_constraintEnd_toStartOf="@id/cancel" + app:layout_constraintTop_toTopOf="@id/cancel" + app:layout_constraintBottom_toBottomOf="@id/cancel"/> diff --git a/app/src/main/res/layout/dialog_start_group_call_from_conversation.xml b/app/src/main/res/layout/dialog_start_group_call_from_conversation.xml index 9dd6e744f1..31945ac91e 100644 --- a/app/src/main/res/layout/dialog_start_group_call_from_conversation.xml +++ b/app/src/main/res/layout/dialog_start_group_call_from_conversation.xml @@ -62,40 +62,43 @@ app:layout_constraintTop_toBottomOf="@id/title" /> + app:layout_constraintEnd_toStartOf="@id/cancel" + app:layout_constraintTop_toTopOf="@id/cancel" + app:layout_constraintBottom_toBottomOf="@id/cancel"/> diff --git a/app/src/main/res/layout/dialog_update_account_password.xml b/app/src/main/res/layout/dialog_update_account_password.xml index 6f9fa12e84..377e20a37d 100644 --- a/app/src/main/res/layout/dialog_update_account_password.xml +++ b/app/src/main/res/layout/dialog_update_account_password.xml @@ -84,40 +84,43 @@ app:layout_constraintBottom_toBottomOf="@id/password" /> + app:layout_constraintEnd_toStartOf="@id/cancel" + app:layout_constraintTop_toTopOf="@id/cancel" + app:layout_constraintBottom_toBottomOf="@id/cancel"/> diff --git a/app/src/main/res/layout/dialog_update_account_password_after_register_failure.xml b/app/src/main/res/layout/dialog_update_account_password_after_register_failure.xml index 553d7aa8f7..ff6b42ed02 100644 --- a/app/src/main/res/layout/dialog_update_account_password_after_register_failure.xml +++ b/app/src/main/res/layout/dialog_update_account_password_after_register_failure.xml @@ -100,40 +100,43 @@ app:layout_constraintBottom_toBottomOf="@id/password" /> + app:layout_constraintEnd_toStartOf="@id/cancel" + app:layout_constraintTop_toTopOf="@id/cancel" + app:layout_constraintBottom_toBottomOf="@id/cancel"/> diff --git a/app/src/main/res/layout/dialog_update_available.xml b/app/src/main/res/layout/dialog_update_available.xml index f0a323cbb8..d6dc1d8b7c 100644 --- a/app/src/main/res/layout/dialog_update_available.xml +++ b/app/src/main/res/layout/dialog_update_available.xml @@ -60,40 +60,43 @@ app:layout_constraintTop_toBottomOf="@id/title" /> + app:layout_constraintEnd_toStartOf="@id/cancel" + app:layout_constraintTop_toTopOf="@id/cancel" + app:layout_constraintBottom_toBottomOf="@id/cancel"/> diff --git a/app/src/main/res/layout/dialog_zrtp_sas_validation.xml b/app/src/main/res/layout/dialog_zrtp_sas_validation.xml index fc5e2249cd..55880e6885 100644 --- a/app/src/main/res/layout/dialog_zrtp_sas_validation.xml +++ b/app/src/main/res/layout/dialog_zrtp_sas_validation.xml @@ -63,6 +63,7 @@ android:text="@string/call_zrtp_sas_validation_skip" android:textSize="13sp" android:textColor="@color/bc_white" + android:background="@drawable/squircle_transparent_button_background" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="parent"/> @@ -111,8 +112,9 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@{viewModel.localToken, default=`ZZ`}" - android:textSize="18sp" + android:textSize="32sp" android:textColor="@color/gray_main2_600" + android:includeFontPadding="false" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@id/local_code_label" /> @@ -228,23 +230,24 @@ android:onClick="@{() -> viewModel.notFound()}" style="@style/default_text_style_600" android:id="@+id/nothing_matches" - android:layout_width="0dp" + android:layout_width="wrap_content" android:layout_height="wrap_content" + android:paddingStart="15dp" + android:paddingEnd="15dp" android:layout_marginTop="15dp" android:layout_marginBottom="15dp" - android:layout_marginStart="30dp" - android:layout_marginEnd="30dp" + android:layout_marginEnd="16dp" android:paddingBottom="@dimen/primary_secondary_buttons_label_padding" android:paddingTop="@dimen/primary_secondary_buttons_label_padding" android:gravity="center" - android:background="@drawable/shape_red_outlined_button_background" + android:background="@drawable/squircle_red_outlined_button_background" android:text="@string/call_dialog_zrtp_validate_trust_letters_do_not_match" - android:textSize="18sp" - android:textColor="?attr/color_danger_500" + android:textSize="13sp" + android:textColor="@color/danger_500" android:maxLines="1" android:ellipsize="end" app:layout_constraintWidth_max="@dimen/button_max_width" - app:layout_constraintStart_toStartOf="parent" + app:layout_constrainedWidth="true" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@id/remote_tokens" app:layout_constraintBottom_toBottomOf="parent"/> diff --git a/app/src/main/res/layout/dialog_zrtp_security_alert.xml b/app/src/main/res/layout/dialog_zrtp_security_alert.xml index 6f2e309244..f2044b5359 100644 --- a/app/src/main/res/layout/dialog_zrtp_security_alert.xml +++ b/app/src/main/res/layout/dialog_zrtp_security_alert.xml @@ -106,23 +106,24 @@ style="@style/default_text_style_600" android:id="@+id/hang_up" android:onClick="@{() -> viewModel.hangUp()}" - android:layout_width="0dp" + android:layout_width="wrap_content" android:layout_height="wrap_content" + android:paddingStart="15dp" + android:paddingEnd="15dp" android:layout_marginTop="15dp" android:layout_marginBottom="15dp" - android:layout_marginStart="30dp" - android:layout_marginEnd="30dp" + android:layout_marginEnd="16dp" android:paddingBottom="@dimen/primary_secondary_buttons_label_padding" android:paddingTop="@dimen/primary_secondary_buttons_label_padding" android:gravity="center" - android:background="@drawable/shape_red_button_background" + android:background="@drawable/squircle_red_button_background" android:text="@string/call_action_hang_up" - android:textSize="18sp" + android:textSize="13sp" android:textColor="@color/bc_white" android:maxLines="1" android:ellipsize="end" + app:layout_constrainedWidth="true" app:layout_constraintWidth_max="@dimen/button_max_width" - app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@id/try_again" app:layout_constraintBottom_toBottomOf="parent"/> diff --git a/app/src/main/res/layout/drawer_menu.xml b/app/src/main/res/layout/drawer_menu.xml index b18c332274..08e26d4348 100644 --- a/app/src/main/res/layout/drawer_menu.xml +++ b/app/src/main/res/layout/drawer_menu.xml @@ -22,225 +22,250 @@ - - + android:layout_height="match_parent"> - - - + app:layout_constraintEnd_toEndOf="parent"> - + + + android:layout_marginStart="16dp" + android:layout_marginEnd="16dp" + android:text="@string/app_name" + android:gravity="center_vertical" + app:layout_constraintTop_toTopOf="@id/brand_icon" + app:layout_constraintStart_toEndOf="@id/brand_icon" + app:layout_constraintBottom_toBottomOf="@id/brand_icon" + app:layout_constraintEnd_toStartOf="@id/close_drawer" /> - - - + + + + + android:orientation="vertical" + entries="@{viewModel.accounts}" + layout="@{@layout/account_list_cell}"> - + - + - + - + - + - + - + - + - + - + + + + + + + diff --git a/app/src/main/res/layout/drawer_shortcuts_list_cell.xml b/app/src/main/res/layout/drawer_shortcuts_list_cell.xml index 61690e4720..7f047f08ca 100644 --- a/app/src/main/res/layout/drawer_shortcuts_list_cell.xml +++ b/app/src/main/res/layout/drawer_shortcuts_list_cell.xml @@ -12,8 +12,9 @@ + android:layout_marginBottom="10dp" + android:padding="5dp" + android:background="@drawable/squircle_transparent_button_background"> + android:background="?attr/color_grey_100"> diff --git a/app/src/main/res/layout/file_media_viewer_activity.xml b/app/src/main/res/layout/file_media_viewer_activity.xml index c0fde0e675..b20cc7e3a5 100644 --- a/app/src/main/res/layout/file_media_viewer_activity.xml +++ b/app/src/main/res/layout/file_media_viewer_activity.xml @@ -48,7 +48,7 @@ + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toBottomOf="@id/top_bar_background" /> + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toBottomOf="@id/top_bar_background" /> + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toBottomOf="@id/top_bar_background" /> + + @@ -62,35 +66,36 @@ - + app:layout_constraintEnd_toStartOf="@id/duration" /> diff --git a/app/src/main/res/layout/file_viewer_activity.xml b/app/src/main/res/layout/file_viewer_activity.xml index cc24247b46..328f6793aa 100644 --- a/app/src/main/res/layout/file_viewer_activity.xml +++ b/app/src/main/res/layout/file_viewer_activity.xml @@ -86,7 +86,7 @@ + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toBottomOf="@id/top_bar_barrier" /> + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toBottomOf="@id/top_bar_barrier" /> + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toBottomOf="@id/top_bar_barrier" /> - - - - - + app:layout_constraintEnd_toEndOf="parent"> - - - - - - - - - - - - - + android:layout_marginTop="10dp" + android:layout_marginStart="10dp" + android:text="@{viewModel.selectionCount, default=`0 selected`}" + android:textSize="12sp" + android:textColor="?attr/color_main2_900" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintTop_toTopOf="parent" /> + + + + + + + + + + + + + + + + + + + + + + - - + android:paddingBottom="5dp" + android:background="@drawable/primary_cell_background"> diff --git a/app/src/main/res/layout/generic_address_picker_conversation_list_cell.xml b/app/src/main/res/layout/generic_address_picker_conversation_list_cell.xml index 481e3ff9d1..232b65ff58 100644 --- a/app/src/main/res/layout/generic_address_picker_conversation_list_cell.xml +++ b/app/src/main/res/layout/generic_address_picker_conversation_list_cell.xml @@ -18,21 +18,10 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginStart="4dp" - android:layout_marginEnd="16dp" + android:layout_marginEnd="4dp" android:paddingTop="5dp" - android:paddingBottom="5dp"> - - + android:paddingBottom="5dp" + android:background="@drawable/primary_cell_background"> diff --git a/app/src/main/res/layout/generic_address_picker_suggestion_list_cell.xml b/app/src/main/res/layout/generic_address_picker_suggestion_list_cell.xml index d3aa2f575c..dab6c641dd 100644 --- a/app/src/main/res/layout/generic_address_picker_suggestion_list_cell.xml +++ b/app/src/main/res/layout/generic_address_picker_suggestion_list_cell.xml @@ -17,8 +17,8 @@ android:onClick="@{onClickListener}" android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_marginStart="16dp" - android:layout_marginEnd="16dp" + android:layout_marginStart="4dp" + android:layout_marginEnd="4dp" android:paddingTop="5dp" android:paddingBottom="5dp" android:background="@drawable/primary_cell_background"> @@ -27,6 +27,7 @@ android:id="@+id/avatar" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:layout_marginStart="12dp" android:layout_marginTop="5dp" android:layout_marginBottom="5dp" layout="@layout/contact_avatar" @@ -41,6 +42,7 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginStart="10dp" + android:layout_marginEnd="12dp" android:text="@{model.name, default=`john.doe`}" android:textSize="14sp" android:maxLines="1" @@ -55,6 +57,7 @@ android:id="@+id/selected" android:layout_width="@dimen/icon_size" android:layout_height="@dimen/icon_size" + android:layout_marginEnd="12dp" android:contentDescription="@null" android:src="@drawable/check" android:visibility="@{model.selected ? View.VISIBLE : View.GONE, default=gone}" @@ -70,6 +73,7 @@ android:layout_marginEnd="10dp" android:background="?attr/color_separator" android:visibility="gone" + android:importantForAccessibility="no" app:layout_constraintStart_toStartOf="@id/name" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintBottom_toBottomOf="parent"/> diff --git a/app/src/main/res/layout/help_debug_fragment.xml b/app/src/main/res/layout/help_debug_fragment.xml index 4f8cdbaa70..d3f54ce506 100644 --- a/app/src/main/res/layout/help_debug_fragment.xml +++ b/app/src/main/res/layout/help_debug_fragment.xml @@ -29,12 +29,11 @@ android:background="?attr/color_background_contrast_in_dark_mode"> + android:layout_height="wrap_content" + android:paddingBottom="@dimen/screen_bottom_margin"> - - - - - - - - - + app:layout_constraintEnd_toEndOf="parent"> - + - - - - - + - + - + + + + + + + + + + + + + + + + + + + + + + + - - + app:layout_constraintVertical_bias="0" + app:layout_constraintStart_toStartOf="@id/info_section" + app:layout_constraintTop_toBottomOf="@id/info_section" + app:layout_constraintBottom_toBottomOf="parent"/> diff --git a/app/src/main/res/layout/help_fragment.xml b/app/src/main/res/layout/help_fragment.xml index 5e3f4711f6..f26cb80b02 100644 --- a/app/src/main/res/layout/help_fragment.xml +++ b/app/src/main/res/layout/help_fragment.xml @@ -38,12 +38,11 @@ android:background="?attr/color_background_contrast_in_dark_mode"> + android:layout_height="wrap_content" + android:paddingBottom="@dimen/screen_bottom_margin"> - - - - - - - + app:layout_constraintEnd_toEndOf="parent"> + + - - - + - - - + + + + android:layout_marginStart="16dp" + android:layout_marginEnd="16dp" + android:layout_marginTop="16dp" + android:text="@string/help_about_privacy_policy_title" + android:drawableEnd="@drawable/arrow_square_out" + android:drawablePadding="5dp" + app:drawableTint="?attr/color_main2_600" + app:layout_constraintTop_toBottomOf="@id/user_guide_subtitle" + app:layout_constraintStart_toEndOf="@id/privacy_policy_icon" + app:layout_constraintEnd_toEndOf="parent"/> - - + android:textColor="?attr/color_main2_600" + app:layout_constraintTop_toBottomOf="@id/privacy_policy_title" + app:layout_constraintStart_toEndOf="@id/privacy_policy_icon" + app:layout_constraintEnd_toEndOf="parent" /> + + + + + + + + + + - - - - - + + + + + - + - + + + - + - + - + - - - + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toEndOf="parent"> + + - + + + + + diff --git a/app/src/main/res/layout/history_call_list_cell.xml b/app/src/main/res/layout/history_call_list_cell.xml index 68d70c5b24..750627d643 100644 --- a/app/src/main/res/layout/history_call_list_cell.xml +++ b/app/src/main/res/layout/history_call_list_cell.xml @@ -76,6 +76,7 @@ android:layout_marginEnd="10dp" android:background="?attr/color_separator" android:visibility="gone" + android:importantForAccessibility="no" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintBottom_toBottomOf="parent"/> diff --git a/app/src/main/res/layout/history_fragment.xml b/app/src/main/res/layout/history_fragment.xml index 4165807615..a637f497d4 100644 --- a/app/src/main/res/layout/history_fragment.xml +++ b/app/src/main/res/layout/history_fragment.xml @@ -30,45 +30,43 @@ android:background="?attr/color_background_contrast_in_dark_mode"> + app:layout_constraintTop_toTopOf="@id/invisible_title"/> + app:layout_constraintTop_toTopOf="@id/invisible_title" /> + android:layout_height="wrap_content" + android:paddingBottom="@dimen/screen_bottom_margin"> - - - - - - - + + + + + + + + + + + + + app:layout_constraintEnd_toEndOf="parent"> - + - + - - - - - - - + - - - - - - - + - + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/history_list_cell.xml b/app/src/main/res/layout/history_list_cell.xml index 529ce524aa..78e2ae522b 100644 --- a/app/src/main/res/layout/history_list_cell.xml +++ b/app/src/main/res/layout/history_list_cell.xml @@ -22,6 +22,7 @@ @@ -100,6 +103,7 @@ android:layout_marginEnd="10dp" android:background="?attr/color_separator" android:visibility="gone" + android:importantForAccessibility="no" app:layout_constraintStart_toStartOf="@id/name" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintBottom_toBottomOf="parent"/> diff --git a/app/src/main/res/layout/history_list_contact_suggestion_cell.xml b/app/src/main/res/layout/history_list_contact_suggestion_cell.xml new file mode 100644 index 0000000000..3d05553380 --- /dev/null +++ b/app/src/main/res/layout/history_list_contact_suggestion_cell.xml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/history_list_fragment.xml b/app/src/main/res/layout/history_list_fragment.xml index 0baddd46be..f22dfd9c8f 100644 --- a/app/src/main/res/layout/history_list_fragment.xml +++ b/app/src/main/res/layout/history_list_fragment.xml @@ -12,7 +12,7 @@ name="onConversationsClicked" type="View.OnClickListener" /> + + + - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/history_popup_menu.xml b/app/src/main/res/layout/history_popup_menu.xml index c220c5a9a0..cae4d9213b 100644 --- a/app/src/main/res/layout/history_popup_menu.xml +++ b/app/src/main/res/layout/history_popup_menu.xml @@ -23,6 +23,9 @@ + + app:layout_constraintBottom_toTopOf="@id/bottom_anchor"/> diff --git a/app/src/main/res/layout/main_activity_alert_top_bar.xml b/app/src/main/res/layout/main_activity_alert_top_bar.xml index 985271a1d9..b4f9ed0780 100644 --- a/app/src/main/res/layout/main_activity_alert_top_bar.xml +++ b/app/src/main/res/layout/main_activity_alert_top_bar.xml @@ -17,7 +17,7 @@ android:onClick="@{() -> viewModel.onTopBarClicked()}"> diff --git a/app/src/main/res/layout/main_activity_in_call_top_bar.xml b/app/src/main/res/layout/main_activity_in_call_top_bar.xml index 9130c5bb24..001103b6da 100644 --- a/app/src/main/res/layout/main_activity_in_call_top_bar.xml +++ b/app/src/main/res/layout/main_activity_in_call_top_bar.xml @@ -17,7 +17,7 @@ android:onClick="@{() -> viewModel.onCallTopBarClicked()}"> diff --git a/app/src/main/res/layout/main_activity_pending_sharing_top_bar.xml b/app/src/main/res/layout/main_activity_pending_sharing_top_bar.xml index f7f742ccfb..ee8f98977b 100644 --- a/app/src/main/res/layout/main_activity_pending_sharing_top_bar.xml +++ b/app/src/main/res/layout/main_activity_pending_sharing_top_bar.xml @@ -16,7 +16,7 @@ android:background="@drawable/color_main_activity_top_bar"> + @@ -47,25 +50,23 @@ app:barrierDirection="bottom" /> + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toBottomOf="parent" /> + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toBottomOf="parent" /> + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toBottomOf="parent" /> + app:layout_constraintTop_toTopOf="@id/search" /> + app:layout_constraintTop_toTopOf="@id/search" /> diff --git a/app/src/main/res/layout/meeting_edit_fragment.xml b/app/src/main/res/layout/meeting_edit_fragment.xml index 21800ace36..a7b5c4945a 100644 --- a/app/src/main/res/layout/meeting_edit_fragment.xml +++ b/app/src/main/res/layout/meeting_edit_fragment.xml @@ -35,11 +35,10 @@ android:background="?attr/color_background_contrast_in_dark_mode"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - - - - + android:paddingBottom="@dimen/screen_bottom_margin"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/meeting_fragment.xml b/app/src/main/res/layout/meeting_fragment.xml index 48f50b7312..9ebecc928a 100644 --- a/app/src/main/res/layout/meeting_fragment.xml +++ b/app/src/main/res/layout/meeting_fragment.xml @@ -15,7 +15,7 @@ name="menuClickListener" type="View.OnClickListener" /> @@ -140,36 +139,26 @@ - - + app:layout_constraintEnd_toEndOf="parent" /> @@ -227,11 +219,12 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginTop="16dp" - android:layout_marginStart="16dp" - android:layout_marginEnd="16dp" + android:layout_marginStart="10dp" + android:layout_marginEnd="10dp" android:text="@{viewModel.description, default=`Lorem ipsum dolor sit amet`}" android:textSize="14sp" android:textColor="?attr/color_main2_600" + android:includeFontPadding="false" android:maxLines="3" android:ellipsize="end" android:drawableStart="@drawable/file_text" @@ -248,6 +241,7 @@ android:layout_height="1dp" android:layout_marginTop="16dp" android:background="?attr/color_separator" + android:importantForAccessibility="no" android:visibility="@{viewModel.description.length() > 0 ? View.VISIBLE : View.GONE}" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" @@ -265,7 +259,7 @@ android:layout_width="@dimen/icon_size" android:layout_height="@dimen/icon_size" android:layout_marginTop="32dp" - android:layout_marginStart="16dp" + android:layout_marginStart="10dp" android:src="@drawable/user_square" android:contentDescription="@string/content_description_meeting_participants_list" android:visibility="@{viewModel.isBroadcast ? View.VISIBLE : View.GONE}" @@ -278,8 +272,8 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginTop="16dp" - android:layout_marginStart="16dp" - android:layout_marginEnd="16dp" + android:layout_marginStart="10dp" + android:layout_marginEnd="10dp" android:visibility="@{viewModel.isBroadcast ? View.VISIBLE : View.GONE}" android:orientation="vertical" entries="@{viewModel.speakers}" @@ -294,6 +288,7 @@ android:layout_height="1dp" android:layout_marginTop="16dp" android:background="?attr/color_separator" + android:importantForAccessibility="no" android:visibility="@{viewModel.isBroadcast ? View.VISIBLE : View.GONE}" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" @@ -304,7 +299,7 @@ android:layout_width="@dimen/icon_size" android:layout_height="@dimen/icon_size" android:layout_marginTop="32dp" - android:layout_marginStart="16dp" + android:layout_marginStart="10dp" android:src="@drawable/users" android:contentDescription="@string/content_description_meeting_participants_list" app:layout_constraintTop_toBottomOf="@id/separator_4" @@ -316,8 +311,8 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginTop="16dp" - android:layout_marginStart="16dp" - android:layout_marginEnd="16dp" + android:layout_marginStart="10dp" + android:layout_marginEnd="10dp" android:nestedScrollingEnabled="true" app:layout_constrainedHeight="true" app:layout_constraintVertical_bias="0" @@ -335,6 +330,7 @@ android:layout_width="0dp" android:layout_height="1dp" android:background="?attr/color_separator" + android:importantForAccessibility="no" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@id/scrollView" /> @@ -349,8 +345,6 @@ android:layout_marginStart="16dp" android:layout_marginEnd="16dp" android:layout_marginBottom="@dimen/screen_bottom_margin" - android:paddingStart="20dp" - android:paddingEnd="20dp" android:text="@string/meeting_info_join_title" app:layout_constraintWidth_max="@dimen/button_max_width" app:layout_constraintVertical_bias="1" diff --git a/app/src/main/res/layout/meeting_list_cell.xml b/app/src/main/res/layout/meeting_list_cell.xml index 73d9c53e32..7a780729fd 100644 --- a/app/src/main/res/layout/meeting_list_cell.xml +++ b/app/src/main/res/layout/meeting_list_cell.xml @@ -85,6 +85,7 @@ android:id="@+id/cardview" android:onClick="@{onClickListener}" android:onLongClick="@{onLongClickListener}" + android:onContextClick="@{onLongClickListener}" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginStart="24dp" diff --git a/app/src/main/res/layout/meeting_popup_menu.xml b/app/src/main/res/layout/meeting_popup_menu.xml index d94aaa1095..66826f07d6 100644 --- a/app/src/main/res/layout/meeting_popup_menu.xml +++ b/app/src/main/res/layout/meeting_popup_menu.xml @@ -19,23 +19,18 @@ + app:layout_constraintBottom_toTopOf="@id/bottom_anchor"/> + + diff --git a/app/src/main/res/layout/meeting_schedule_fragment.xml b/app/src/main/res/layout/meeting_schedule_fragment.xml index 55b8b3573b..6f0e390901 100644 --- a/app/src/main/res/layout/meeting_schedule_fragment.xml +++ b/app/src/main/res/layout/meeting_schedule_fragment.xml @@ -35,11 +35,10 @@ android:background="?attr/color_background_contrast_in_dark_mode"> - - - - - - - - - - - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:paddingBottom="@dimen/screen_bottom_margin"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/meeting_waiting_room_fragment.xml b/app/src/main/res/layout/meeting_waiting_room_fragment.xml index a59e505d85..e0f76298ef 100644 --- a/app/src/main/res/layout/meeting_waiting_room_fragment.xml +++ b/app/src/main/res/layout/meeting_waiting_room_fragment.xml @@ -19,12 +19,11 @@ android:background="@color/gray_900"> + + + + + + + bind:visibility="@{viewModel.cancelMeetingViewModel.operationInProgress}" /> diff --git a/app/src/main/res/layout/recording_list_cell.xml b/app/src/main/res/layout/recording_list_cell.xml index 2601f8325b..481999a510 100644 --- a/app/src/main/res/layout/recording_list_cell.xml +++ b/app/src/main/res/layout/recording_list_cell.xml @@ -26,6 +26,7 @@ android:id="@+id/cardview" android:onClick="@{onClickListener}" android:onLongClick="@{onLongClickListener}" + android:onContextClick="@{onLongClickListener}" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginTop="5dp" diff --git a/app/src/main/res/layout/recording_player_fragment.xml b/app/src/main/res/layout/recording_player_fragment.xml index d4f126bfb8..cc4fc7596e 100644 --- a/app/src/main/res/layout/recording_player_fragment.xml +++ b/app/src/main/res/layout/recording_player_fragment.xml @@ -5,6 +5,7 @@ + @@ -14,6 +15,9 @@ + @@ -49,35 +53,36 @@ - + app:layout_constraintEnd_toStartOf="@id/duration" /> + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toBottomOf="@id/top_bar_background" /> + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toBottomOf="@id/top_bar_background" /> + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toBottomOf="@id/top_bar_background" /> - - - - - - - + app:barrierDirection="bottom" + app:constraint_referenced_ids="back, cancel_search" /> - + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toEndOf="parent"> + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/settings_advanced_calls_auto_answer.xml b/app/src/main/res/layout/settings_advanced_calls_auto_answer.xml new file mode 100644 index 0000000000..514ff61b60 --- /dev/null +++ b/app/src/main/res/layout/settings_advanced_calls_auto_answer.xml @@ -0,0 +1,114 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/settings_advanced_calls_early_media.xml b/app/src/main/res/layout/settings_advanced_calls_early_media.xml new file mode 100644 index 0000000000..935633c413 --- /dev/null +++ b/app/src/main/res/layout/settings_advanced_calls_early_media.xml @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/settings_advanced_calls_fragment.xml b/app/src/main/res/layout/settings_advanced_calls_fragment.xml new file mode 100644 index 0000000000..f0f7a59073 --- /dev/null +++ b/app/src/main/res/layout/settings_advanced_calls_fragment.xml @@ -0,0 +1,462 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/settings_advanced_fragment.xml b/app/src/main/res/layout/settings_advanced_fragment.xml index f9708e1b50..4cfac67db0 100644 --- a/app/src/main/res/layout/settings_advanced_fragment.xml +++ b/app/src/main/res/layout/settings_advanced_fragment.xml @@ -1,7 +1,6 @@ + xmlns:app="http://schemas.android.com/apk/res-auto"> @@ -22,12 +21,11 @@ android:background="?attr/color_background_contrast_in_dark_mode"> + android:layout_height="wrap_content" + android:paddingBottom="@dimen/screen_bottom_margin"> - + app:layout_constraintEnd_toEndOf="parent"> - + - + - + - + - + - + - + - + - + - + - - - + + + + app:layout_constraintTop_toBottomOf="@id/main_section"/> @@ -313,20 +296,18 @@ android:layout_width="0dp" android:layout_height="50dp" android:layout_marginEnd="16dp" - android:layout_marginTop="8dp" android:paddingStart="20dp" android:paddingEnd="20dp" android:textSize="14sp" android:textColor="@color/gray_main2_600" android:gravity="center_vertical" android:overlapAnchor="false" - android:dropDownVerticalOffset="2dp" + android:dropDownVerticalOffset="25dp" android:spinnerMode="dropdown" android:popupBackground="@drawable/shape_squircle_white_background" android:background="@drawable/edit_text_background" android:visibility="@{viewModel.expandAudioDevices ? View.VISIBLE : View.GONE}" app:layout_constraintHorizontal_bias="0" - app:layout_constraintWidth_max="@dimen/text_input_max_width" app:layout_constraintTop_toBottomOf="@id/input_audio_device_label" app:layout_constraintStart_toStartOf="@id/input_audio_device_label" app:layout_constraintEnd_toEndOf="parent" /> @@ -345,16 +326,15 @@ app:layout_constraintEnd_toEndOf="@id/input_audio_device"/> @@ -364,20 +344,18 @@ android:layout_width="0dp" android:layout_height="50dp" android:layout_marginEnd="16dp" - android:layout_marginTop="8dp" android:paddingStart="20dp" android:paddingEnd="20dp" android:textSize="14sp" android:textColor="@color/gray_main2_600" android:gravity="center_vertical" android:overlapAnchor="false" - android:dropDownVerticalOffset="2dp" + android:dropDownVerticalOffset="25dp" android:spinnerMode="dropdown" android:popupBackground="@drawable/shape_squircle_white_background" android:background="@drawable/edit_text_background" android:visibility="@{viewModel.expandAudioDevices ? View.VISIBLE : View.GONE}" app:layout_constraintHorizontal_bias="0" - app:layout_constraintWidth_max="@dimen/text_input_max_width" app:layout_constraintTop_toBottomOf="@id/output_audio_device_label" app:layout_constraintStart_toStartOf="@id/output_audio_device_label" app:layout_constraintEnd_toEndOf="parent" /> @@ -397,74 +375,6 @@ - - - - - - - - diff --git a/app/src/main/res/layout/settings_calls.xml b/app/src/main/res/layout/settings_calls.xml index 43fe0e7791..64d33cd49f 100644 --- a/app/src/main/res/layout/settings_calls.xml +++ b/app/src/main/res/layout/settings_calls.xml @@ -5,6 +5,9 @@ + @@ -103,6 +106,7 @@ android:text="@string/settings_calls_adaptive_rate_control_title" android:maxLines="2" android:ellipsize="end" + android:labelFor="@id/adaptive_rate_control_switch" app:layout_constraintTop_toTopOf="@id/adaptive_rate_control_switch" app:layout_constraintBottom_toBottomOf="@id/adaptive_rate_control_switch" app:layout_constraintStart_toStartOf="parent" @@ -120,34 +124,6 @@ app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@id/calibrate_echo_canceller" /> - - - - + app:layout_constraintTop_toBottomOf="@id/adaptive_rate_control_switch" /> + + \ No newline at end of file diff --git a/app/src/main/res/layout/settings_chat.xml b/app/src/main/res/layout/settings_chat.xml index cf87e4e8e4..3534e5e893 100644 --- a/app/src/main/res/layout/settings_chat.xml +++ b/app/src/main/res/layout/settings_chat.xml @@ -27,6 +27,7 @@ android:text="@string/settings_conversations_auto_download_title" android:maxLines="2" android:ellipsize="end" + android:labelFor="@id/auto_download_switch" app:layout_constraintTop_toTopOf="@id/auto_download_switch" app:layout_constraintBottom_toBottomOf="@id/auto_download_switch" app:layout_constraintStart_toStartOf="parent" @@ -55,6 +56,7 @@ android:text="@string/settings_conversations_auto_export_media_to_native_gallery_title" android:maxLines="2" android:ellipsize="end" + android:labelFor="@id/auto_export_media_to_native_gallery_switch" app:layout_constraintTop_toTopOf="@id/auto_export_media_to_native_gallery_switch" app:layout_constraintBottom_toBottomOf="@id/auto_export_media_to_native_gallery_switch" app:layout_constraintStart_toStartOf="parent" @@ -84,6 +86,7 @@ android:text="@string/settings_conversations_mark_as_read_when_dismissing_notif_title" android:maxLines="2" android:ellipsize="end" + android:labelFor="@id/mark_as_read_dismiss_notif_switch" app:layout_constraintTop_toTopOf="@id/mark_as_read_dismiss_notif_switch" app:layout_constraintBottom_toBottomOf="@id/mark_as_read_dismiss_notif_switch" app:layout_constraintStart_toStartOf="parent" @@ -101,6 +104,35 @@ app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@id/auto_export_media_to_native_gallery_switch" /> + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/settings_codec_list_cell.xml b/app/src/main/res/layout/settings_codec_list_cell.xml index 2b59429914..64423db45f 100644 --- a/app/src/main/res/layout/settings_codec_list_cell.xml +++ b/app/src/main/res/layout/settings_codec_list_cell.xml @@ -25,6 +25,7 @@ android:text="@{model.mimeType, default=`Opus`}" android:maxLines="1" android:ellipsize="end" + android:labelFor="@id/codec_switch" app:layout_constraintVertical_chainStyle="packed" app:layout_constraintTop_toTopOf="@id/codec_switch" app:layout_constraintBottom_toTopOf="@id/codec_subtitle" @@ -54,7 +55,7 @@ android:onClick="@{() -> model.toggleEnabled()}" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginTop="16dp" + android:layout_marginTop="20dp" android:layout_marginEnd="16dp" android:checked="@{model.enabled}" app:layout_constraintEnd_toEndOf="parent" diff --git a/app/src/main/res/layout/settings_contacts.xml b/app/src/main/res/layout/settings_contacts.xml index 34110315d0..99996f6ad0 100644 --- a/app/src/main/res/layout/settings_contacts.xml +++ b/app/src/main/res/layout/settings_contacts.xml @@ -15,6 +15,109 @@ android:paddingBottom="20dp" android:background="@drawable/shape_squircle_white_background"> + + + + + + + + + + + + + + @@ -66,6 +170,7 @@ android:ellipsize="end" android:drawableEnd="@drawable/caret_right" android:drawableTint="?attr/color_main2_600" + android:background="@drawable/squircle_transparent_button_background" app:layout_constraintTop_toBottomOf="@id/existing_ldap_servers" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent"/> diff --git a/app/src/main/res/layout/settings_contacts_carddav.xml b/app/src/main/res/layout/settings_contacts_carddav.xml index 42aebb291e..d70dc43981 100644 --- a/app/src/main/res/layout/settings_contacts_carddav.xml +++ b/app/src/main/res/layout/settings_contacts_carddav.xml @@ -17,7 +17,7 @@ + android:background="?attr/color_background_contrast_in_dark_mode"> - + app:layout_constraintBottom_toBottomOf="parent"> + + + + + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + diff --git a/app/src/main/res/layout/settings_contacts_ldap.xml b/app/src/main/res/layout/settings_contacts_ldap.xml index 459b0ad1f4..37ee947f73 100644 --- a/app/src/main/res/layout/settings_contacts_ldap.xml +++ b/app/src/main/res/layout/settings_contacts_ldap.xml @@ -16,7 +16,7 @@ + android:background="?attr/color_background_contrast_in_dark_mode"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + - - - - - - + android:paddingBottom="@dimen/screen_bottom_margin"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - + app:layout_constraintEnd_toEndOf="parent"> - - - + - - - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/settings_fragment.xml b/app/src/main/res/layout/settings_fragment.xml index c9515b7597..b810777f12 100644 --- a/app/src/main/res/layout/settings_fragment.xml +++ b/app/src/main/res/layout/settings_fragment.xml @@ -11,6 +11,9 @@ + @@ -28,12 +31,11 @@ android:background="?attr/color_background_contrast_in_dark_mode"> @@ -87,13 +91,13 @@ @@ -103,13 +107,15 @@ android:onClick="@{() -> viewModel.toggleCallsExpand()}" android:layout_width="0dp" android:layout_height="wrap_content" - android:padding="5dp" - android:layout_marginStart="26dp" - android:layout_marginEnd="26dp" - android:layout_marginTop="16dp" + android:padding="10dp" + android:layout_marginStart="16dp" + android:layout_marginEnd="16dp" + android:layout_marginTop="10dp" android:text="@string/settings_calls_title" android:drawableEnd="@{viewModel.expandCalls ? @drawable/caret_up : @drawable/caret_down, default=@drawable/caret_up}" android:drawableTint="?attr/color_main2_600" + android:background="@drawable/squircle_transparent_button_background" + app:layout_constraintWidth_max="@dimen/section_max_width" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@id/security_settings"/> @@ -117,13 +123,16 @@ @@ -147,13 +158,15 @@ @@ -177,13 +192,15 @@ @@ -207,13 +226,15 @@ @@ -236,13 +259,15 @@ @@ -265,13 +292,15 @@ @@ -295,13 +326,15 @@ @@ -329,15 +364,17 @@ android:onClick="@{developerSettingsClickListener}" android:layout_width="0dp" android:layout_height="wrap_content" - android:padding="5dp" - android:layout_marginStart="26dp" - android:layout_marginEnd="26dp" - android:layout_marginTop="16dp" + android:padding="10dp" + android:layout_marginStart="16dp" + android:layout_marginEnd="16dp" + android:layout_marginTop="10dp" android:layout_marginBottom="@dimen/screen_bottom_margin" android:text="@string/settings_developer_title" android:drawableEnd="@drawable/caret_right" android:drawableTint="?attr/color_main2_600" + android:background="@drawable/squircle_transparent_button_background" android:visibility="@{viewModel.showDeveloperSettings ? View.VISIBLE : View.GONE, default=gone}" + app:layout_constraintWidth_max="@dimen/section_max_width" app:layout_constraintVertical_bias="0" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" diff --git a/app/src/main/res/layout/settings_meetings.xml b/app/src/main/res/layout/settings_meetings.xml index 50882d1de5..e3cee2fb08 100644 --- a/app/src/main/res/layout/settings_meetings.xml +++ b/app/src/main/res/layout/settings_meetings.xml @@ -18,6 +18,35 @@ + + + + diff --git a/app/src/main/res/layout/settings_network.xml b/app/src/main/res/layout/settings_network.xml index a526101fc4..93e0eb0f55 100644 --- a/app/src/main/res/layout/settings_network.xml +++ b/app/src/main/res/layout/settings_network.xml @@ -18,7 +18,7 @@ + app:layout_constraintTop_toBottomOf="@id/enable_vfs_switch" /> @@ -33,7 +34,6 @@ android:layout_width="0dp" android:layout_height="50dp" android:layout_marginEnd="16dp" - android:layout_marginTop="8dp" android:paddingStart="20dp" android:paddingEnd="20dp" android:text="@={viewModel.tunnelMainHost}" @@ -50,13 +50,14 @@ app:layout_constraintEnd_toEndOf="parent"/> @@ -66,7 +67,6 @@ android:layout_width="0dp" android:layout_height="50dp" android:layout_marginEnd="16dp" - android:layout_marginTop="8dp" android:paddingStart="20dp" android:paddingEnd="20dp" android:text="@={viewModel.tunnelMainPort}" @@ -93,6 +93,7 @@ android:text="@string/settings_tunnel_dual_mode_label" android:maxLines="2" android:ellipsize="end" + android:labelFor="@id/tunnel_dual_mode_switch" app:layout_constraintTop_toTopOf="@id/tunnel_dual_mode_switch" app:layout_constraintBottom_toBottomOf="@id/tunnel_dual_mode_switch" app:layout_constraintStart_toStartOf="parent" @@ -104,21 +105,22 @@ android:onClick="@{() -> viewModel.toggleTunnelDualMode()}" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginTop="16dp" + android:layout_marginTop="20dp" android:layout_marginEnd="16dp" android:checked="@{viewModel.tunnelDualMode}" app:layout_constraintTop_toBottomOf="@id/tunnel_port" app:layout_constraintEnd_toEndOf="parent" /> @@ -128,7 +130,6 @@ android:layout_width="0dp" android:layout_height="50dp" android:layout_marginEnd="16dp" - android:layout_marginTop="8dp" android:paddingStart="20dp" android:paddingEnd="20dp" android:text="@={viewModel.tunnelDualHost}" @@ -146,13 +147,14 @@ app:layout_constraintEnd_toEndOf="parent"/> @@ -163,7 +165,6 @@ android:layout_width="0dp" android:layout_height="50dp" android:layout_marginEnd="16dp" - android:layout_marginTop="8dp" android:paddingStart="20dp" android:paddingEnd="20dp" android:text="@={viewModel.tunnelDualPort}" @@ -181,13 +182,14 @@ app:layout_constraintEnd_toEndOf="parent"/> @@ -197,7 +199,6 @@ android:layout_width="0dp" android:layout_height="50dp" android:layout_marginEnd="16dp" - android:layout_marginTop="8dp" android:paddingStart="20dp" android:paddingEnd="20dp" android:textSize="14sp" @@ -209,7 +210,6 @@ android:popupBackground="@drawable/shape_squircle_white_background" android:background="@drawable/edit_text_background" app:layout_constraintHorizontal_bias="0" - app:layout_constraintWidth_max="@dimen/text_input_max_width" app:layout_constraintTop_toBottomOf="@id/tunnel_mode_label" app:layout_constraintStart_toStartOf="@id/tunnel_mode_label" app:layout_constraintEnd_toEndOf="parent" /> diff --git a/app/src/main/res/layout/settings_user_interface.xml b/app/src/main/res/layout/settings_user_interface.xml index f392d25936..df55a03ee6 100644 --- a/app/src/main/res/layout/settings_user_interface.xml +++ b/app/src/main/res/layout/settings_user_interface.xml @@ -39,6 +39,7 @@ android:text="@string/settings_user_interface_auto_show_dialpad_title" android:maxLines="2" android:ellipsize="end" + android:labelFor="@id/auto_show_dialpad_switch" app:layout_constraintTop_toTopOf="@id/auto_show_dialpad_switch" app:layout_constraintBottom_toBottomOf="@id/auto_show_dialpad_switch" app:layout_constraintStart_toStartOf="parent" @@ -57,7 +58,7 @@ app:layout_constraintTop_toTopOf="parent" /> @@ -103,7 +103,7 @@ app:layout_constraintEnd_toEndOf="@id/theme_spinner"/> diff --git a/app/src/main/res/layout/single_sign_on_fragment.xml b/app/src/main/res/layout/single_sign_on_activity.xml similarity index 77% rename from app/src/main/res/layout/single_sign_on_fragment.xml rename to app/src/main/res/layout/single_sign_on_activity.xml index c090558d30..a30306e1a0 100644 --- a/app/src/main/res/layout/single_sign_on_fragment.xml +++ b/app/src/main/res/layout/single_sign_on_activity.xml @@ -4,9 +4,12 @@ + + type="org.linphone.ui.sso.SingleSignOnViewModel" /> + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/start_call_fragment.xml b/app/src/main/res/layout/start_call_fragment.xml index 1b4dea73d0..8809708571 100644 --- a/app/src/main/res/layout/start_call_fragment.xml +++ b/app/src/main/res/layout/start_call_fragment.xml @@ -28,20 +28,12 @@ android:layout_height="match_parent" android:background="?attr/color_background_contrast_in_dark_mode"> - - - - - + app:layout_constraintEnd_toEndOf="parent"> - + - + android:layout_marginTop="5dp" + android:layout_marginStart="10dp" + android:layout_marginEnd="10dp" + android:background="@drawable/shape_squircle_white_background" + android:scrollbars="none" + android:visibility="@{viewModel.multipleSelectionMode ? View.VISIBLE : View.GONE, default=gone}" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/multiple_selection_count"> - + - + - + - - - + - - + - + - + - + - + + - + + + + + + + + + + + + + @@ -49,148 +50,151 @@ android:id="@+id/flow" android:layout_width="match_parent" android:layout_height="0dp" + android:layout_marginBottom="10dp" + android:layout_marginStart="10dp" + android:layout_marginEnd="10dp" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/handle" app:layout_constraintBottom_toTopOf="@id/call_barrier" - android:layout_marginBottom="20dp" app:flow_horizontalStyle="spread" app:flow_wrapMode="aligned" app:flow_verticalGap="10dp" + app:flow_horizontalGap="10dp" app:flow_maxElementsWrap="3" app:constraint_referenced_ids="digit_1, digit_2, digit_3, digit_4, digit_5, digit_6, digit_7, digit_8, digit_9, digit_star, digit_0, digit_sharp" /> @@ -236,10 +240,11 @@ android:src="@drawable/backspace_fill" android:contentDescription="@string/content_description_erase_last_input" android:padding="15dp" + android:background="@drawable/in_call_numpad_button_background" app:layout_constraintStart_toStartOf="@id/digit_sharp" app:layout_constraintEnd_toEndOf="@id/digit_sharp" app:layout_constraintTop_toTopOf="@id/call_barrier" - app:tint="?attr/color_main2_600" /> + app:tint="@color/bc_white" /> diff --git a/app/src/main/res/layout/start_chat_fragment.xml b/app/src/main/res/layout/start_chat_fragment.xml index 700909bb34..439ac2c36c 100644 --- a/app/src/main/res/layout/start_chat_fragment.xml +++ b/app/src/main/res/layout/start_chat_fragment.xml @@ -25,20 +25,12 @@ android:layout_height="match_parent" android:background="?attr/color_background_contrast_in_dark_mode"> - - - - - + app:layout_constraintEnd_toEndOf="parent"> - + - + android:layout_marginTop="5dp" + android:layout_marginStart="10dp" + android:layout_marginEnd="10dp" + android:background="@drawable/shape_squircle_white_background" + android:scrollbars="none" + android:visibility="@{viewModel.multipleSelectionMode ? View.VISIBLE : View.GONE, default=gone}" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/multiple_selection_count"> - + - + - + - + - - + - + - + - + + - + - + + + + + + + + + + + + - + app:popUpToInclusive="true" /> + - - - - - - - - - - + app:launchSingleTop="true" /> - - - - - + android:id="@+id/recoverAccountFragment" + android:name="org.linphone.ui.assistant.fragment.RecoverAccountFragment" + android:label="RecoverAccountFragment" + tools:layout="@layout/assistant_recover_account_fragment" /> \ No newline at end of file diff --git a/app/src/main/res/navigation/contacts_nav_graph.xml b/app/src/main/res/navigation/contacts_nav_graph.xml index bceb929883..6611a78926 100644 --- a/app/src/main/res/navigation/contacts_nav_graph.xml +++ b/app/src/main/res/navigation/contacts_nav_graph.xml @@ -33,6 +33,22 @@ app:destination="@id/emptyFragment" app:popUpTo="@id/contactFragment" app:popUpToInclusive="true" /> + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/main_nav_graph.xml b/app/src/main/res/navigation/main_nav_graph.xml index 22fda01d30..fdbb02ee4e 100644 --- a/app/src/main/res/navigation/main_nav_graph.xml +++ b/app/src/main/res/navigation/main_nav_graph.xml @@ -145,6 +145,14 @@ app:exitAnim="@anim/slide_out_left" app:popEnterAnim="@anim/slide_in_left" app:popExitAnim="@anim/slide_out_right" /> + - - - - + tools:layout="@layout/settings_advanced_fragment"> - - + + \ No newline at end of file diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index ffa3a0c933..7d3df18770 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -6,4 +6,21 @@ %s dies %s dies + Prem a la notificació per arregar-ho + Benvingut + Assegurada + No t\'escolten! + Un aplicatiu de comunicació segur, de codi obert i en francès. + Esborra-ho per a mi + Has esborrat aquest missatge + Participants + No s\'ha trobat cap participant + Afegeix participants + Aquest missatge ha estat esborrat + Grup de membres (%s) + Esborra-ho per a tothom + Afegeix participants + Afegeix participants + Participants + Afegeix participants diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index a4519efa5b..75f31c3267 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -68,14 +68,12 @@ Nenalezena žádná aplikace k otevření tohoto typu souboru Soubor nelze otevřít! Udělit práva administrátora - Z historie budou odebrány všechny zprávy + Z historie budou odebrány všechny zprávy. Zahájit skupinový hovor? Přidal(a) jste se ke skupině Úroveň bezpečnosti byla snížena kvůli %s Maximální počet účastníků byl překročen o %s Klepnutím přidat další účastníky - Smazat schůzku? - Přejete si smazat schůzku? Je vyžadováno ověření Ztracené pakety: %s SAS algoritmus: %s @@ -108,7 +106,6 @@ Ahoj, přidej se ke mně na &appName;! Aplikaci si můžeš zdarma stáhnout zde: %s Zpráva byla smazána Informace o hromadném odeslání.\nZjistit více - Zrušit schůzku? Vyplňte prosím název a vyberte alespoň jednoho účastníka Příchozí videohovor pro %s Upravit adresář CardDAV @@ -133,7 +130,7 @@ Účet byl zakázán, nebudete moci příjímat hovory ani zprávy. Účet se připojuje, prosím počkejte… Připojení se nezdařilo, protože chybí nebo je neplatné ověření účtu\n%s.\n\nMůžete znovu zadat heslo nebo zkontrolovat nastavení účtu v konfiguraci. - Z historie budou odstraněny všechny hovory + Z historie budou odstraněny všechny hovory. Nastavení mizejících zpráv Žádný shodný výsledek… Nenalezena žádná média… @@ -157,7 +154,6 @@ Přijmout Zrušit Pokračovat - OK Volat Smazat Tento dialog již nezobrazovat @@ -168,7 +164,7 @@ Oznámení o příchozích hovorech &appName; Oznámení o zmeškaných hovorech &appName; Oznámení služby &appName; - Oznámení o zprávách (&appName;) + Oznámení o okamžitých zprávách Reakce uživatele %1$s na: %2$s Označit jako přečtené Odpovědět @@ -194,7 +190,6 @@ Bezplatná a otevřená aplikace od roku 2001. SIP adresa zkopírována do schránky Nový účet byl nastaven - Chyba připojení! Soubor byl exportován do systémové galerie Soubor byl exportován do dokumentů Chyba při exportu souboru do dokumentů @@ -237,7 +232,6 @@ Váš účet byl vytvořen pomocí: Emailu Telefonního čísla - Poslat kód Kontakty Hovory Schůzky @@ -268,7 +262,6 @@ Sdílet odkaz na ladící logy pomocí… Chyba při nahrávání ladících logů Zobrazit konfiguraci - Smazat importované kontakty z adresáře zařízení Nastavení Zabezpečení Šifrovat vše @@ -285,7 +278,6 @@ %s ms selhalo Adaptivní datový tok - Povolit video Povolit FEC pro video Vibrovat při příchozím hovoru Automaticky spouštět nahrávání hovorů @@ -318,7 +310,6 @@ Atributy SIP Doména SIP Nastala chyba, server LDAP nebyl uložen! - URL adresa serveru nemůže být prázdná Schůzky Výchozí rozložení Aktivní mluvčí @@ -388,7 +379,7 @@ Povolit push notifikace Push notifikace nejsou dostupné! Šifrování zpráv je povinné - URL adresa SIP proxy serveru + Registrační URI Odchozí proxy server Nastavení zásad NAT URL adresa serveru STUN/TURN @@ -411,14 +402,13 @@ Odpovídající účet nebyl nalezen! Nový hovor Hledat kontakt nebo historii hovorů - Historie hovorů Vytvořit skupinový hovor Momentálně žádný návrh ani kontakt… Momentálně žádný hovor… Konverzace Opravdu si přejete smazat veškerou historii hovorů? Opravdu si přejete smazat historii s touto osobou? - Z historie bodou odstraněny všechny hovory + Z historie budou odstraněny všechny hovory. Momentálně žádný kontakt… Oblíbené Všechny kontakty @@ -510,8 +500,6 @@ Koncově šifrovaná konverzace Zaručená důvěrnost Tato konverzace není šifrována! - Povinné šifrování - Zapnuli jste povinné šifrování. Nešifrované konverzace jsou z bezpečnostních důvodů zakázány. Tuto konverzaci můžete znovu vytvořit, nebo v nastavení účtu vypnout povinné šifrování. Dosažen maximální možný počet příloh! Otevřít nebo exportovat soubor? Otevřít soubor @@ -539,7 +527,6 @@ Pořídit snímek Otevřít galerii Vybrat soubor - Heslem chráněná PDF zatím není možné otevřít Členové skupiny (%s) Přidat účastníky Administrátor @@ -606,7 +593,6 @@ Schůzka nebyla nalezena! Popis Upravit schůzku - Přejete si zrušit schůzku a odeslat oznámení všem účastníkům? Zrušit schůzku Přidat název… Smazat schůzku @@ -735,12 +721,6 @@ Stáhnout Sdílet Oranžová - Žlutá - Zelená - Modrá - Červená - Růžová - Fialová Zde se zobrazí vybraní účastnící Chyba při připojení účtu(ů) Zvolený účet je momentálně zákázán @@ -813,7 +793,6 @@ Otevře výběr emoji Spustí výběr souboru Zapne/vypne ztlumení konverzace - Konverzace se odstraňuje Tato konverzace není zabezpečená Hledat směrem vzhůru Hledat směrem dolů @@ -842,7 +821,6 @@ Zprávy v této konverzaci jsou koncově šifrovány. Dešifrovat je může pouze váš protějšek. Poslali jsme vám ověřovací kód na vaše telefonní číslo %1$s.\n\nZadejte prosím níže ověřovací kód: Push notifikace se na vašem zařízení zdají být nedostupné, ale jsou nezbytné pro vytvoření účtu v mobilní aplikaci.\n\nZveme vás proto, abyste si místo toho vytvořili účet na naší webové platformě: - Zadejte telefonní číslo spojené s vaším účtem, pošleme vám kód. Tento režim zajišťuje důvěrnost vašich dat. Naše technologie koncového šifrování poskytuje nejvyšší úroveň zabezpečení pro vaši komunikaci. &appName; nemůže soubor otevřít.\n\nPřejete si jej otevřít v jiné aplikaci (pokud to bude možné) nebo jej exportovat do vašeho zařízení? Úprava schůzky selhala! @@ -860,4 +838,104 @@ Přihlásit se k odběru informací o přítomnosti Tento účet je offline, pravděpodobně proto, že právě nejste připojeni k internetu. Odpojeno + Hledat + Heslem chráněná PDF zatím není možné otevřít + Účastníci + Smazat importované kontakty z adresáře zařízení + Upravit + Rozumím + Nerušit + Oznámení o chybách účtu + Zapnout reproduktor + Schůzka bude zrušena + Chcete odeslat upozornění všem účastníkům? + Vypnout reproduktor + Registrace účtu %s se nezdařila! + Otevřete &appName; pro obnovení registrace + Algoritmus (algoritmy) LIME (oddělené čárkou) + Povolené hodnoty jsou: c25519, c448, c25519k512, c25519mlk512 a c448mlk1024 + Seřadit kontakty podle + Použijte editor kontaktů aplikace &appName; pro nativní kontakty + Skrýt kontakty bez SIP adresy a telefonního čísla + Tento adresář je pouze pro čtení + Přidat protokoly LDAP do protokolů &appName; + Všechna pole musí být vyplněna + Pokročilá nastavení hovorů + Early-media + Automatická odpověď + Automatická odpověď s videem povolená v obou směrech + Klikněte ještě dvakrát, abyste povolili nastavení pro vývojáře + Klikněte ještě jednou pro povolení nastavení pro vývojáře + Povolit měřiče hlasitosti nahrávání/přehrávání během hovoru + Zobrazit pokročilé statistiky hovorů + Seznam domén kompatibilních s push notifikacemi (oddělené čárkami) + Při příštím spuštění aplikace budou znovu importovány, pokud neodstraníte oprávnění pro kontakty + Importované kontakty byly smazány + Odchozí SIP proxy + Pokud je toto pole vyplněno, odchozí proxy bude automaticky povolena. Nechte jej prázdné, chcete-li ji zakázat. + Změnit účet + Bylo dosaženo limitu výsledků vyhledávání, upřesněte své vyhledávání. + Zprávy nejsou šifrovány koncovým šifrováním, proto nesdílejte citlivé informace! + Nešifrovaná konverzace + Zprávy vyměněné v této konverzaci mohou být zachyceny a přečteny jinými osobami než vaším příjemcem, důvěrnost není zaručena! + Tento PDF soubor nelze otevřít, může být poškozen + Zpráva se upravuje + Upraveno + Tato zpráva bude smazána + Tuto zprávu jste smazali + Nenalezeni žádní účastníci + Odmítnout + Odpověď + Mikrofon + HDMI + ICE: %s + Rozsah IP: %s + Zrušit úpravu zprávy + Zavřít menu + Volaný vás neslyší! + Žádné osiřelé autentizační údaje nebyly nalezeny + + %s osiřelý autentizační údaj odstraněn + %s osiřelé autentizační údaje byly odstraněny + %s osiřelých autentizačních údajů bylo odstraněno + + Klikněte na toto oznámení a opravte to + Stav připojení + Vymazat autentizační údaje, které již nejsou spojeny s žádným účtem + Výběr vyzváněcích tónů není k dispozici! + Koncově šifrováno + <redacted> + Nezobrazovat obsah zprávy v oznámení systému Android + Zobrazit minulé schůzky + Ověření telefonního čísla není k dispozici + Ověření telefonního čísla není k dispozici, použijte prosím proces vytvoření e-mailového účtu. + Registrace s e-mailem + Terakota + Levandule + Med + Burgundsko + Máta + Korál + Švestka + Titan + Minerální modrá + Smazat to pro mě + Smazat to pro všechny + LDAP server uložen + Zastavit nahrávání + Záznam hovoru byl uložen + Použít SIP INFO pro DTMF + Použít RFC 2833 pro DTMF + Odstranit účastníka? + Tento účastník byl odstraněn ze skupinové konverzace. + Smazat schůzku? + Tato schůzka bude odstraněna pouze ze seznamu schůzek tohoto zařízení. + Smazán bude pouze tento záznam. + Opravdu chcete smazat tuto konverzaci? + Všechny zprávy budou také smazány z historie. + Už nebudete moci odesílat ani přijímat nové zprávy, ale vaše historie zpráv zůstane k dispozici. + Opravdu chcete smazat tento záznam hovoru? + Opravdu chcete opustit tuto konverzaci? + Toto oznámení udržuje aplikaci aktivní, i když běží na pozadí + Pomocí senzoru přiblížení vypnout displej, když se přiblížíte k uchu diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index a1685ed364..9c8b584da0 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -3,7 +3,6 @@ ]> - &appName; SIP-Adresse Geräte-ID Anzeigename @@ -30,7 +29,6 @@ Akzeptieren Abbrechen Fortsetzen - OK Anruf Löschen Installieren @@ -41,8 +39,8 @@ Bestätigen &appName; aktive Anrufe Benachrichtigungen &appName; eingehende Anrufe Benachrichtigungen - &appName; verpasste Anrufe Benachrichtigungen - &appName; Servicebenachrichtigung + Benachrichtigungen über verpasste Anrufe + Servicebenachrichtigung Dieser Service wird ständig ausgeführt, um die App am Leben zu erhalten und Ihnen den Empfang von Anrufen und Nachrichten ohne Push-Benachrichtigungen zu ermöglichen. &appName; Instant Messages Benachrichtigungen %1$s hat mit %2$s reagiert @@ -64,7 +62,6 @@ %s Datei wird heruntergeladen %s Dateien werden heruntergeladen - %s, %s Zum Öffnen klicken Willkommen @@ -76,7 +73,6 @@ Eine kostenlose Open-Source-Anwendung seit 2001. SIP-Adresse in die Zwischenablage kopiert Neues Konto konfiguriert - Verbindungsfehler! Die Datei wurde in die native Galerie exportiert Fehler beim Versuch, die Datei in die native Galerie zu exportieren Die Datei wurde in Dokumente exportiert @@ -97,8 +93,8 @@ Drittanbieter-SIP-Konto verwenden Drittanbieter-SIP-Konto Single Sign-on - SIP-Adresse ist ungültig! - SIP-Adresse enthält keinen Benutzernamen! + SIP Adresse ist ungültig! + SIP Adresse enthält keinen Benutzernamen! Das Konto ist bereits vorhanden Noch kein Konto? Registrieren @@ -159,7 +155,6 @@ Debug Logs gelöscht Debug Logs Hochladen fehlgeschlagen Konfiguration anzeigen - Importierte Kontakte aus dem nativen Adressbuch löschen Einstellungen Sicherheit Alles verschlüsseln @@ -175,7 +170,6 @@ %s ms fehlgeschlagen Adaptive Geschwindigkeitsregelung - Video aktivieren Video FEC aktivieren Vibrieren während ein eingehender Anruf klingelt Automatische Anrufaufzeichnung starten @@ -213,7 +207,6 @@ SIP-Attribute SIP-Domäne Ein Fehler ist aufgetreten, LDAP-Server nicht gespeichert! - Die Server-URL darf nicht leer sein Besprechungen Standardlayout Aktiver Lautsprecher @@ -282,7 +275,7 @@ Push Benachrichtigungen zulassen Push Benachrichtigungen sind nicht verfügbar! IM Verschlüsselung obligatorisch - SIP proxy Server URL + Registrar URI Ausgehender Proxy STUN/TURN Server URL NAT-Richtlinien @@ -305,25 +298,24 @@ Verbindung fehlgeschlagen, da Authentifizierung für Konto %s fehlt oder ungültig ist.\n\nSie können das Passwort erneut eingeben oder Ihre Kontokonfiguration in den Einstellungen überprüfen. Passwort Es konnte kein passendes Konto gefunden werden! - Anrufverlauf Neuer Anruf Kontakt oder Anrufverlauf durchsuchen Gruppenanruf erstellen Kein Vorschlag und im Moment kein Kontakt… Betreff für Gruppenanruf festlegen Betreff des Gruppenanrufs - Im Moment kein Anruf… + Kein Anruf von/zu diesem Konto gefunden… Chat Möchten Sie wirklich den gesamten Anrufverlauf löschen? - Alle Anrufe werden aus dem Verlauf gelöscht + Alle Anrufe werden aus dem Verlauf gelöscht. Möchten Sie den Verlauf mit dieser Person wirklich löschen? - Alle Anrufe werden aus dem Verlauf gelöscht + Alle Anrufe werden aus dem Verlauf gelöscht. Im Moment kein Kontakt… Favoriten Alle Kontakte - Alle Kontakte + Alle anzeigen &appName; Kontakte - SIP-Kontakte + SIP Kontakte Neuer Kontakt Kontakt bearbeiten Vorname @@ -337,7 +329,7 @@ Änderungen nicht speichern? Alle Änderungen werden verloren Mindestens ein Pflichtfeld wurde nicht ausgefüllt - Telefonnummern & SIP-Adressen + Telefonnummern & SIP Adressen Unternehmen: Berufsbezeichnung: Vertrauen @@ -357,7 +349,7 @@ Überprüfen Sie alle Ihre Kontaktgeräte, um sicherzustellen, dass Ihre Kommunikation geschützt und unverändert bleibt.\nWenn alles verifiziert ist, erreichen Sie das maximale Vertrauensniveau. %s löschen? Dieser Kontakt wird endgültig entfernt. - Eine Nummer oder SIP-Adresse wählen + Eine Nummer oder SIP Adresse wählen Verfügbar Online um %s Online Heute um %s @@ -419,8 +411,6 @@ Dank der End-to-End Verschlüsselungstechnologie in &appName; ist die Vertraulichkeit von Nachrichten, Anrufen und Meetings gewährleistet. Niemand kann die ausgetauschten Daten entschlüsseln, nicht einmal wir selbst. Dieser Chat ist nicht verschlüsselt! Zu Ihrer Sicherheit wurde diese Konversation deaktiviert. - Obligatorische Verschlüsselung - Sie haben die obligatorische Verschlüsselung aktiviert. Nicht verschlüsselte Konversationen sind zu Ihrer Sicherheit deaktiviert. Sie können diese Konversation neu erstellen oder die obligatorische Verschlüsselung in Ihren Kontoparametern deaktivieren. Maximale Anzahl an Anhängen erreicht! Chat-Betreff festlegen Chat-Betreff bearbeiten @@ -448,7 +438,6 @@ Chat wurde nicht gefunden Kein passendes Ergebnis gefunden Letztes passendes Ergebnis erreicht - Passwortgeschützte PDFs können noch nicht geöffnet werden Gruppenteilnehmer (%s) Teilnehmer hinzufügen Administrator @@ -459,7 +448,7 @@ Remove admin rights Kontaktprofil anzeigen Möchten Sie wirklich alle Nachrichten löschen? - Alle Nachrichten werden aus dem Verlauf gelöscht + Alle Nachrichten werden aus dem Verlauf gelöscht. Der Verlauf wurde erfolgreich gelöscht %s hat sich dem Chat angeschlossen %s hat den Chat verlassen @@ -522,8 +511,6 @@ Das Meeting konnte nicht gefunden werden! Beschreibung Besprechung bearbeiten - Besprechung absagen? - Möchten Sie das Besprechung absagen und allen Teilnehmern eine Benachrichtigung senden? Besprechung löschen Besprechung wurde erstellt Besprechung wurde aktualisiert @@ -546,7 +533,7 @@ Der Gesprächspartner hat den Anruf beendet Eingehender Anruf für %s Eingehender Videoanruf für %s - Weiterleiten %s an… + %s weiterleiten an… Aktuelle Anrufe Kein andere Anruf Anrufweiterleitung bestätigen @@ -563,7 +550,7 @@ Layout Im Gange Klingeln - Eingehend + Eingehender Aktiv Pausieren Fern pausiert @@ -645,7 +632,7 @@ Beitritt… Pausiert gibt seinen Bildschirm frei - Ungültige SIP-Adresse, kann nicht zur Konferenz hinzugefügt werden + Ungültige SIP Adresse, kann nicht zur Konferenz hinzugefügt werden Mosaik Lautsprecher Nur Audio @@ -673,12 +660,6 @@ Herunterladen Teilen Orange - Gelb - Grün - Blau - Rot - Rosa - Lila Kein Ergebnis gefunden… Ausgewählte Teilnehmer erscheinen hier Verbindungsfehler bei den Konten @@ -769,7 +750,6 @@ Öffnet den Medien-Picker Klicken Sie hier, um das Thema dieser Unterhaltung zu bearbeiten Stummschalten dieses Chats - Der Chat wird entfernt Dieser Chat ist nicht gesichert Suche nach oben Suche nach unten @@ -792,11 +772,10 @@ LDAP-Konfiguration speichen Spielt die Gesprächsaufzeichnung ab Klingelton auswählen - TURN aktivieren TURN aktivieren - Early Media erlauben - Rufsignal bei Early Media Ansage - Ausgehendes Early Media erlauben + Frühe Medienübertragung erlauben + Rufsignal bei früher Medienübertragung + Ausgehende frühe Medianübertragung erlauben Nur alpha-numerische Zeichen Datei-Upload-URL Telefonnummern mit internationaler Vorwahl formatieren @@ -832,15 +811,11 @@ Sicherheitsstufe verringert wegen %s Maximale Teilnehmerzahl um %s überschritten Besprechung absagen - Besprechung löschen? - Möchten Sie die Besprechung löschen? Passwort vergessen Sie haben Ihren Account erstellt mit: - Geben Sie die mit Ihrem Konto verknüpfte Telefonnummer ein, und wir senden Ihnen einen Code zu. Benachrichtigungen scheinen auf Ihrem Gerät nicht verfügbar zu sein, sind allerdings nötig um einen Account in der App zu erstellen.\n\nWir laden Sie ein, stattdessen einen Account auf unserer Webplattform zu erstellen: Einer E-Mail Einer Telefonnummer - Code senden Benachrichtigungen scheinen auf Ihrem Gerät nicht verfügbar zu sein, sind aber für die Wiederherstellung eines Rufnummernkontos in der mobilen App zwingend erforderlich. &appName; Benutzeranleitung Lerne alle Funktionen der App zu meistern, Schritt für Schritt. @@ -856,4 +831,101 @@ Dieses Konto ist offline, weil Sie vermutlich nicht mit dem Internet verbunden sind. Getrennt Anwesendheitsinfos abonnieren + Suchen + Importierte Kontakte aus dem nativen Adressbuch löschen + Bearbeiten + Passwortgeschützte PDFs können noch nicht geöffnet werden + Teilnehmer + Verstanden + Account Fehlermeldungen + Lautsprecher einschalten + Automatische Antwort mit Video in beide Richtungen aktiviert + Account %s Registrierung fehlgeschlagen! + Zulässige Werte sind: c25519, c448, c25519k512, c25519mlk512 und c448mlk1024 + Klicken Sie noch zweimal, um die Entwicklereinstellungen zu aktivieren + Die maximale Anzahl an Suchergebnissen wurde erreicht. Bitte verfeinern Sie Ihre Suche. + Klicken Sie noch einmal, um die Entwicklereinstellungen zu aktivieren + Wenn dieses Feld ausgefüllt ist, wird der ausgehende Proxy automatisch aktiviert. Lassen Sie es leer, um ihn zu deaktivieren. + IP-Familie: %s + Nachrichten sind nicht verschlüsselt, geben Sie keine vertraulichen Informationen weiter! + Die in dieser Unterhaltung ausgetauschten Nachrichten können von anderen Personen als Ihrem Korrespondenzpartner abgefangen und gelesen werden, die Vertraulichkeit ist nicht gewährleistet! + Nachrichtenbearbeitung abbrechen + Menü schließen + Nicht abbrechen + Lautsprecher ausschalten + Öffne &appName; um die Registrierung zu aktualisieren + LIME-Algorithmus (kommasepariert) + Kontakte sortieren nach + Verwenden Sie den Kontakteditor von &appName; für native Kontakte + Dieses Adressbuch ist schreibgeschützt + Kontakte ohne SIP-Adresse oder Telefonnummer ausblenden + LDAP Log Informationen zu &appName; hinzufügen + Alle Felder müssen ausgefüllt werden + Erweiterte Anruf Einstellungen + Early Media + Automatische Antwort + Anzeige der Lautstärke während des Gesprächs aktivieren + Erweiterte Anrufstatistiken anzeigen + Liste der mit Push-Benachrichtigungen kompatiblen Domains (kommasepariert) + Sie werden beim nächsten Start der App erneut importiert, es sei denn, Sie entfernen die Berechtigung für Kontakte + Importierte Kontakte wurden gelöscht + Ausgehender SIP-Proxy + Account ändern + Unverschlüsselte Unterhaltung + PDF-Datei kann nicht geöffnet werden, möglicherweise ist sie beschädigt + Nachricht wird bearbeitet + Bearbeitet + Diese Nachricht wurde gelöscht + Sie haben diese Nachricht gelöscht + Keine Teilnehmer gefunden + Meeting wird abgesagt + Möchten Sie eine Benachrichtigung an alle Teilnehmer senden? + Ablehnen + Annehmen + Mikrofon + HDMI + ICE: %s + Der Klingelton-Auswähler ist nicht verfügbar! + Keine verwaisten Authentifizierungsinformationen gefunden + Verbindungsstatus + Authentifizierungsinformationen löschen, die nicht mehr mit einem Konto verknüpft sind + + %s nicht mehr benötigte Anmeldedaten wurden entfernt + %s nicht mehr benötigte Anmeldedaten wurden entfernt + + Klicken Sie auf diese Benachrichtigung, um das Problem zu beheben + Ihr Gesprächspartner hört Sie nicht! + <zensiert> + End-to-End-verschlüsselt + Nachrichteninhalt nicht in Android-Benachrichtigungen anzeigen + Vergangene Meetings anzeigen + Die Überprüfung der Telefonnummer ist nicht verfügbar. Bitte nutzen Sie den Prozess zur Erstellung eines E-Mail-Kontos. + Die Überprüfung der Telefonnummer ist nicht verfügbar + Mit E-Mail registrieren + Für alle löschen + Terrakotta + Lavendel + Honig + Burgund + Minze + Koral + Pflaume + Titan + Mineralblau + Für mich löschen + LDAP Server gespeichert + Aufnahme stoppen + Aufnahme wurde gespeichert + RFC 2833 für DTMF-Töne verwenden + SIP INFO für DTMF-Töne verwenden + Möchten Sie dieses Anrufprotokoll wirklich löschen? + Nur dieser Eintrag wird entfernt. + Alle Nachrichten werden ebenfalls aus dem Verlauf entfernt. + Sie können keine neuen Nachrichten mehr senden oder empfangen, aber Ihr Nachrichtenverlauf bleibt weiterhin verfügbar. + Möchten Sie diese Konversation wirklich löschen? + Möchten Sie diesen Chat wirklich verlassen? + Teilnehmer entfernen? + Besprechung löschen? + Dieses Meeting wird nur aus der Meetingliste dieses Geräts entfernt. + Dieser Teilnehmer wird aus dem Gruppenchat entfernt. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 2d38c630d5..1269e1e7a9 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -19,7 +19,6 @@ Aceptar Cancelar Continuar - Ok Llamar Eliminar Instalar @@ -64,11 +63,10 @@ código abierto Dirección SIP copiada al portapapeles Nueva cuenta configurada - Error de conexión! El archivo se ha exportado a la galería nativa Error tratando de exportar el archivo a la galería nativa El archivo ha sido exportado a documentos - El volumen de los medios es bajo, ¡es posible que no escuches nada! + El volumen de los medios es bajo !es posible que no escuches nada! Configuración aplicada satisfactoriamente Error al intentar descargar y aplicar la configuración remota Términos generales y política de privacidad @@ -78,20 +76,20 @@ Confirmar número de teléfono Acceso Escanear código QR - Código QR no válido! + ¡Código QR no válido! Utilice una cuenta SIP de terceros Cuenta SIP de terceros Inicio de sesión único - La dirección SIP no es válida! + ¡La dirección SIP no es válida! La cuenta ya existe - Aún no tienes cuenta? + ¿Aún no tienes cuenta? Registrar Confirma tu número de teléfono Una aplicación gratuita y de código abierto desde 2001. Error tratando de exportar archivo a documentos Error al intentar crear un reproductor multimedia ¿Estás seguro que tu número de teléfono es %s? - La dirección SIP no contiene un nombre de usuario! + ¡La dirección SIP no contiene un nombre de usuario! Hemos enviado un código de verificación a tu número de teléfono %1$s.\n\nIntroduce el código de verificación a continuación: %s archivo en proceso de carga @@ -104,10 +102,10 @@ %s Archivos en descarga %s Archivo en descarga - Número equivocado? + ¿Número equivocado? Crear Crea una cuenta con tu correo electrónico en: - Ya tienes una cuenta? + ¿Ya tienes una cuenta? Transporte Algunas funciones requieren una cuenta &appName; como la mensajería grupal, las videoconferencias, etc.\n\nEstas funciones están ocultas al registrarse con una cuenta SIP de terceros.\n\nPara habilitarlas en un proyecto comercial, contáctenos. Prefiero crear una cuenta; &appName; @@ -126,8 +124,6 @@ Usted creó su cuenta usando: Un correo electrónico Un numero de teléfono - Ingresa el número de teléfono vinculado a tu cuenta, te enviaremos un código. - Enviar el código Contactos Llamadas Conversaciones @@ -173,11 +169,10 @@ Se han limpiado los registros de depuración No se pudieron cargar los registros de depuración Mostrar configuracion - Borrar contactos importados de la libreta de direcciones nativa Ajustes Seguridad Cifrar todo - Advertencia: una vez habilitado, ¡no se puede deshabilitar! + Advertencia: una vez habilitado ¡no se puede deshabilitar! ¡Error al habilitar el módulo de cifrado! Módulo de cifrado habilitado ¿Realmente quieres cifrarlo todo? @@ -192,7 +187,6 @@ %s ms fallido Control de velocidad adaptativo - Habilitar video Habilitar FEC de vídeo Vibrar mientras suena la llamada entrante Iniciar automáticamente la grabación de llamadas @@ -214,17 +208,16 @@ Dominio de autenticación Almacenar los contactos recién creados La sincronización fue exitosa - Error de sincronización! + ¡Error de sincronización! Cuenta CardDAV eliminada Identificador del dispositivo - Se produjo un error, ¡el servidor LDAP no se guardó! + Se produjo un error ¡el servidor LDAP no se guardó! Respuesta automática de las llamadas entrantes Interfaz de usuario Utilice dos servidores Ayuda a los desarrolladores a solucionar problemas enviando registros a Crashlytics después de un bloqueo Graba videollamadas usando H265/AV1 Suena durante las primeras llamadas entrantes - La URL del servidor no puede estar vacía Reuniones Diseño predeterminado Altavoz activo @@ -312,4 +305,10 @@ Responder Conversaciones Contactos + Borrar contactos importados de la libreta de direcciones nativa + Buscar + Entendido + No cancelar + ¡El registro de la cuenta %s falló! + Buscar diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 25e41c7d5d..2af45c3fd2 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -2,4 +2,5 @@ AVPF Todennus tarvitaan + Audio/video kokous, tehdas URI diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index e84070f98c..c8ebc3b8e4 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1,7 +1,7 @@ - - -]> - + + +]> Adresse SIP @@ -21,6 +21,7 @@ %s jour %s jours + Rechercher %s selectionné %s selectionnés @@ -30,7 +31,6 @@ Accepter Annuler Continuer - OK Appeler Supprimer Installer @@ -39,6 +39,8 @@ Oui Retirer Confirmer + J\'ai compris + Ne pas annuler Notifications d\'appels en cours Notifications d\'appels entrants @@ -46,6 +48,7 @@ Notification de service Ce service sera actif en permanence pour garder l\'app en vie et vous permettre de recevoir appels et messages sans recourir aux notifications push. Notifications des conversations + Notifications d\'erreurs liées aux comptes A réagi par %1$s à : %2$s Marquer comme lu Répondre @@ -66,6 +69,14 @@ %s fichiers en cours de réception Cliquez pour ouvrir + Cette notification maintient l\'app en vie en arrière plan + Activer haut-parleur + Désactiver haut-parleur + Compte %s en erreur ! + Ouvrez &appName; pour rafraîchir la connexion + Votre correspondant ne vous entend pas ! + Cliquez sur cette notification pour corriger le problème + <contenu masqué> Bienvenue sur &appName; @@ -77,7 +88,6 @@ Adresse copiée Nouveau compte ajouté - Erreur de connexion ! Média exporté Échec de l\'exportation Document exporté @@ -93,6 +103,9 @@ En continuant, vous acceptez nos %1$s et %2$s. Confirmez votre numéro de téléphone Êtes-vous sûr que le %s est votre numéro de téléphone ? + Inscription par téléphone indisponible + L\'inscription par numéro de téléphone n\'est pas disponible, veuillez utiliser l\'inscription par adresse email. + S\'inscrire avec un email Connexion Scanner un QR code Ce QR code est invalide ! @@ -111,6 +124,8 @@ Créez un compte avec votre email ici : Vous avez déjà un compte ? Transport + Algorithme(s) LIME (séparés par des virgules) + Les valeurs autorisées sont : c25519, c448, c25519k512, c25519mlk512 and c448mlk1024 Certaines fonctionnalités telles que les conversations de groupe, les vidéo-conférences, etc… nécessitent un compte &appName;.\n\nCes fonctionnalités seront masquées si vous utilisez un compte SIP tiers.\n\nPour les activer dans un projet commercial, merci de nous contacter. Je préfère créer un compte &appName; J\'ai compris @@ -127,13 +142,11 @@ Notifications : Pour vous informer quand vous recevez un message ou un appel. Microphone : Pour permettre à vos correspondants de vous entendre. Caméra : Pour capturer votre vidéo lors des appels et des conférences. - Mot de passe oublié + Récupération de compte Choisissez comment récupérer votre compte. Vous avez créé votre compte avec : Un email Un numéro de téléphone - Veuillez saisir le numéro de téléphone lié à votre compte, nous allons vous envoyer un code. - Envoyer le code Les notifications push ne semblent pas être disponibles sur votre appareil. Celles-ci sont nécessaires à la récupération d’un compte sur l’application mobile avec un numéro de téléphone. Contacts @@ -179,7 +192,6 @@ Les journaux ont été nettoyés Échec à l\'envoi des journaux Afficher la configuration - Supprimer les contacts natifs importés Paramètres Securité @@ -199,17 +211,24 @@ %s ms échec Contrôle automatique de la qualité - Autoriser la vidéo Activer la FEC vidéo Vibration lors de l\'appel Enregistrement automatique des appels Changer de sonnerie Choisissez la sonnerie + Le sélectionneur de sonnerie n\'est pas disponible ! + Utiliser la RFC 2833 pour les DTMFs + Utiliser SIP INFO pour les DTMFs + Utiliser le détecteur de proximité pour éteindre l\'écran quand proche de l\'oreille Conversations Télécharger automatiquement les fichiers Rendre visible dans la galerie les médias téléchargés Marquer la conversation comme lue lorsqu\'une notification de message est supprimée + Masquer le contenu du message dans la notification Android Contacts + Trier les contacts par + Editer les contacts natifs dans &appName; + Masquer les contacts sans adresse SIP ni numéro de téléphone Souscrire aux informations de présence Ajouter un serveur LDAP Editer le serveur LDAP @@ -225,11 +244,13 @@ Erreur de synchronization ! Compte CardDAV supprimé Veuillez remplir au moins le nom d\'affichage et l\'URL du serveur + Ce carnet d\'adresses est en lecture seule Activé URL du serveur (ne peut être vide) Bind DN Mot de passe Utiliser TLS + Ajouter les logs LDAP à ceux de &appName; Base de recherche (ne peut être vide) Filtre Nombre de résultats maximum @@ -239,9 +260,11 @@ Attributs de nom Attributs SIP Domaine SIP + Configuration LDAP sauvegardée Une erreur s\'est produite, la configuration LDAP n\'a pas été sauvegardée ! - L\'URL du serveur ne peut être vide + Tous les champs doivent être remplis Réunions + Afficher les réunions passées Disposition par défaut Intervenant actif Mosaïque @@ -273,17 +296,21 @@ Caractères alpha-numériques uniquement URL du serveur de partage de fichier URL du serveur de partage des logs + Paramètres d\'appels avancés Enregistrer les appels vidéos utilisant H265/AV1 Utilisera un format de fichier propriétaire Chiffrement du média Rendre le chiffrement du média obligatoire Créer en mode chiffré de bout en bout les réunions et les appels de groupe + Early media Accepter l\'early media Sonner pendant un appel entrant avec early-media Autoriser l\'early media pour les appels sortants + Décrochage automatique Décrocher automatiquement les appels entrants Délai avant le décrochage automatique Délai en millisecondes + Décrocher automatiquement avec la vidéo activée dans les deux sens URL de configuration distante Télécharger & appliquer Périphériques audio @@ -296,8 +323,22 @@ Paramètres Android de &appName; Paramètres développeurs Afficher les paramètres développeurs + Encore 2 clicks pour activer les paramètres développeurs + Encore 1 click pour activer les paramètres développeurs Paramètres développeurs activés Paramètres développeurs déjà activés + Activer l\'indicateur des volumes d\'enregistrement et de lecture + Afficher plus de statistiques d\'appel + Liste des domaines qui supportent les notifications poussées (séparés par des virgules) + Supprimer les contacts natifs importés + Ils seront synchronisés à nouveau au prochain démarrage de l\'application sauf si vous retirez la permission de lire les contacts + Contacts importés supprimés + Supprimer les informations d\'authentification orphelines + Aucune information d\'authentification orpheline trouvée + + %s information d\'authentification supprimée + %s informations d\'authentification supprimées + Mon compte Détails @@ -306,6 +347,7 @@ Ajouter une image Modifier Supprimer + État de la connexion Vous êtes en ligne, on peut vous joindre. Vous êtes hors ligne, probablement car vous n\'êtes pas actuellement connecté à internet. Compte désactivé, vous ne recevrez ni appel ni message. @@ -325,12 +367,14 @@ Dernière connexion : Se déconnecter du compte ? Si vous souhaitez supprimer définitivement votre compte rendez-vous sur : https://sip.linphone.org + Proxy SIP sortant + Si ce champ est rempli, l\'outbound proxy sera activé automatiquement. Laissez-le vide pour le désactiver. Paramètres de compte Activer les notifications push Notifications push non disponibles Chiffrement obligatoire des conversations - URL du serveur mandataire - Serveur mandataire sortant + Registrar URI + URI du proxy SIP sortant Paramètres de politique NAT URL du serveur STUN/TURN Activer ICE @@ -354,19 +398,21 @@ La connexion a échoué pour le compte \n%s.\n\nVous pouvez renseigner votre mot de passe à nouveau ou bien vérifier les options de configuration de votre compte. Mot de passe Le compte selectionné est introuvable ! - Historique d\'appel Nouvel appel Cherchez un contact ou une suggestion Démarrer un appel de groupe Aucun contact ni suggestion pour le moment… Nommer l\'appel de groupe Nom de l\'appel de groupe - Aucun appel dans votre historique… + Aucun appel vers/depuis ce compte n\'a été trouvé… + Changer de compte Conversation Supprimer l\'historique d\'appels ? L\'ensemble de votre historique d\'appels sera définitivement supprimé. Supprimer l\'historique d\'appels ? L\'ensemble de votre historique d\'appels avec ce correspondant sera définitivement supprimé. + Supprimer cet historique d\'appel ? + Seul cet appel sera supprimé. Aucun contact pour le moment… Aucun contact SIP pour le moment… @@ -376,6 +422,7 @@ Tous les contacts Contacts &appName; Contacts SIP + Nombre maximal de résultats atteint, affinez votre recherche. Nouveau contact Modifier contact Prénom @@ -423,7 +470,7 @@ Vérifier Appareil sans nom - Aucune conversation pour le moment… + Aucune conversation liée à ce compte pour le moment… En cours de suppression… %s : @@ -471,10 +518,11 @@ Les messages de cette conversation sont chiffrés de bout en bout. Seul votre correspondant peut les déchiffrer. La confidentialité de vos échanges garantie Grâce à la technologie de chiffrement de bout en bout de &appName;, la confidentialité de vos messages, appels et réunions avec vos correspondants est garantie. Personne ne pourra déchiffrer vos conversations, pas même &appName;. + Les messages ne sont pas chiffrés de bout en bout, assurez-vous de ne pas partager d\'informations sensibles ! + Conversation non chiffrée + Les messages échangés dans cette conversation peuvent être interceptés et consultés par des personnes autres que le destinataire désiré, la confidentialité n\'est pas garantie ! Cette conversation n\'est pas chiffrée ! Cette conversation a été désactivée pour garantir votre sécurité. - Chiffrement obligatoire - Vous avez activé le chiffrement obligatoire. Vos conversations non chiffrées sont désactivées pour garantir votre sécurité. Vous pouvez recréer cette conversation ou bien désactiver le chiffrement obligatoire dans vos paramètres de compte. Nombre maximum de fichiers atteint ! Nommer la conversation Renommer la conversation @@ -508,7 +556,16 @@ Ouvrir la galerie Choisir un fichier Impossible d\'ouvrir le fichier ! - Impossible d\'ouvrir un PDF protégé par mot de passe + Impossible d\'ouvrir un PDF protégé par mot de passe + Impossible d\'ouvrir ce PDF, le fichier est peut-être corrompu + Modification du message + Modifié + Supprimer pour moi + Supprimer pour tout le monde + Le message a été supprimé + Vous avez supprimé le message + Participants + Aucun participant trouvé Participants (%s) Ajouter des participants Administrateur @@ -519,7 +576,11 @@ Voir le contact Ajouter aux contacts Supprimer l\'historique ? - Tout les messages de cette conversation seront supprimés. + Tous les messages de cette conversation seront supprimés. + Supprimer la conversation ? + Tous les messages de cette conversation seront également supprimés. + Quitter la conversation ? + Vous ne pourrez plus envoyer ni recevoir de nouveaux messages mais l\'historique des messages sera toujours consultable. Historique supprimé %s a rejoint la conversation %s a quitté la conversation @@ -529,6 +590,8 @@ Aucune adresse à ajouter au contact Lancer un appel de groupe ? Tous les participants de la conversation recevront un appel. + Retirer le participant ? + Ce participant sera retiré de la conversation de groupe. Vous avez rejoint la conversation Vous avez quitté la conversation %s a rejoint la conversation @@ -587,11 +650,11 @@ Réunion introuvable ! Description Modifier la réunion - Annuler la réunion? - Voulez-vous annuler la réunion et envoyer une notification aux participants ? - Annuler la réunion + La réunion va être annulée + Voulez-vous envoyer une notification aux participants ? Supprimer la réunion ? - Voulez-vous supprimer la réunion ? + La réunion sera supprimée de la liste des réunions de cet appareil uniquement. + Annuler la réunion Supprimer la réunion Réunion créée Réunion mise à jour @@ -629,7 +692,10 @@ Mettre en pause Reprendre Enregistrer + Arrêter l\'enregistrement Raccrocher + Décliner + Décrocher Disposition En cours En train de sonner @@ -642,12 +708,14 @@ En attente du chiffrement… Appel chiffré de bout en bout Appel chiffré de point à point + Conférence chiffrée de bout en bout Faire la vérification à nouveau Vérification nécessaire Appel chiffré de point à point Appel non chiffré Liste des appels L\'appel est enregistré + Enregistrement sauvegardé %s enregistre l\'appel %s appels %s appels en pause @@ -664,17 +732,21 @@ Alerte de sécurité Réessayer La confidentialité de votre appel peut être compromise ! + Microphone Oreillette Haut parleur Bluetooth (%s) Aide auditive (%s) Casque Écouteurs + HDMI Audio Codec : %s Bande passante : %s Taux de perte : %s Tampon de gigue : %s + ICE : %s + Famille IP : %s Vidéo Définition : %s FPS : %s @@ -741,6 +813,7 @@ Inviter Ré-envoyer Info de réception + Modifier Répondre Transférer Copier le texte @@ -748,12 +821,15 @@ Partager Orange - Jaune - Vert - Bleu - Rouge - Rose - Violet + Terracotta + Lavande + Miel + Bordeaux + Menthe + Corail + Prune + Titane + Bleu Minéral Aucun résultat… Les participants selectionnés apparaîtront ici @@ -778,7 +854,7 @@ Passer - Mot de passe oublié ? + Mot de passe oublié ou inconnu ? Passer La réunion a été mise à jour @@ -844,11 +920,11 @@ Démarre l\'enregistrement d\'un message vocal Envoie le message Le message ne sera plus une réponse à un précédent message + Annule l\'édition du message Ouvre le selectionneur d\'emoji Ouvre le selectionneur de fichier Cliquez pour modifier le sujet de la conversation Met ou enlève la sourdine de la conversation - La conversation est en train d\'être supprimée La conversation n\'est pas chiffrée de bout en bout Rechercher vers le haut Rechercher vers le bas @@ -875,4 +951,5 @@ Au moins un message vocal est disponible Faire un appui long pour appeler la boite vocale Cliquer pour annuler le partage des fichiers ou du texte en attente + Ferme le menu diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 7bc12ec059..bcd82f2b06 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -21,7 +21,6 @@ Elfogad Mégse Folytatás - OK Hívás Törlés Telepítés @@ -56,7 +55,6 @@ Új üzenetek fogadása %1$s Reagált rá: %2$s Az új fiók konfigurálva - Csatlakozási hiba! A konfiguráció sikeresen alkalmazva Hiba történt a médialejátszó készítése közben Általános feltételek és biztonsági szabályok @@ -102,7 +100,6 @@ Milyen adatokat gyűjt és használ a Linphone Verzió Haladó - Importált névjegyek törlése a telefon névjegyzékéből Beállítások Biztonság Titkosítás @@ -160,4 +157,6 @@ Válasz Üzenetek Névjegyek + &appName; + Importált névjegyek törlése a telefon névjegyzékéből diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml new file mode 100644 index 0000000000..a6b3daec93 --- /dev/null +++ b/app/src/main/res/values-it/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/app/src/main/res/values-land/dimen.xml b/app/src/main/res/values-land/dimen.xml index 7e94f71993..985b2b99e0 100644 --- a/app/src/main/res/values-land/dimen.xml +++ b/app/src/main/res/values-land/dimen.xml @@ -5,6 +5,9 @@ 110dp 235dp + 600dp 600dp 0dp + + 175dp \ No newline at end of file diff --git a/app/src/main/res/values-lo/strings.xml b/app/src/main/res/values-lo/strings.xml new file mode 100644 index 0000000000..0e1181e71b --- /dev/null +++ b/app/src/main/res/values-lo/strings.xml @@ -0,0 +1,448 @@ + + + ປະຕິເສດ + ຊື່ສະແດງ + ໂດເມນ + ຊື່ຜູ້ໃຊ້ + ID ການຢືນຢັນຕົວຕົນ (ຖ້າຕ່າງກັນ) + ລະຫັດຜ່ານ + ເບີໂທລະສັບ + ຫຼື + ຖັດໄປ + ເລີ່ມຕົ້ນ + ຄົ້ນຫາ + ຍອມຮັບ + ຍົກເລີກ + ດຳເນີນການຕໍ່ + ໂທ + ລຶບ + ID ອຸປະກອນ + ຂ້ອຍເຂົ້າໃຈແລ້ວ + ອຸປະກອນສຽງ + ເວົ້າບາງຢ່າງ… + ຖ່າຍຮູບ + ລວມທຸກສາຍເຂົ້າໃນການປະຊຸມບໍ? + ສ້າງການປະຊຸມ + ກວດສອບອຸປະກອນ + ລະຫັດຂອງເຈົ້າ: + ລະຫັດຂອງຄູ່ສົນທະນາ: + ຜູ້ເຂົ້າຮ່ວມ + ສ້າງການໂທກຸ່ມຫຼົ້ມເຫຼວ! + ກຳລັງເຂົ້າຮ່ວມ… + ພັກໄວ້ + ແບບຕາຕະລາງ + ຜູ້ເວົ້າ + ສຽງຢ່າງດຽວ + ໄຟລ໌ບັນທຶກສຽງ + ເບິ່ງຜູ້ຕິດຕໍ່ + ຕອບກັບ + ສົ່ງຕໍ່ + ຄັດລອກ + ດາວໂຫຼດ + ແບ່ງປັນ + ຜູ້ຕິດຕໍ່ + ຄຳແນະນຳ + ແກ້ໄຂຜູ້ຕິດຕໍ່ + ຕິດຕັ້ງ + ບໍ່ຕ້ອງສະແດງຂໍ້ຄວາມນີ້ອີກ + ບໍ່ + ແມ່ນ + ເອົາອອກ + ຢືນຢັນ + ເຂົ້າໃຈແລ້ວ + ໝາຍວ່າອ່ານແລ້ວ + ຕອບກັບ + ສາຍທີ່ບໍ່ໄດ້ຮັບ + ຍິນດີຕ້ອນຮັບ + ປອດໄພ + ໂອເພນຊອດ + ຄັດລອກທີ່ຢູ່ SIP ໃສ່ຄລິບບອດແລ້ວ + ນຳໃຊ້ການຕັ້ງຄ່າສຳເລັດແລ້ວ + ຂໍ້ກຳນົດທົ່ວໄປ ແລະ ນະໂຍບາຍຄວາມເປັນສ່ວນຕົວ + ຂໍ້ກຳນົດທົ່ວໄປ + ນະໂຍບາຍຄວາມເປັນສ່ວນຕົວ + ຢືນຢັນເບີໂທລະສັບ + ເຂົ້າສູ່ລະບົບ + ສະແກນລະຫັດ QR + ລະຫັດ QR ບໍ່ຖືກຕ້ອງ! + ໃຊ້ບັນຊີ SIP ຂອງພາກສ່ວນທີສາມ + ຍັງບໍ່ມີບັນຊີບໍ? + ລົງທະບຽນ + ເບີໂທບໍ່ຖືກຕ້ອງບໍ? + ສ້າງ + ສ້າງບັນຊີດ້ວຍອີເມວຂອງເຈົ້າທີ່: + ມີບັນຊີແລ້ວບໍ? + ໂປຣໂຕຄໍການຮັບສົ່ງ + ບໍ່ໄດ້ຮັບການແຈ້ງເຕືອນພຸຊພ້ອມລະຫັດຢືນຢັນພາຍໃນ 5 ວິນາທີ, ກະລຸນາລອງໃໝ່ພາຍຫຼັງ + ເກີດຂໍ້ຜິດພາດທີ່ບໍ່ຄາດຄິດ, ກະລຸນາລອງໃໝ່ພາຍຫຼັງ + ຊື່ຜູ້ໃຊ້ ຫຼື ລະຫັດຜ່ານບໍ່ຖືກຕ້ອງ + ອະນຸຍາດສິດການເຂົ້າເຖິງ + ຕົກລົງ + ໄວ້ເຮັດພາຍຫຼັງ + ຜູ້ຕິດຕໍ່ + ການໂທ + ການສົນທະນາ + ການປະຊຸມ + ຈັດການໂປຣໄຟລ໌ + ເຊື່ອມຕໍ່ແລ້ວ + ປິດໃຊ້ງານ + ກຳລັງເຊື່ອມຕໍ່… + ຜິດພາດ + ຍັງບໍ່ທັນມີການຕັ້ງຄ່າບັນຊີ + ເພີ່ມບັນຊີ + ຊ່ວຍເຫຼືອ + ຮຽນຮູ້ວິທີໃຊ້ງານທຸກຄຸນສົມບັດຂອງແອັບຯເທື່ອລະຂັ້ນຕອນ. + ນະໂຍບາຍຄວາມເປັນສ່ວນຕົວ + ເວີຊັນ + ກວດສອບການອັບເດດ + ຂັ້ນສູງ + ເວີຊັນຂອງເຈົ້າເປັນເວີຊັນຫຼ້າສຸດແລ້ວ + ເກີດຂໍ້ຜິດພາດຂະນະກວດສອບການອັບເດດ + ມີການອັບເດດໃໝ່ + ອອກຈາກແອັບຯ + ການແກ້ໄຂບັນຫາ + ສະແດງລັອກໃນ logcat + ລ້າງລັອກ (Logs) + ແບ່ງປັນລັອກ + ເວີຊັນແອັບຯ + ເວີຊັນ SDK + ID ໂຄງການ Firebase + ແບ່ງປັນລິ້ງລັອກດີບັກຜ່ານ… + ລ້າງລັອກດີບັກແລ້ວ + ອັບໂຫຼດລັອກດີບັກຫຼົ້ມເຫຼວ + ສະແດງການຕັ້ງຄ່າ + ການຕັ້ງຄ່າ + ຄວາມປອດໄພ + ເຂົ້າລະຫັດທຸກຢ່າງ + ຄຳເຕືອນ: ເມື່ອເປີດໃຊ້ແລ້ວ ຈະບໍ່ສາມາດປິດໄດ້! + ປ້ອງກັນການບັນທຶກໜ້າຈໍ ຫຼື ແຄັບໜ້າຈໍ + ການໂທ + ໃຊ້ຊອບແວຕັດສຽງສະທ້ອນ + ປ້ອງກັນສຽງສະທ້ອນບໍ່ໃຫ້ອີກຝ່າຍໄດ້ຍິນ ຫາກອຸປະກອນບໍ່ມີລະບົບຕັດສຽງສະທ້ອນໃນຕົວ + ປັບຕັ້ງລະບົບຕັດສຽງສະທ້ອນ + ກຳລັງດຳເນີນການ + ບໍ່ມີສຽງສະທ້ອນ + ຫຼົ້ມເຫຼວ + ການຄວບຄຸມອັດຕາຮັບສົ່ງແບບປັບຕົວ (Adaptive rate control) + ເປີດໃຊ້ Video FEC + ສັ່ນເວົ້າມີສາຍຮຽກເຂົ້າ + ເລີ່ມບັນທຶກການໂທໂດຍອັດຕະໂນມັດ + ປ່ຽນສຽງຮຽກເຂົ້າ + ການສົນທະນາ + ດາວໂຫຼດໄຟລ໌ອັດຕະໂນມັດ + ໝາຍວ່າອ່ານແລ້ວເມື່ອປິດການແຈ້ງເຕືອນຂໍ້ຄວາມ + ຜູ້ຕິດຕໍ່ + ເພີ່ມເຊີເວີ LDAP + ແກ້ໄຂເຊີເວີ LDAP + ເພີ່ມສະໝຸດລາຍຊື່ CardDAV + ແກ້ໄຂສະໝຸດລາຍຊື່ CardDAV + ຊື່ສະແດງ + URL ເຊີເວີ + ຊື່ຜູ້ໃຊ້ + ລະຫັດຜ່ານ + ການຊິງຂໍ້ມູນຜິດພາດ! + ເປີດໃຊ້ງານ + URL ເຊີເວີ (ຫ້າມປະຫວ່າງ) + Bind DN + ລະຫັດຜ່ານ + ໃຊ້ TLS + Search base (ຫ້າມປະຫວ່າງ) + ຕົວຕອງ + ຜົນລັບສູງສຸດ + ໝົດເວລາ (ເປັນວິນາທີ) + ໄລຍະຫ່າງລະຫວ່າງການຄົ້ນຫາ (ເປັນມິນລິວິນາທີ) + ຈຳນວນຕົວອັກສອນຂັ້ນຕ່ຳເພື່ອເລີ່ມຄົ້ນຫາ + Name attributes + SIP attributes + SIP domain + ເກີດຂໍ້ຜິດພາດ, ບໍ່ໄດ້ບັນທຶກເຊີເວີ LDAP! + ການປະຊຸມ + ສະແດງການປະຊຸມທີ່ຜ່ານມາ + ຮູບແບບເລີ່ມຕົ້ນ + ຜູ້ເວົ້າຫຼັກ + ແບບຕາຕະລາງ + ເຄືອຂ່າຍ + ໃຊ້ສະເພາະເຄືອຂ່າຍ Wi-Fi + ອະນຸຍາດ IPv6 + ປິດໃຊ້ງານ + ການຕັ້ງຄ່າຂັ້ນສູງ + ID ອຸປະກອນ + ໃຊ້ໄດ້ສະເພາະຕົວອັກສອນ ແລະ ຕົວເລກເທົ່ານັ້ນ + URL ເຊີເວີແບ່ງປັນໄຟລ໌ + ການຕັ້ງຄ່າສຳລັບນັກພັດທະນາ + URL ເຊີເວີແບ່ງປັນລັອກ + ການຕັ້ງຄ່າການໂທຂັ້ນສູງ + ການເຂົ້າລະຫັດສື່ + ບັງຄັບການເຂົ້າລະຫັດສື່ + ສ້າງການປະຊຸມ ແລະ ການໂທກຸ່ມແບບເຂົ້າລະຫັດ E2E + ເອີລີມີເດຍ (Early-media) + ຍອມຮັບ Early-media + ອະນຸຍາດ Early-media ຂາອອກ + URL ສຳລັບການຕັ້ງຄ່າຈາກທາງໄກ (Remote provisioning) + ດາວໂຫຼດ ແລະ ນຳໃຊ້ + ອຸປະກອນຮັບສຽງເລີ່ມຕົ້ນ + ອຸປະກອນອອກສຽງເລີ່ມຕົ້ນ + Audio codecs + Video codecs + ສະແດງການຕັ້ງຄ່າສຳລັບນັກພັດທະນາ + ຄລິກອີກ 2 ຄັ້ງເພື່ອເປີດການຕັ້ງຄ່າສຳລັບນັກພັດທະນາ + ຄລິກອີກ 1 ຄັ້ງເພື່ອເປີດການຕັ້ງຄ່າສຳລັບນັກພັດທະນາ + ເປີດໃຊ້ການຕັ້ງຄ່າສຳລັບນັກພັດທະນາແລ້ວ + ເປີດໃຊ້ການຕັ້ງຄ່າສຳລັບນັກພັດທະນາຢູ່ແລ້ວ + ສະແດງລະດັບສຽງ (VU meters) ຂະນະໂທ + ສະແດງສະຖິຕິການໂທຂັ້ນສູງ + ລາຍຊື່ໂດເມນທີ່ຮອງຮັບການແຈ້ງເຕືອນພຸຊ (ແຍກດ້ວຍຈຸດ) + ລຶບລາຍຊື່ຜູ້ຕິດຕໍ່ທີ່ນຳມາຈາກສະໝຸດໂທລະສັບ + ລາຍຊື່ຈະຖືກນຳເຂົ້າໃໝ່ເມື່ອເປີດແອັບຯຄັ້ງຖັດໄປ ເວັ້ນເສຍແຕ່ເຈົ້າຈະປິດສິດການເຂົ້າເຖິງຜູ້ຕິດຕໍ່ + ລຶບລາຍຊື່ຜູ້ຕິດຕໍ່ທີ່ນຳເຂົ້າແລ້ວ + ລຶບຂໍ້ມູນການຢືນຢັນຕົວຕົນທີ່ບໍ່ໄດ້ເຊື່ອມຕໍ່ກັບບັນຊີໃດໆ + ບໍ່ພົບຂໍ້ມູນການຢືນຢັນຕົວຕົນທີ່ຕົກຄ້າງ + ຈັດການບັນຊີ + ລາຍລະອຽດ + ອຸປະກອນ + ບໍ່ພົບອຸປະກອນ… + ເພີ່ມຮູບ + ແກ້ໄຂຮູບ + ເອົາຮູບອອກ + ບັນຊີນີ້ອອນລາຍຢູ່, ທຸກຄົນສາມາດໂທຫາເຈົ້າໄດ້. + ບັນຊີຖືກປິດໃຊ້ງານ, ເຈົ້າຈະບໍ່ໄດ້ຮັບການໂທ ຫຼື ຂໍ້ຄວາມໃດໆ. + ບັນຊີກຳລັງເຊື່ອມຕໍ່ຫາເຊີເວີ, ກະລຸນາຖ້າ… + ເຊື່ອມຕໍ່ບັນຊີຫຼົ້ມເຫຼວ, ກະລຸນາກວດສອບການຕັ້ງຄ່າ. + ລະຫັດປະເທດ + ການຕັ້ງຄ່າບັນຊີ + ອອກຈາກລະບົບ + ໂໝດເຮັດວຽກຮ່ວມກັນໄດ້ + ເອົາອອກ + ການເຊື່ອມຕໍ່ຫຼ້າສຸດ: + ອອກຈາກລະບົບຂອງເຈົ້າບໍ? + ຫາກເຈົ້າຕ້ອງການລຶບບັນຊີຖາວອນ, ກະລຸນາໄປທີ່: https://sip.linphone.org + SIP Proxy ຂາອອກ + ຫາກປ້ອນຂໍ້ມູນໃນຫ້ອງນີ້, Outbound Proxy ຈະຖືກເປີດໃຊ້ໂດຍອັດຕະໂນມັດ. ປະໄວ້ຫວ່າງຫາກຕ້ອງການປິດໃຊ້ງານ. + ການຕັ້ງຄ່າບັນຊີ + ອະນຸຍາດການແຈ້ງເຕືອນແບບພຸຊ + ການແຈ້ງເຕືອນແບບພຸຊ ບໍ່ພ້ອມໃຊ້ງານ! + ບັງຄັບການເຂົ້າລະຫັດ IM + URI ຜູ້ລົງທະບຽນ (Registrar) + URI ຂອງ SIP Proxy ຂາອອກ + ການຕັ້ງຄ່ານະໂຍບາຍ NAT + URL ເຊີເວີ STUN/TURN + ເປີດໃຊ້ ICE + ເປີດໃຊ້ TURN + ຊື່ຜູ້ໃຊ້ TURN + ລະຫັດຜ່ານ TURN + AVPF + ໝົດອາຍຸ (ເປັນວິນາທີ) + URI ສ້າງການປະຊຸມ + URI ສ້າງການປະຊຸມ ສຽງ/ວິດີໂອ + URL ເຊີເວີ CCMP + ໂໝດ Bundle + ໃຊ້ CPIM ໃນການສົນທະນາແບບ \"ພື້ນຖານ\" + URI ຂໍ້ຄວາມສຽງ + URI ເຊີເວີ MWI (Message Waiting Indicator) + ຈັດຮູບແບບເບີໂທລະສັບໂດຍໃຊ້ລະຫັດປະເທດ + ແທນທີ່ + ດ້ວຍ 00 ເວລາຈັດຮູບແບບເບີໂທລະສັບ + ອັບເດດລະຫັດຜ່ານ + ຈຳເປັນຕ້ອງມີການຢືນຢັນຕົວຕົນ + ລະຫັດຜ່ານ + ໂທໃໝ່ + ຄົ້ນຫາຜູ້ຕິດຕໍ່ ຫຼື ປະຫວັດການໂທ + ສ້າງການໂທກຸ່ມ + ຕັ້ງຫົວຂໍ້ການໂທກຸ່ມ + ຫົວຂໍ້ການໂທກຸ່ມ + ເຈົ້າຕ້ອງການລຶບປະຫວັດການໂທທັງໝົດແທ້ບໍ? + ຍັງບໍ່ມີຜູ້ຕິດຕໍ່ໃນຕອນນີ້… + ລາຍການທີ່ມັກ + ຜູ້ຕິດຕໍ່ທັງໝົດ + ເບິ່ງທັງໝົດ + ເບິ່ງຜູ້ຕິດຕໍ່ SIP + ເພີ່ມຜູ້ຕິດຕໍ່ໃໝ່ + ແກ້ໄຂຜູ້ຕິດຕໍ່ + ຊື່ + ນາມສະກຸນ + ບໍລິສັດ + ຕຳແໜ່ງງານ + ບໍ່ບັນທຶກການປ່ຽນແປງບໍ? + ການປ່ຽນແປງທັງໝົດຈະຖືກຍົກເລີກ + ຄວາມເຊື່ອຖື + ບໍ່ພົບອຸປະກອນ… + ຈຳນວນອຸປະກອນທີ່ເຊື່ອຖືໄດ້: + ການຈັດການອື່ນໆ + ແກ້ໄຂ + ເພີ່ມໃສ່ລາຍການທີ່ມັກ + ເອົາອອກຈາກລາຍການທີ່ມັກ + ແບ່ງປັນ + ລຶບ + ລຶບລາຍຊື່ຜູ້ຕິດຕໍ່ແລ້ວ + ເພີ່ມລະດັບຄວາມເຊື່ອຖື + ລະດັບຄວາມເຊື່ອຖື + ກວດສອບອຸປະກອນຜູ້ຕິດຕໍ່ທັງໝົດຂອງເຈົ້າ ເພື່ອໃຫ້ແນ່ໃຈວ່າການສື່ສານຈະປອດໄພ ແລະ ບໍ່ຖືກປ່ຽນແປງ.\nເມື່ອທຸກຢ່າງຖືກກວດສອບແລ້ວ, ເຈົ້າຈະບັນລຸລະດັບຄວາມເຊື່ອຖືສູງສຸດ. + ຂໍ້ຄວາມ + ກວດສອບ + ລາຍຊື່ຜູ້ຕິດຕໍ່ນີ້ຈະຖືກລຶບອອກຢ່າງຖາວອນ. + ເລືອກເບີໂທ ຫຼື ທີ່ຢູ່ SIP + ໂທ + ອຸປະກອນທີ່ບໍ່ມີຊື່ + ໝາຍວ່າອ່ານແລ້ວ + ປິດແຈ້ງເຕືອນ + ເປີດແຈ້ງເຕືອນ + ໂທ + ລຶບການສົນທະນາ + ອອກຈາກກຸ່ມ + ຕັ້ງຄ່າຂໍ້ຄວາມຊົ່ວຄາວ + ຂໍ້ຄວາມຊົ່ວຄາວ + ຂໍ້ຄວາມໃໝ່ຈະຖືກລຶບໂດຍອັດຕະໂນມັດເມື່ອທຸກຄົນອ່ານແລ້ວ.\nເລືອກໄລຍະເວລາ: + ປິດໃຊ້ງານ + 1 ນາທີ + 1 ຊົ່ວໂມງ + 1 ມື້ + 3 ມື້ + 1 ອາທິດ + ການສົນທະນາໃໝ່ + ຄົ້ນຫາຜູ້ຕິດຕໍ່ + ສ້າງການສົນທະນາກຸ່ມ + ເພີ່ມຜູ້ເຂົ້າຮ່ວມ + ຄົ້ນຫາ + ຂໍ້ມູນການສົນທະນາ + ຂໍ້ຄວາມຊົ່ວຄາວ + ສື່ + ເອກະສານ + ບໍ່ພົບສື່… + ບໍ່ພົບເອກະສານ… + ການສົນທະນາແບບເຂົ້າລະຫັດຕົ້ນທາງຫາປາຍທາງ + ຮັບປະກັນຄວາມເປັນສ່ວນຕົວ + ຂໍ້ຄວາມບໍ່ໄດ້ຖືກເຂົ້າລະຫັດແບບຕົ້ນທາງຫາປາຍທາງ, ລະວັງການແບ່ງປັນຂໍ້ມູນທີ່ສຳຄັນ! + ການສົນທະນານີ້ບໍ່ໄດ້ເຂົ້າລະຫັດ! + ຕັ້ງຫົວຂໍ້ການສົນທະນາ + ແກ້ໄຂຫົວຂໍ້ການສົນທະນາ + ຫົວຂໍ້ການສົນທະນາ + ລຶບຂໍ້ຄວາມແລ້ວ + ສ້າງການສົນທະນາຫຼົ້ມເຫຼວ! + ບໍ່ສາມາດສ້າງການສົນທະນາກັບຜູ້ເຂົ້າຮ່ວມທີ່ບໍ່ໄດ້ຢູ່ໃນໂດເມນດຽວກັນໄດ້ ເນື່ອງຈາກຂໍ້ຈຳກັດດ້ານຄວາມປອດໄພ! + ເປີດການໃຊ້ຂໍ້ຄວາມຊົ່ວຄາວແລ້ວ + ປິດການໃຊ້ຂໍ້ຄວາມຊົ່ວຄາວແລ້ວ + ເຈົ້າໄດ້ອອກຈາກກຸ່ມແລ້ວ + ບໍ່ພົບຜົນການຄົ້ນຫາ + ເປີດແກເລີຣີ + ເລືອກໄຟລ໌ + ບໍ່ສາມາດເປີດໄຟລ໌ໄດ້! + ກຳລັງແກ້ໄຂຂໍ້ຄວາມ + ແກ້ໄຂແລ້ວ + ລຶບສຳລັບຂ້ອຍ + ລຶບໃຫ້ທຸກຄົນ + ຜູ້ເຂົ້າຮ່ວມ + ບໍ່ພົບຜູ້ເຂົ້າຮ່ວມ + ເພີ່ມຜູ້ເຂົ້າຮ່ວມ + ຜູ້ດູແລ + ລຶບປະຫວັດ + ເອົາອອກຈາກກຸ່ມ + ຕັ້ງເປັນຜູ້ດູແລ + ເອົາສິດຜູ້ດູແລອອກ + ເບິ່ງໂປຣໄຟລ໌ຜູ້ຕິດຕໍ່ + ເພີ່ມໃສ່ລາຍຊື່ຜູ້ຕິດຕໍ່ + ເຈົ້າຕ້ອງການລຶບຂໍ້ຄວາມທັງໝົດແທ້ບໍ? + ລຶບປະຫວັດສຳເລັດແລ້ວ + ເລີ່ມການໂທກຸ່ມບໍ? + ຜູ້ເຂົ້າຮ່ວມທັງໝົດຈະໄດ້ຮັບສາຍຮຽກເຂົ້າ. + ເຈົ້າໄດ້ເຂົ້າຮ່ວມກຸ່ມແລ້ວ + ເຈົ້າໄດ້ອອກຈາກກຸ່ມແລ້ວ + ເປີດການໃຊ້ຂໍ້ຄວາມຊົ່ວຄາວແລ້ວ + ປິດການໃຊ້ຂໍ້ຄວາມຊົ່ວຄາວແລ້ວ + ສື່ ແລະ ເອກະສານ + ສື່ທີ່ແບ່ງປັນ + ເອກະສານທີ່ແບ່ງປັນ + ສົ່ງຕໍ່ຂໍ້ຄວາມໄປຫາ… + ສົ່ງຕໍ່ຂໍ້ຄວາມແລ້ວ + ຍົກເລີກການສົ່ງຕໍ່ຂໍ້ຄວາມແລ້ວ + ຄລິກເພື່ອເອົາອອກ + ສົ່ງຕໍ່ແລ້ວ + ບໍ່ມີການນັດໝາຍການປະຊຸມໃນມື້ນີ້ + ການປະຊຸມໃໝ່ + ການປະຊຸມ + ເພີ່ມຫົວຂໍ້… + ເລືອກວັນທີເລີ່ມຕົ້ນ + ເລືອກເວລາເລີ່ມຕົ້ນ + ເລືອກເວລາສິ້ນສຸດ + ເຂດເວລາ + ເພີ່ມຄຳອະທິບາຍ + ເພີ່ມຜູ້ເຂົ້າຮ່ວມ + ສົ່ງຄຳເຊີນໃຫ້ຜູ້ເຂົ້າຮ່ວມ + ເຂົ້າຮ່ວມການປະຊຸມດຽວນີ້ + ຜູ້ຈັດການປະຊຸມ + ສ້າງກິດຈະກຳໃນປະຕິທິນ + ລຶບການປະຊຸມແລ້ວ + ຄຳອະທິບາຍ + ແກ້ໄຂການປະຊຸມ + ລຶບການປະຊຸມ + ສ້າງການປະຊຸມແລ້ວ + ອັບເດດການປະຊຸມແລ້ວ + ການປະຊຸມຖືກຍົກເລີກແລ້ວ + ນັດໝາຍການປະຊຸມຫຼົ້ມເຫຼວ! + ສົ່ງຄຳເຊີນການປະຊຸມຫຼົ້ມເຫຼວ! + ສົ່ງຄຳເຊີນໃຫ້ຜູ້ເຂົ້າຮ່ວມບາງຄົນຫຼົ້ມເຫຼວ! + ເຂົ້າຮ່ວມ + ຍົກເລີກ + ກຳລັງເຊື່ອມຕໍ່ + ສາຍໂທອອກ + ສາຍຮຽກເຂົ້າ + ສາຍທີ່ກຳລັງລົມຢູ່ + ບໍ່ມີສາຍອື່ນ + ຢືນຢັນການໂອນສາຍ + ໂອນສາຍ + ໂທໃໝ່ + ລາຍການການໂທ + ແປ້ນໂທ + ຂໍ້ຄວາມ + ພັກສາຍ + ລົມຕໍ່ + ບັນທຶກ + ວາງສາຍ + ຮູບແບບການສະແດງ + ກຳລັງລົມ + ພັກໄວ້ + ຖືກພັກສາຍໂດຍອີກຝ່າຍ + ກຳລັງກັບເຂົ້າສູ່ການລົມ… + ກຳລັງຖ້າການເຂົ້າລະຫັດ… + ເຂົ້າລະຫັດແບບຕົ້ນທາງຫາປາຍທາງດ້ວຍ ZRTP + ເຂົ້າລະຫັດແບບຈຸດຕໍ່ຈຸດດ້ວຍ ZRTP + ເຂົ້າລະຫັດແບບຕົ້ນທາງຫາປາຍທາງ (E2E) + ກວດສອບ ZRTP SAS ຄືນໃໝ່ + ຈຳເປັນຕ້ອງມີການກວດສອບ + ເຂົ້າລະຫັດແບບຈຸດຕໍ່ຈຸດດ້ວຍ SRTP + ການໂທບໍ່ໄດ້ເຂົ້າລະຫັດ + ລາຍການການໂທ + ກຳລັງບັນທຶກການໂທ + ບັນທຶກການໂທຖືກເກັບໄວ້ແລ້ວ + ບໍ່ມີອັນໃດກົງກັນ + ແຈ້ງເຕືອນຄວາມປອດໄພ + ລອງໃໝ່ + ຄວາມລັບຂອງການໂທນີ້ອາດຈະຖືກແຊກແຊງ! + ລຳໂພງຫູຟັງ + ຜູ້ເວົ້າ + ຫູຟັງ + ສຽງ + ວິດີໂອ + ການເຂົ້າລະຫັດສື່ + ລຶບປະຫວັດແລ້ວ + ກຳລັງໂອນສາຍ + ໂອນສາຍສຳເລັດແລ້ວ + ການໂອນສາຍຫຼົ້ມເຫຼວ! + ແບ່ງປັນຄຳເຊີນ + ກຳລັງຖ້າຜູ້ເຂົ້າຮ່ວມຄົນອື່ນ… + ແບ່ງປັນໜ້າຈໍ + ຍັງບໍ່ມີໄຟລ໌ບັນທຶກສຽງໃນຕອນນີ້… + ເພີ່ມໃສ່ລາຍຊື່ຜູ້ຕິດຕໍ່ + ຄັດລອກເບີໂທລະສັບ + ລຶບປະຫວັດ + ລຶບ + ເຊີນ + ສົ່ງຄືນໃໝ່ + ສະຖານະການສົ່ງ + ແກ້ໄຂ + ບໍ່ພົບຜົນລັບ… + ການເຊື່ອມຕໍ່ບັນຊີຜິດພາດ + ບັນຊີທີ່ເລືອກຖືກປິດໃຊ້ງານໃນຂະນະນີ້ + ເຈົ້າບໍ່ໄດ້ເຊື່ອມຕໍ່ອິນເຕີເນັດ + ກຳລັງດຳເນີນການ, ກະລຸນາຖ້າ + ການສົນທະນາ + ເພີ່ມຜູ້ເຂົ້າຮ່ວມ + diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml index fb069ad48a..4a4419f3a2 100644 --- a/app/src/main/res/values-night/themes.xml +++ b/app/src/main/res/values-night/themes.xml @@ -10,14 +10,11 @@ shortEdges true - @color/background_color_alt_dark_mode + @color/background_color_dark_mode ?attr/color_main1_500 @color/orange_main_100 - @color/orange_main_100_alpha_50 - @color/orange_main_300 @color/orange_main_500 - @color/orange_main_700 @color/background_color_dark_mode @color/gray_main2_800 @@ -50,9 +47,10 @@ @color/blue_info_500_night @color/orange_warning_600_night + @color/orange_plain_text_security_warning_600 @color/bc_white - @color/background_color_alt_dark_mode + @color/background_color_dark_mode @color/gray_main2_400 @color/gray_main2_100 @color/gray_main2_300 @@ -73,67 +71,60 @@ @color/gray_main2_600 @color/gray_600 - @color/background_color_alt_dark_mode - @color/background_color_alt_dark_mode + @color/gray_main2_800 + @color/background_color_dark_mode @color/gray_main2_600 #142932 @color/gray_main2_400 @color/background_color_alt_dark_mode + + @color/gray_500 - - - - - - + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 83598abd2f..da3765fa5b 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -20,7 +20,6 @@ %s dagen Doorgaan - OK Installeren Toon dit dialoogvenster niet meer Nee @@ -55,7 +54,6 @@ Dankzij onze end-to-end-encryptie is uw communicatie veilig. SIP-adres gekopieerd naar klembord Nieuw account geconfigureerd - Verbindingsfout! Fout bij het exporteren van een bestand naar de galerij Bestand is geëxporteerd naar de documenten Fout bij het exporteren van een bestand naar de documenten @@ -86,7 +84,7 @@ Pushmelding met autorisatietoken niet binnen 5 seconden ontvangen, probeer het later opnieuw Er is een onverwachte fout opgetreden, probeer het later opnieuw Verkeerde gebruikersnaam of wachtwoord - Inloggen is mislukt: foutcode is %s + Inloggen mislukt: foutcode is %s Verleen machtigingen OK Doe het later @@ -96,7 +94,6 @@ Kies hoe u uw account wilt herstellen. U heeft uw account aangemaakt met: Een e-mail - Verstuur de code Contacten Oproepen Gesprekken @@ -121,20 +118,19 @@ Probleemoplossing Logboeken afdrukken in logcat Logboeken opschonen - Deel logs + Logs delen App-versie SDK-versie Firebase-project-ID - Foutopsporingslogs zijn opgeschoond - Uploaden van foutopsporingslogs is mislukt + Debuglogs zijn opgeschoond + Uploaden van debuglogs mislukt Configuratie weergeven - Geïmporteerde contacten uit het lokale adresboek verwijderen Instellingen Beveiliging Versleutel alles Inschakelen van het versleutelingsmodule is mislukt! Versleutelingsmodule ingeschakeld - Weet u zeker dat u alles wilt versleutelen? + Weet je zeker dat je alles wilt versleutelen? Voorkom dat de interface wordt opgenomen Oproepen Softwarematige echo-onderdrukking gebruiken @@ -144,7 +140,6 @@ Geen echo Mislukt Adaptieve bitrate-regeling - Video inschakelen Video-FEC inschakelen Trillen tijdens inkomend gesprek Oproepen automatisch opnemen @@ -166,8 +161,8 @@ Bind-DN Wachtwoord TLS gebruiken - Zoekbasis (mag niet leeg zijn) - Filter + Zoeken + Zoekbasis (mag niet leeg zijn) SIP-attributen Er is een fout opgetreden, LDAP-server is niet opgeslagen! Vergaderingen @@ -189,9 +184,9 @@ Geen apparaat gevonden… Bewerk foto Verwijder foto - Dit account is online, iedereen kan u bellen. + Dit account is online, iedereen kan je bellen. Account is verbinding aan het maken met de server, even geduld aub… - Verbinding met account is mislukt, controleer uw instellingen. + Verbinding met account mislukt, controleer je instellingen. Internationale prefix Accountinstellingen Uitloggen @@ -202,7 +197,7 @@ Verwijderen Laatste verbinding: Uitloggen uit je account? - Als u uw account permanent wilt verwijderen, ga naar: https://sip.linphone.org + Als je je account permanent wilt verwijderen, ga naar: https://sip.linphone.org Accountinstellingen Open bestandskiezer Sla LDAP-configuratie op @@ -221,7 +216,7 @@ Begin een nieuw gesprek Zoek naar beneden Zoek omhoog - Gesprek wordt verwijderd + Zet geluid aan/uit voor dit gesprek Lang indrukken om voicemail te bellen Spraakberichten zijn beschikbaar @@ -248,11 +243,10 @@ Voor sommige functies is een &appName;-account vereist, zoals groepsberichten en videoconferenties.\n\nDeze functies zijn verborgen wanneer u zich registreert met een SIP-account van derden.\n\nNeem contact met ons op om deze functie in een commercieel project toe te voegen. Pushmeldingen lijken niet beschikbaar te zijn op uw apparaat, maar ze zijn verplicht voor het aanmaken van een account in de mobiele app.\n\nWe raden u aan om in plaats daarvan een account aan te maken op ons webplatform: Om optimaal van &appName; te kunnen genieten, moet u ons de volgende rechten verlenen: - Voer het telefoonnummer in dat aan uw account is gekoppeld, dan sturen wij u een code. Uw versie is up-to-date Er is een fout opgetreden tijdens het controleren op updates Deel link naar foutopsporingslogs via… - Zodra dit is geactiveerd, moet u de app opnieuw starten.\nDaarna wordt alle applicatiedata versleuteld en is deze alleen nog toegankelijk via de applicatie.\n\nWees voorzichtig: dit kan niet ongedaan worden gemaakt! + Zodra dit is geactiveerd, moet je de app opnieuw starten.\nDaarna wordt alle applicatiedata versleuteld en is deze alleen nog toegankelijk via de applicatie.\n\nWees voorzichtig: dit kan niet ongedaan worden gemaakt! Beltoon wijzigen Gesprek als gelezen markeren bij het sluiten van berichtmelding Een beveiligde, open source en Franstalige communicatie-app. @@ -273,8 +267,8 @@ Maximum aantal resultaten Time-out (in seconden) Tijd tussen twee aanvragen (in milliseconden) - Account is uitgeschakeld, u ontvangtontvangt geen oproepen of berichten. - Kies je land zodat &appName; uw contacten kan koppelen. + Account is uitgeschakeld, je ontvangt geen oproepen of berichten. + Kies je land zodat &appName; je contacten kan koppelen. Klik om het onderwerp van dit gesprek te bewerken Gelieve minstens de weergavenaam en de server-URL in te vullen Standaardindeling @@ -289,12 +283,11 @@ Minimale aantal tekens om een query te starten Naam-attributen SIP-domein - Server-URL mag niet leeg zijn Help ontwikkelaars bij het oplossen van problemen door logbestanden naar Crashlytics te sturen na een crash Audiocodecs Android-instellingen van &appName; Eind-tot-eind versleutelde modus - Deze modus garandeert de vertrouwelijkheid van uw gegevens. Onze eind-tot-eind versleutelingstechnologie biedt het hoogste niveau van beveiliging voor uw communicatie. + Deze modus garandeert de vertrouwelijkheid van je gegevens. Onze eind-tot-eind versleutelingstechnologie biedt het hoogste niveau van beveiliging voor je communicatie. Bewerk LDAP-server Voeg CardDAV-adresboek toe Bewerk CardDAV-adresboek @@ -346,7 +339,7 @@ TURN-gebruikersnaam AVPF Vervalt (in seconden) - Conferentiefabriek-URI + Conference factory URI Schakel ICE in TURN-wachtwoord URI van de Audio/video conference factory @@ -360,7 +353,6 @@ Vervang + door 00 bij het formatteren van telefoonnummers Wachtwoord bijwerken Authenticatie vereist - Gespreksgeschiedenis Maak een groepsgesprek Gesprek Weet je zeker dat je alle gespreksgeschiedenis wilt verwijderen? @@ -379,7 +371,7 @@ Achternaam Bedrijf Wijzigingen zijn succesvol opgeslagen - Opslaan van wijzigingen is mislukt! + Opslaan van wijzigingen mislukt! Contact is succesvol aangemaakt Wijzigingen niet opslaan? Alle wijzigingen gaan verloren @@ -392,7 +384,7 @@ Bewerken Toevoegen aan favorieten Verwijderen uit favorieten - Deel + Delen Verwijderen %s verwijderen? Dit contact wordt definitief verwijderd. @@ -418,14 +410,14 @@ Geen overeenkomend resultaat… Zeg iets… Inkomende video-oproep - U heeft de oproep beëindigd + Je hebt de oproep beëindigd De gesprekspartner heeft de oproep beëindigd Inkomende oproep voor %s Zet %s over naar… Huidige oproepen Geen andere oproep Bevestig oproepoverdracht - U staat op het punt oproep %1$s over te dragen naar %2$s. + Je staat op het punt oproep %1$s over te dragen naar %2$s. Doorverbinden Nieuwe oproep Oproepenlijst @@ -440,7 +432,7 @@ Zoeken Gesprek informatie Functietitel - Contact aanmaken is mislukt! + Contact aanmaken mislukt! Deelnemers toevoegen Nieuwe oproep Zoek contact of gespreksgeschiedenis @@ -451,10 +443,10 @@ Vul alstublieft een naam in voor het gesprek Uitgaande oproep Berichten - U staat op het punt een oproep te doen naar %1$s\'s apparaat %2$s.\nWilt u de oproep doen? + Je staat op het punt een oproep te doen naar %1$s\'s apparaat %2$s.\nWil je de oproep doen? Inkomende oproep Inkomende video-oproep voor %s - Verbinding is mislukt omdat de authenticatie ontbreekt of ongeldig is voor account \n%s.\n\nJe kunt het wachtwoord opnieuw invoeren of je accountconfiguratie controleren in de instellingen. + Verbinding mislukt omdat de authenticatie ontbreekt of ongeldig is voor account \n%s.\n\nJe kunt het wachtwoord opnieuw invoeren of je accountconfiguratie controleren in de instellingen. Wachtwoord Kan overeenkomend account niet vinden! Geen suggestie en geen contact op dit moment… @@ -496,7 +488,7 @@ Deelnemen Annuleren Verbinding wordt tot stand gebracht - U zal zo meteen deelnemen + Je zult zo meteen deelnemen Overgaan Deelnemen aan de vergadering mislukt! Actief @@ -550,17 +542,17 @@ Apparaat gevalideerd Oproep wordt doorverbonden Oproep is succesvol doorverbonden - Doorverbinden van oproep is mislukt! + Doorverbinden van oproep mislukt! Gebruiker is niet gevonden Niet-compatibele mediaparameters Service niet beschikbaar of netwerkfout Server time-out - Deel uitnodiging + Uitnodiging delen Wachten op andere deelnemers… - Deel scherm + Scherm delen Deelnemers - Groepsoproep aanmaken is mislukt! - Samenvoegen van oproepen is mislukt! + Groepsoproep aanmaken mislukt! + Samenvoegen van oproepen mislukt! Weet je zeker dat je deze deelnemer uit het groepsgesprek wilt verwijderen? Deelnemer is uit het groepsgesprek verwijderd Deelnemen… @@ -587,23 +579,17 @@ Doorsturen Kopiëren Downloaden - Deel - Geel + Delen Toestemming voor meldingen niet verleend! Toestemming om inkomende oproepen te tonen is niet verleend! Overslaan Wachtwoord vergeten? Overslaan - Groen - Blauw - Rood - Roze - Paars Geen resultaat gevonden… Geselecteerde deelnemers zullen hier verschijnen Account(s) verbindingsfout Geselecteerd account is momenteel uitgeschakeld - U bent niet verbonden met internet + Je bent niet verbonden met internet Bezig met bewerking, even geduld alstublieft Gesprekken Contacten @@ -631,7 +617,7 @@ Wijzigt de gebruikte camera (voor/achter) Oproep is gepauzeerd Toont oproepstatistieken - U bent deze oproep aan het opnemen + Je bent deze oproep aan het opnemen Verwijdert het laatste cijfer of teken Opent het filtergebied Sluit het filtergebied @@ -641,26 +627,26 @@ Toont het numpad Klik om alle beschikbare opties te tonen Deelnemer is gedempt - Voeg deelnemers toe - Speelt/pauzeert afspelen van de audio-opname - Speelt/pauzeert afspelen van de video-opname - Deel bestand + Deelnemers toevoegen + Speelt/pauzeert de audio-opname + Speelt/pauzeert de video-opname + Bestand delen Bestand opslaan - Afbeelding toegevoegd aan het bericht + Afbeelding toegevoegd aan bericht Video toegevoegd aan bericht - Bestand is toegevoegd aan het bericht + Bestand toegevoegd aan bericht Dit bericht is een reactie op een eerder bericht Klik om de bezorgstatus te bekijken - Speelt/pauzeert afspelen van het spraakbericht + Speelt/pauzeert de spraakbericht-opname Verwijder dit bestand uit de bijlagen Sluit bijlagen Gesprek is gedempt Tijdelijke berichten zijn ingeschakeld Sluit de deelnemerslijst Annuleert de opname van het spraakbericht - Stopt het opnemen van een spraakbericht - Start het opnemen van een spraakbericht - Stuurt een bericht in het gesprek + Stopzetting van de spraakbericht-opname + Start met opnemen van een spraakbericht + Stuurt bericht in gesprek Opent de emoji keuzelijst Plan een vergadering @@ -676,7 +662,6 @@ Gewaarborgde vertrouwelijkheid Dit gesprek is niet versleuteld! Voor je veiligheid is dit gesprek uitgeschakeld. - Verplichte versleuteling Maximaal aantal bijlagen bereikt! Gespreksonderwerp instellen Gespreksonderwerp bewerken @@ -690,7 +675,7 @@ Openen als platte tekst Spraakopname kan niet worden afgespeeld! Bericht is verwijderd - Gesprek aanmaken is mislukt! + Gesprek aanmaken mislukt! Geselecteerde media niet gevonden Gespreksonderwerp is veranderd Tijdelijke berichten zijn ingeschakeld @@ -699,7 +684,7 @@ Maximale duur bereikt Deelnemer(s) konden niet aan het gesprek worden toegevoegd Gesprek is succesvol verwijderd - U heeft de groep verlaten + Je hebt de groep verlaten Gesprek niet gevonden Geen overeenkomend resultaat gevonden Laatste overeenkomend resultaat bereikt @@ -725,8 +710,8 @@ Contact niet gevonden Groepsoproep starten? Alle deelnemers ontvangen een oproep. - U bent bij de groep gekomen - U heeft de groep verlaten + Je bent bij de groep gekomen + Je hebt de groep verlaten %s is erbij gekomen %s is vertrokken Nieuw apparaat voor %s @@ -778,17 +763,14 @@ Vergadering kan niet worden gevonden! Beschrijving Vergadering bewerken - Vergadering annuleren? Vergadering annuleren - Vergadering verwijderen? - Wil je de vergadering verwijderen? Vergadering verwijderen Vergadering is aangemaakt Vergadering is bijgewerkt Vergadering is geannuleerd Vergadering geannuleerd - Vergadering inplannen is mislukt! - Vergadering bewerken is mislukt! + Vergadering inplannen mislukt! + Vergadering bewerken mislukt! Wachten op versleuteling… Voor uw veiligheid moeten we het apparaat van uw gesprekspartner authentiseren.\nWissel alstublieft uw codes uit: Voor uw veiligheid moeten we het apparaat van uw gesprekspartner opnieuw authentiseren.\nWissel alstublieft opnieuw uw codes uit: @@ -796,7 +778,7 @@ &appName; kan dit bestand niet openen.\n\nWil je het openen in een andere app (indien mogelijk), of het exporteren naar je apparaat? Ongeldig SIP-adres, kan niet worden toegevoegd aan de conferentie Dit bericht is doorgestuurd vanuit een ander gesprek - Scrolt naar het eerste ongelezen bericht of naar het einde + Scrolt naar het eerste ongelezen bericht of naar de onderkant Bericht zal geen reactie meer zijn op een eerder bericht Verloren pakketten: %s Media-versleuteling @@ -807,7 +789,6 @@ Deelnemers (%s) Dankzij de eind-tot-eind versleutelingstechnologie in &appName; zijn de vertrouwelijkheid van berichten, oproepen en vergaderingen gegarandeerd. Niemand kan de uitgewisselde gegevens decoderen, zelfs wij niet. - U heeft verplichte versleuteling ingeschakeld. Niet-versleutelde gesprekken zijn uitgeschakeld voor uw veiligheid. U kunt dit gesprek opnieuw aanmaken of verplichte versleuteling uitschakelen in uw accountinstellingen. Te veel deelnemers voor mozaïeklay-out SIP-adres kopiëren Inkomend @@ -822,13 +803,11 @@ Gesprek kan niet worden aangemaakt met een deelnemer die zich niet op hetzelfde domein bevindt vanwege beveiligingsbeperkingen! Geen adres om aan contact toe te voegen Er is geen app gevonden om dit type bestand te openen - Wachtwoord beveiligde Pdf\'s kunnen nog niet worden geopend Wil je echt alle berichten verwijderen? Tijdelijke berichten zijn ingeschakeld Man-in-the-middle aanval gedetecteerd voor %s Deelnemers toevoegen Vergadering is verwijderd - Wil je de vergadering annuleren en een melding naar alle deelnemers sturen? Oranje %s melding voor ander(en) account(s) @@ -841,14 +820,19 @@ Dempen/dempen opheffen microfoon Wijzigt het uitvoerapparaat voor audio Voegt oproepen samen in een groepsgesprek - Leer alle functies van de app te beheersen, stap voor stap. - Verstuur uw diagnostische logs om het oplossen van fouten te vergemakkelijken. - Punt-naar-puntversleuteling door ZRTP + Zoeken + Bewerken Verbinding verbroken - Is ingeschakeld - Abboneer op aanwezigheidsinformatie - Klik om vrijgeven van bestanden of teksten te annuleren. &appName; gebruikershandleiding + Leer alle functies van de app te beheersen, stap voor stap. Help mee de app toegankelijk te maken aan zoveel mogelijk personen. + Verstuur uw diagnostische logs om het oplossen van fouten te vergemakkelijken. + Abboneer op aanwezigheidsinformatie + Is ingeschakeld + Geïmporteerde contacten uit het lokale adresboek verwijderen Dit account is offline, waarschijnlijk omdat u momenteel niet met internet bent verbonden. + Wachtwoord beveiligde Pdf\'s kunnen nog niet worden geopend + Deelnemers + Punt-naar-puntversleuteling door ZRTP + Klik om vrijgeven van bestanden of teksten te annuleren. diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 6fcd440d9a..3547b7843b 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -3,37 +3,42 @@ ]> + Adres SIP ID urządzenia Nazwa wyświetlana Domena Nazwa użytkownika + Identyfikator uwierzytelnienia (jeśli inny) Hasło Numer telefonu lub Dalej Rozpocznij Dziś + Wczoraj + + %s dzień + %s dni + %s dni + %s wybrany - %s wybranych %s wybranych Nie zezwalaj Akceptuj Anuluj - OK + Kontynuuj Zadzwoń Usuń Instaluj + Nie pokazuj więcej tego okna dialogowego Nie Tak Usuń Potwierdź - &appName; powiadomienia o aktywnych połączeniach - &appName; powiadomienia o połączeniach przychodzących - &appName; powiadomienia usługi - &appName; powiadomienia o wiadomościach błyskawicznych + Ta usługa będzie działać przez cały czas, aby utrzymać aplikację w stanie aktywnym i umożliwić odbieranie połączeń i wiadomości bez powiadomień push. Reakcja %1$s na: %2$s Zaznacz jako odczytane Odpowiedz @@ -43,33 +48,38 @@ Nieodebrane połączenie &appName; Wyszukiwanie nowych wiadomości + Usuń &appName; Trwa przesyłanie plików %s plik jest przesyłany - %s plików jest przesyłanych %s plików jest przesyłanych %s plik jest pobierany - %s plików jest pobieranych %s plików jest pobieranych Kliknij, aby otworzyć Witamy + w &appName; + Bezpieczna , otwartoźródłowa i francuska aplikacja komunikacyjna. Zabezpieczone Twoja komunikacja jest bezpieczna dzięki naszemu szyfrowaniu typu end-to-end. Open source Bezpłatna aplikacja o otwartym kodzie źródłowym od 2001 roku. + Adres SIP został skopiowany do schowka Nowe konto zostało skonfigurowane - Błąd połączenia! + Plik został wyeksportowany do galerii natywnej Błąd podczas próby eksportu pliku do natywnej galerii Plik został wyeksportowany do dokumentów Błąd podczas próby eksportowania pliku do dokumentów + Głośność mediów jest niska, możesz nic nie słyszeć! Konfiguracja została pomyślnie zastosowana Błąd podczas próby pobrania i zastosowania zdalnej konfiguracji + Błąd podczas próby utworzenia odtwarzacza multimediów + Ogólne warunki i polityka prywatności ogólne warunki polityka prywatności Kontynuując, akceptujesz nasze %1$s i %2$s. @@ -78,90 +88,66 @@ Login Zeskanuj kod QR Nieprawidłowy kod QR! + Użyj konta SIP innej firmy + Konto SIP innej firmy Pojedyncze logowanie + Adres SIP jest nieprawidłowy! Adres SIP nie zawiera nazwy użytkownika! Konto już istnieje Nie masz jeszcze konta? Zarejestruj się + Potwierdź swój numer telefonu Wysłaliśmy kod weryfikacyjny na numer telefonu %1$s.\n\nWpisz poniżej kod weryfikacyjny: Zły numer? Utwórz Załóż konto, podając swój adres e-mail na stronie: Masz już konto? Transport + Niektóre funkcje wymagają konta &appName;, np. wiadomości grupowe, wideokonferencje…\n\nFunkcje te są ukryte, gdy rejestrujesz się za pomocą konta SIP innej firmy.\n\nAby włączyć je w projekcie komercyjnym, skontaktuj się z nami. Wolę utworzyć konto &appName; Zrozumiałem + Powiadomienia push nie wydają się być dostępne na Twoim urządzeniu, ale są one obowiązkowe do utworzenia konta w aplikacji mobilnej.\n\nZapraszamy do utworzenia konta na naszej platformie internetowej: Powiadomienie push z tokenem uwierzytelniającym nie zostało otrzymane w ciągu 5 sekund. Spróbuj ponownie później. + Wystąpił nieoczekiwany błąd, spróbuj ponownie później Nieprawidłowa nazwa użytkownika lub hasło Nie udało się zalogować: kod błędu to %s Przyznaj uprawnienia OK + Zrób to później + Aby w pełni korzystać z aplikacji &appName;, musisz udzielić nam następujących uprawnień : + Odczytuj kontakty: Aby wyświetlić swoje kontakty i znaleźć osoby korzystające z aplikacji &appName;. Powiadomienia o wiadomościach: Aby otrzymywać powiadomienia o otrzymaniu wiadomości lub połączenia. - Zapomniałem hasła + Nagrywanie dźwięku: Aby korespondent mógł Cię usłyszeć i nagrywać wiadomości głosowe. + Dostęp do kamery: Do nagrywania filmów podczas rozmów wideo i konferencji. + Wybierz sposób odzyskania konta. + Konto zostało utworzone przy użyciu: E-mail Numer telefonu - Wyślij kod + Powiadomienia push nie wydają się być dostępne na Twoim urządzeniu, ale są one obowiązkowe do odzyskania konta numeru telefonu w aplikacji mobilnej. + Kontakty Połączenia Rozmowy Spotkania Zarządzaj profilem Połączono Rozłączono - - %s dzień - - %s dni - %s dni - - Kontynuuj - Użyj konta SIP innej firmy - Potwierdź swój numer telefonu - Adres SIP - Wczoraj - Błąd podczas próby utworzenia odtwarzacza multimediów - Ogólne warunki i polityka prywatności - Dostęp do kamery: Do nagrywania filmów podczas rozmów wideo i konferencji. - Wybierz sposób odzyskania konta. - Konto zostało utworzone przy użyciu: - Powiadomienia push nie wydają się być dostępne na Twoim urządzeniu, ale są one obowiązkowe do odzyskania konta numeru telefonu w aplikacji mobilnej. - Plik został wyeksportowany do galerii natywnej - Niektóre funkcje wymagają konta &appName;, np. wiadomości grupowe, wideokonferencje...\n\nFunkcje te są ukryte, gdy rejestrujesz się za pomocą konta SIP innej firmy.\n\nAby włączyć je w projekcie komercyjnym, skontaktuj się z nami. - Powiadomienia push nie wydają się być dostępne na Twoim urządzeniu, ale są one obowiązkowe do utworzenia konta w aplikacji mobilnej.\n\nZapraszamy do utworzenia konta na naszej platformie internetowej: - w &appName; - Bezpieczna , otwartoźródłowa i francuska aplikacja komunikacyjna. - Zrób to później - Nagrywanie dźwięku: Aby korespondent mógł Cię usłyszeć i nagrywać wiadomości głosowe. - Wprowadź numer telefonu powiązany z Twoim kontem, a my wyślemy Ci kod. - Adres SIP jest nieprawidłowy! - &appName; powiadomienia o nieodebranych połączeniach - Odczytuj kontakty: Aby wyświetlić swoje kontakty i znaleźć osoby korzystające z aplikacji &appName;. - Identyfikator uwierzytelnienia (jeśli inny) - Ta usługa będzie działać przez cały czas, aby utrzymać aplikację w stanie aktywnym i umożliwić odbieranie połączeń i wiadomości bez powiadomień push. - Kontakty - Aby w pełni korzystać z aplikacji &appName;, musisz udzielić nam następujących uprawnień : - Głośność mediów jest niska, możesz nic nie słyszeć! - Nie pokazuj więcej tego okna dialogowego - Adres SIP został skopiowany do schowka - Konto SIP innej firmy - Wystąpił nieoczekiwany błąd, spróbuj ponownie później Połączenia Rozmowy Kontakty - Spotkania - Rozmowy - Kontakty - Hasło - ID urządzenia - Usuń - Zadzwoń - Anuluj Nazwa wyświetlana Nazwa użytkownika + Hasło Hasło + Spotkania + ID urządzenia Usuń Hasło + Zadzwoń Zaznacz jako odczytane Zadzwoń + Anuluj Usuń Odpowiedz + Rozmowy + Kontakty diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index fb020f3040..661022f795 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -103,7 +103,6 @@ Aceitar Cancelar Continuar - OK Chamar Excluir Instalar @@ -133,7 +132,6 @@ Um aplicativo gratuito e de código aberto desde 2001 . Endereço SIP copiado para a área de transferência Nova conta configurada - Erro de conexão! O arquivo foi exportado para a galeria nativa Erro ao tentar exportar o arquivo para a galeria nativa Erro ao tentar exportar o arquivo para documentos @@ -177,8 +175,6 @@ Você criou sua conta usando: Um e-mail Um número de telefone - Digite o número de telefone vinculado à sua conta, enviaremos um código. - Enviar o código Contatos Chamadas Conversas @@ -211,7 +207,6 @@ Compartilhar link de logs de depuração usando… Logs de depuração foram limpos Mostrar configuração - Limpar contatos importados da agenda nativa Configurações Segurança Criptografar tudo @@ -227,7 +222,6 @@ sem eco %s ms falhou - Ativar vídeo Ativar FEC de vídeo Iniciar gravação de chamadas automaticamente Alterar toque @@ -261,7 +255,6 @@ Atributos de nome Domínio SIP Ocorreu um erro, servidor LDAP não salvo! - A URL do servidor não pode ficar em branco Reuniões Layout padrão Orador ativo @@ -288,7 +281,6 @@ Remover dos favoritos Compartilhar Excluir - Histórico de chamadas Nova chamada Pesquisar contato ou histórico de chamadas Nenhuma chamada no momento… @@ -417,7 +409,6 @@ Graças à tecnologia de criptografia de ponta a ponta no &appName;, a confidencialidade de mensagens, chamadas e reuniões é garantida. Ninguém pode descriptografar os dados trocados, nem mesmo nós. Esta conversa não está criptografada! Para sua segurança, esta conversa foi desativada. - Criptografia obrigatória O assunto é obrigatório Assunto da conversa Abrir ou exportar arquivo? @@ -442,7 +433,6 @@ Abrir galeria Escolher arquivo O arquivo não pode ser aberto! - Não é possível abrir PDFs protegidos por senha ainda Membros do grupo (%s) Adicionar participantes Admin @@ -512,11 +502,7 @@ A reunião não pode ser encontrada! Descrição Editar reunião - Cancelar a reunião? - Você quer cancelar a reunião e enviar uma notificação para todos os participantes? Cancelar reunião - Excluir a reunião? - Você quer excluir a reunião? Excluir reunião A reunião foi criada A reunião foi atualizada @@ -583,11 +569,6 @@ Baixar Compartilhar Laranja - Amarelo - Azul - Vermelho - Rosa - Roxo Nenhum resultado encontrado… Os participantes selecionados aparecerão aqui Erro de conexão da(s) conta(s) @@ -656,7 +637,6 @@ Abre o seletor de arquivos Clique para editar o assunto desta conversa Silencia/reativa o som desta conversa - A conversa está sendo removida Esta conversa não é segura Pesquisar abaixo Rolar para hoje @@ -709,7 +689,6 @@ Nova conversa Nova conversa em grupo Por favor, insira um nome para a conversa - Você ativou a criptografia obrigatória. Conversas não criptografadas estão desativadas para sua segurança. Você pode recriar esta conversa ou desativar a criptografia obrigatória nos parâmetros da sua conta. Número máximo de anexos atingido! Definir assunto da conversa Editar assunto da conversa @@ -739,7 +718,6 @@ %s de novas mensagens de voz %s novas mensagens de voz - Verde %s arquivo esperando para ser compartilhado %s de arquivos esperando para ser compartilhados @@ -860,4 +838,19 @@ Mensagens de voz estão disponíveis Pressione e segure para ligar para o correio de voz Sair + Pesquisar + Participantes + Limpar contatos importados da agenda nativa + Não é possível abrir PDFs protegidos por senha ainda + Editar + Entendido + &appName; notificações de erro da conta + Clique nesta notificação para corrigí-la + Não cancele + Essa notificação mantém o aplicativo em execução em segundo plano + Desligue o alto-falante + Ligue o alto-falante + Seu interlocutor não o ouve! + Falha no registro da conta %s! + Abra &appName; para atualizar o registro diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 3132607fcf..828c652f31 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -28,7 +28,6 @@ Принять Отменить Продолжить - ОК Звонок Удалить Больше не показывать этот диалог @@ -59,7 +58,6 @@ Бесплатное приложение с открытым исходным кодом с 2001 года. SIP-адрес скопирован в буфер обмена Новая учетная запись настроена - Ошибка подключения! Файл экспортирован в документы Ошибка при попытке экспортировать файл в документы Конфигурация успешно применена @@ -224,7 +222,6 @@ Выберите страну, чтобы разрешить &appName; сопоставлять ваши контакты. Этот режим позволяет вам пользоваться всеми функциями &appName;, сохраняя при этом совместимость с любым SIP-провайдером посредством шифрования «точка-точка». Тайм-аут (в секундах) - URL сервера не может быть пустым URL-адрес удалённого конфигурирования Этот режим гарантирует конфиденциальность ваших данных. Наша технология сквозного шифрования обеспечивает наивысший уровень безопасности для ваших коммуникаций. STUN/TURN сервер @@ -260,7 +257,6 @@ Пароль забыт Выберите способ восстановления вашей учетной записи. Номер телефона - Отправить код Контакты Звонки Беседы @@ -284,7 +280,6 @@ Поделитесь ссылкой на журналы отладки с помощью… Журналы отладки были очищены Показать конфигурацию - Очистить импортированные контакты из встроенной адресной книги Настройки Безопасность Зашифровать все @@ -297,7 +292,6 @@ %s мс калибровка не удалась Адаптивный контроль скорости - Включить видео Включить видео FEC Автоматически начинать запись звонков Изменить рингтон @@ -316,7 +310,6 @@ Подключение не удалось, так как аутентификация отсутствует или недействительна для учетной записи\n%s\n\nВы можете снова указать пароль или проверить конфигурацию аккаунта в настройках. Пароль Не удалось найти соответствующий аккаунт! - История звонков Новый вызов Найти контакт или историю звонков Создать групповой звонок @@ -407,7 +400,6 @@ Этот разговор не зашифрован! В целях вашей безопасности эта беседа была отключена. - Обязательное шифрование Достигнуто максимальное количество вложений! Установить тему беседы Изменить тему беседы @@ -434,7 +426,6 @@ Сфотографировать Открыть галерею Выбрать файл - Пока не удается открыть PDF-файлы защищенные паролем Участники группы (%s) Добавить участников Администратор @@ -493,10 +484,7 @@ Встреча не найдена! Описание Редактировать встречу - Отменить встречу? Отменить встречу - Удалить встречу? - Вы хотите удалить встречу? Удалить встречу Встреча была обновлена Встреча отменена @@ -616,11 +604,6 @@ Копировать Поделиться Оранжевый - Жёлтый - Синий - Красный - Розовый - Фиолетовый Выбранные участники появятся здесь Вы не подключены к интернету Выбранный аккаунт сейчас отключен @@ -688,7 +671,6 @@ Открывает средство выбора файлов Нажмите, чтобы изменить тему этой беседы Включает/выключает эту беседу - Беседа удаляется Поиск вверх Поиск вниз Начать новую беседу @@ -729,7 +711,6 @@ Удалить %s из конференции? Включает/выключает видимость пароля Расширяет/втягивает нижний лист - Введите номер телефона, привязанный к вашему аккаунту, и мы отправим вам код. Распечатать логи в logcat Очистить логи Имя @@ -765,7 +746,6 @@ Нет соответствующих результатов… Сквозное шифрование беседы Сообщения в этой беседе зашифрованы end-to-end шифрованием. Расшифровать их может только ваш собеседник. - Вы включили обязательное шифрование. Незашифрованные беседы отключены для вашей безопасности. Вы можете заново создать эту беседу или отключить обязательное шифрование в параметрах вашего аккаунта. Экспортировать файл Просмотреть профиль контакта Вы собираетесь позвонить на устройство %2$s пользователя %1$s.\nХотите позвонить? @@ -781,7 +761,6 @@ Алгоритм аутентификации: %s Пока нет избранного контакта Скачать - Зелёный Обнаружена атака типа «человек посередине» для %s Сообщение было переслано На сегодня встреча не запланирована @@ -795,7 +774,6 @@ Попробуйте еще раз Громкая связь Выберите время начала - Хотите отменить встречу и отправить уведомление всем участникам? Внимание: после включения отключить невозможно! калибровка началась Вибрация при входящем звонке @@ -865,4 +843,92 @@ &appname; Руководство пользователя Узнайте, как освоить все функции приложения, шаг за шагом. Помогите сделать приложение доступным как можно большим количеством людей. + Изменить + Найти + Очистить импортированные контакты из встроенной адресной книги + Пока не удается открыть PDF-файлы защищенные паролем + Участники + Понимаю + Уведомления об ошибках аккаунта + Регистрация аккаунта %s не удалась! + Вы хотите отправить уведомление всем участникам? + Выбор мелодии звонка недоступен! + Не показывать содержимое собщения в уведомлениях Android + Используйте редактор контактов &appName; для родных контактов + Расширенные настройки звонков + Автоматический ответ с включенным видео в обоих направлениях + Очистить информацию об авторизации, которая больше не ассоциирована ни с одним аккаунтом + Отменяет редактирование сообщения + Сообщения не зашифрованы сквозным шифрованием, убедитесь, что вы не делитесь чувствительной информацией! + Достигнут лимит результатов поиска, уточните ваш запрос. + Удалить для меня + Удалить для всех + Вы удалили это сообщение + Участники не найдены + Ответить + Сквозное шифрование через ZRTP + Закрывает меню + Лаванда + Мёд + Бургундский + Мята + Корал + Вишня + Титан + Терракотовый + <отредактировано> + Отсортировать контакты по + Скрыть контакты без SIP-адреса или номера телефона + Подписаться на информацию о присутствии + Эта адресная книга доступна только для чтения + Добавить логи LDAP в журналы &appName; + Включить индикаторы громкости записи/воспроизведения во время звонка + Показать расширенную статистику звонков + Статус соединения + Сменить аккаунт + Незашифрованный диалог + Сообщение редактируется + Отредактировано + ICE: %s + Зашифровано сквозным шифрованием + Не отменять + Включить динамик + Выключить динамик + Откройте &appName; чтобы обновить регистрацию + Ваш собеседник вас не слышит! + Нажмите на уведомление, чтобы это исправить + Проверка номера телефона недоступна + Проверка номера телефона недоступна, пожалуйста, используйте электронную почту для создания аккаунта. + Зарегистрироваться с помощью электронной почты + Алгоритм(ы) LIME (разделены запятыми) + Допустимые значения: c25519, c448, c25519k512, c25519mlk512 and c448mlk1024 + Отключено + Исходящий SIP прокси + Это сообщение было удалено + Встреча будет отменена + Отклонить + Микрофон + HDMI + Отсортировать прошедшие встречи + + %s непривязанная авторизация удалена + %s непривязанных авторизаций удалены + %s непривязанных авторизаций удалены + %s непривязанных авторизаций удалены + + Список совместимых с push-уведомлениями доменов (разделены запятой) + Они будут импортированы снова при следующем запуске приложения, если вы не уберёте разрешение на доступ к контактам + Импортированные контакты были удалены + Этот аккаунт оффлайн, возможно потому что вы сейчас не подключены к интернету. + Если это поле заполнено, исходящий прокси будет включен автоматически. Оставьте его пустым, чтобы отключить исходящий прокси. + Все поля должны быть заполнены + Early-media + Автоматический ответ + Кликните ещё 2 раза, чтобы включить настройки разработчика + Кликните ещё 1 раз, чтобы включить настройки разработчика + Не найдена информация о непривязанной авторизации + Минеральный синий + Сообщения в этом диалоге могут быть перехвачены и прочитаны посторонними людьми, конфиденциальность не гарантирована! + Диапазон IP: %s + Невозможно открыть этот PDF-файл, возможно он повреждён diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index e89efb1199..083ebf39be 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -43,7 +43,6 @@ SIP adresa ID pre overenie (ak je odlišné) Služba bude bežať trvalo, aby zabezpečila funkčnosť aplikácie a príjem hovorov a správ bez použitia push oznámení. - OK Tento dialóg už nezobrazovať Oznámenia o prichádzajúcich hovoroch &appName; Označiť ako prečítané @@ -76,7 +75,6 @@ Bezplatná a otvorená aplikácia od roku 2001. SIP adresa skopírovaná do schránky Nový účet bol nastavený - Chyba pripojenia! Súbor bol exportovaný do systémovej galérie Chyba pri exportovaní súboru do systémovej galérie Súbor bol exportovaný do dokumentov @@ -129,7 +127,6 @@ Váš účet bol vytvorený pomocou: E-mailu Telefónneho čísla - Poslať kód Push notifikácie sú na Vašom zariadení nedostupné, avšak sú nevyhnutné pre obnovu účtu s telefónnym číslom v mobilnej aplikácii. Kontakty Hovory @@ -164,7 +161,6 @@ Zdieľať odkaz na ladiace záznamy pomocou… Ladiace záznamy boli vyčistené Zobraziť konfiguráciu - Vymazať importované kontakty zo systémového adresára Nastavenia Zabezpečenie Šifrovať všetko @@ -180,7 +176,6 @@ bez ozveny %s ms zlyhalo - Povoliť video Povoliť FEC pre video Automaticky spustiť nahrávanie hovorov Zmeniť vyzváňací tón @@ -214,7 +209,6 @@ Atribúty SIP Doména SIP Nastala chyba, server LDAP nebol uložený! - URL adresa servera nemôže byť prázdna Schôdzky Konverzácie Aktívny hovoriaci @@ -307,7 +301,6 @@ Vyžaduje sa overenie Heslo Nepodarilo sa nájsť zodpovedajúci účet! - História hovorov Nový hovor Hľadať kontakt alebo históriu hovoru Vytvoriť skupinový hovor @@ -325,7 +318,6 @@ Obľúbené Konverzácie Nahrávanie zvuku: Aby Vás osoba na druhej strane mohla počuť a aby ste mohli nahrávať hlasové správy. - Zadajte telefónne číslo spojené s Vašim účtom, pošleme Vám kód. Zapisovať záznamy do logcatu Chyba pri nahrávaní ladiacich záznamov Vyberte svoju krajinu, aby &appName; mohol správne spárovať Vaše kontakty. @@ -464,7 +456,6 @@ Správy v tejto konverzácii sú koncovo šifrované. Dešifrovať ich môže len váš partner v konverzácii. Táto konverzácia nie je šifrovaná! Táto konverzácia bola z bezpečnostných dôvodov deaktivovaná. - Povinné šifrovanie Dosiahnutý maximálny možný počet príloh! Nastaviť predmet konverzácie Upraviť predmet konverzácie @@ -529,13 +520,11 @@ Zdieľané médiá Zaručená dôvernosť Vďaka technológii koncového šifrovania v &appName; je zachovaná dôvernosť správ, hovorov a schôdzok. Vymieňané údaje nemôže dešifrovať nikto, ani my sami. - Heslom chránené PDF zatiaľ nie je možné otvoriť Skutočne chcete vymazať všetky správy? %s je odteraz administrátorom Nenájdená žiadna aplikácia na otvorenie tohto typu súboru Maximálny počet účastníkov bol prekročený o %s Nenájdená žiadna aplikácia na otvorenie tohto typu súboru.\n\nChcete ho otvoriť ako textový súbor? - Aktivovali ste povinné šifrovanie. Z dôvodu vašej bezpečnosti boli nešifrované konverzácie deaktivované. Konverzáciu môžete znovu vytvoriť, alebo v nastaveniach účtu vypnúť povinné šifrovanie. %s sa odpojil Doba platnosti dočasných (miznúcich) správ je teraz %s Dočasné (miznúce) správy boli povolené @@ -575,10 +564,7 @@ Schôdzka nebola nájdená! Popis Upraviť schôdzku - Zrušiť schôdzku? Zrušiť schôdzku - Vymazať schôdzku? - Želáte si vymazať schôdzku? Vymazať schôdzku Schôdzka bola aktualizovaná Schôdzka bola zrušená @@ -651,7 +637,6 @@ Pozastavené vzdialenou stranou Preposlanie správy bolo zrušené Odoslať pozvánku účastníkom - Želáte si zrušiť schôdzku a odoslať oznámenie všetkým účastníkom? Šírka pásma: %s Pozastaviť Schôdzka bola vytvorená @@ -720,12 +705,6 @@ Stiahnuť Zdieľať Oranžová - Žltá - Zelená - Modrá - Červená - Ružová - Fialová Žiadne výsledky… Tu sa zobrazia vybraní účastníci Chyba pripojenia účtu(-ov) @@ -795,7 +774,6 @@ Otvorí výber emoji Otvorí výber súboru Zapne/vypne stlmenie konverzácie - Konverzácia sa odstraňuje Táto konverzácia nie je zabezpečená Hľadať smerom hore Hľadať smerom dole @@ -857,4 +835,9 @@ Kliknutím upraviť názov konverzácie Vytvoriť kontakt Podržaním tlačidla volanie hlasovej schránky + Heslom chránené PDF zatiaľ nie je možné otvoriť + Účastníci + Hľadať + Vymazať importované kontakty zo systémového adresára + Upraviť diff --git a/app/src/main/res/values-sw600dp/dimen.xml b/app/src/main/res/values-sw600dp/dimen.xml index 78da8b09d1..86af787802 100644 --- a/app/src/main/res/values-sw600dp/dimen.xml +++ b/app/src/main/res/values-sw600dp/dimen.xml @@ -2,4 +2,11 @@ 150dp 600dp + 90dp + 500dp + 800dp + + 8 + + 350dp \ No newline at end of file diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 369c47ddca..8edfb93b83 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -21,7 +21,6 @@ Ваше спілкування убезпечене завдяки нашому наскрізному шифруванню. Відкритий код Новий обліковий запис налаштовано - Помилка підключення! Файл завантажено до документів Помилка під час спроби завантаження файлу до документів Конфігурацію успішно застосовано @@ -50,7 +49,6 @@ Прийняти Скасувати Продовжити - ОК Виклик Видалити Встановити @@ -123,7 +121,6 @@ Ви створили свій обліковий запис, використовуючи: Електронна пошта Номер телефону - Надіслати код Контакти Дзвінки Розмови @@ -154,7 +151,6 @@ Ще немає облікового запису? Для деяких функцій потрібен обліковий запис &appName;, такі як груповий обмін повідомленнями, відеоконференції…\n\nЦі функції приховані, якщо ви реєструєтесь за допомогою стороннього облікового запису SIP.\n\nЩоб увімкнути їх у комерційному проєкті, зв’яжіться з нами. Щоб повною мірою користуватися &appName;, вам потрібно надати застосунку наступні дозволи: - Введіть номер телефону, прив’язаний до вашого облікового запису, ми надішлемо вам код. Схоже, push-сповіщення недоступні на вашому пристрої, але вони обов’язкові для відновлення облікового запису номера телефону в мобільному застосунку. Помилка Обліковий запис ще не налаштовано @@ -170,10 +166,6 @@ Використовувати два сервери Авто URI сервера MWI (індикатор очікування повідомлення) - Зелений - Червоний - Рожевий - Фіолетовий Обрані учасники з\'являться тут %s нове голосове повідомлення @@ -181,7 +173,6 @@ %s нових голосових повідомлень %s нових голосових повідомлень - Розмова видаляється Основний колір Контакт в мережі Відобразити меню @@ -220,7 +211,6 @@ Копіювати Поділитись Помаранчевий - Жовтий Цей обліковий запис онлайн, кожен може вам зателефонувати. Не знайдено… Виберіть режим облікового запису @@ -241,7 +231,6 @@ Показує цифрову клавіатуру Учасник говорить Ви справді хочете видалити всю історію дзвінків? - Ви ввімкнули обов’язкове шифрування. Незашифровані розмови вимкнено для вашої безпеки. Ви можете повторно створити цю розмову або вимкнути обов’язкове шифрування в параметрах свого облікового запису. Ви дійсно хочете видалити всі повідомлення? Виявлено атаку типу «людина посередині» для %s Повідомлення було перенаправлено @@ -270,7 +259,6 @@ Журнали налагодження очищено Не вдалося завантажити логи налагодження Показати конфігурацію - Очистити імпортовані контакти з власної адресної книги Налаштування Безпека Зашифрувати все @@ -283,7 +271,6 @@ %s мс Калібрування не вдалося Адаптивне керування швидкістю - Увімкнути відео Увімкнути FEC відео Вібрація під час вхідного дзвінка Автоматично починати запис дзвінків @@ -325,7 +312,6 @@ Атрибути SIP SIP-домен Сталася помилка, LDAP-сервер не збережено! - URL-адреса сервера не може бути порожньою Наради Відображати за замовчуванням Активний промовець @@ -418,7 +404,6 @@ З’єднання не вдалося, оскільки автентифікація відсутня або недійсна для облікового запису\n%s.\n\nВи можете ввести пароль ще раз або перевірити конфігурацію облікового запису в налаштуваннях. Пароль Не вдалося знайти відповідний обліковий запис! - Історія дзвінків Новий дзвінок Пошук контакту або історії дзвінків Встановити назву групового дзвінка @@ -514,7 +499,6 @@ Гарантована конфіденційність Ця розмова не зашифрована! Заради вашої безпеки цю розмову було вимкнено. - Обов\'язкове шифрування Досягнуто максимальної кількості вкладень! Встановити назву розмови Редагувати назву розмови @@ -542,7 +526,6 @@ Зробити фото Відкрити галерею Вибрати файл - Поки що не вдається відкрити PDF-файли, захищені паролем Учасники групи (%s) Додати учасників Адмін @@ -608,10 +591,7 @@ Нараду не знайдено! Опис Редагувати нараду - Скасувати нараду? Скасувати нараду - Видалити нараду? - Ви хочете видалити нараду? Видалити нараду Нараду створено Нараду оновлено @@ -714,7 +694,6 @@ Ви приєднаєтеся незабаром Співрозмовник завершив дзвінок Для вашої безпеки нам потрібно автентифікувати пристрій вашого співрозмовника.\nБудь ласка, обміняйтеся кодами: - Ви хочете скасувати нараду та надіслати сповіщення усім учасникам? Будь ласка, введіть назву та виберіть хоча б одного учасника Об\'єднати всі дзвінки в конференцію? Конфіденційність цього дзвінка може бути скомпрометована! @@ -839,7 +818,6 @@ Втрачені пакети: %s Постквантовий ZRTP Історію видалено - Синій Виклик успішно перенаправлено Сервіс недоступний або помилка мережі Додати до контактів @@ -865,4 +843,42 @@ Надсилайте свої діагностичні журнали для полегшення вирішення помилок. Натисніть, щоб скасувати обмін файлами або текстом, що очікує надсилання Увімкнуто + Пошук + Поки що не вдається відкрити PDF-файли, захищені паролем + Учасники + Очистити імпортовані контакти з власної адресної книги + Редагувати + <відредаговано> + Підписатися на інформацію про присутність + Не скасовувати + Увімкнути динамік + Вимкнути динамік + Відкрийте &appName; щоб оновити реєстрацію + Перевірка номера телефону недоступна + Зареєструватися за допомогою електронної пошти + Алгоритм(и) LIME (розділені комами) + Відключено + Сортувати контакти за + Використовуйте редактор контактів &appName; для рідних контактів + Приховати контакти без SIP-адреси та номера телефону + Ця адресна книга доступна лише для читання + Додати логи LDAP до журналів &appName; + Усі поля мають бути заповнені + Автоматична відповідь з увімкненим відео в обох напрямках + Натисніть ще 1 раз, щоб увімкнути налаштування розробника + Увімкнути індикатори гучності запису/відтворення під час дзвінка + Показати розширену статистику викликів + Зрозуміло + Сповіщення про помилки облікового запису + Натисніть на це сповіщення, щоб виправити це + Ваш співрозмовник вас не чує! + Вибір мелодії дзвінка недоступний! + Не показувати вміст повідомлення в сповіщеннях Android + Розширені налаштування дзвінків + Автоматична відповідь + Натисніть ще 2 рази, щоб увімкнути налаштування розробника + Реєстрація облікового запису %s не вдалася! + Допустимі значення: c25519, c448, c25519k512, c25519mlk512 та c448mlk1024 + Перевірка номера телефону недоступна, будь ласка, скористайтеся електронну пошту для створення облікового запису. + Показати минулі зустрічі diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index e4e341bdb5..aa5f9905ab 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -19,11 +19,9 @@ 您真的要删除所有邮件吗? 此聊天中的消息是端对端e2e加密的。只有您的对话方才能解密它们。 播放/暂停视频播放 - 尚无法打开受密码保护的PDF 未找到用户 撤消通知 已启用阅后即焚时消息 - 取消会议? 发送所有会议邀请失败! 身份验证算法:%s SAS算法:%s @@ -72,7 +70,6 @@ 接受 取消 继续 - OK 通话 删除 安装 @@ -111,7 +108,6 @@ 一款 自由、开源应用,自 2001 SIP地址已复制到剪贴板 新账户已配置 - 网络连接错误! 文件已经导出到本地相册 尝试将文件导出到本机相册时出错 媒体音量低,您可能听不到任何声音! @@ -179,11 +175,9 @@ 调试日志已清理 上传调试日志失败 显示配置 - 从本地通讯簿中清除导入的联系人 您使用以下方式创建了账户: 电话号码 电子邮箱 - 发送验证码 推送通知似乎在您的设备上不可用,但它们是在移动应用程序中恢复电话号码账户所必需的。 联系人 通话 @@ -205,7 +199,6 @@ %s 毫秒 失败 自适应速率控制 - 启用视频 启用视频前向纠错FEC 来电铃响时振动 自动开始录制通话 @@ -241,7 +234,6 @@ SIP属性 SIP域名 发生错误,LDAP服务器未保存! - 服务器URL不能为空 会议 缺省布局 镶嵌Mosaic @@ -337,7 +329,6 @@ 需要身份验证 密码 找不到匹配的账户! - 通话记录 新呼叫 搜索联系人或历史通话记录 创建群组呼叫 @@ -350,8 +341,8 @@ 暂时没有通话… 聊天 您真的要删除所有通话记录吗? - 所有通话都将从历史记录中删除 - 所有通话都将从历史记录中删除 + 所有通话都将从历史记录中删除。 + 所有通话都将从历史记录中删除。 暂时没有联系人… 目前没有SIP联系人… 修改过滤器 @@ -437,8 +428,6 @@ 保证机密性 此聊天未加密! 为了您的安全,此聊天已被禁用。 - 强制加密 - 您启用了强制加密。为了您的安全,非加密聊天被禁用。您可以重新创建此聊天或在账户参数中禁用强制加密。 已达到附件的最大数量! 设置聊天主题 编辑聊天主题 @@ -480,7 +469,7 @@ 删除管理员权限 查看联系人资料 添加到联系人 - 所有消息都将从历史记录中删除 + 所有消息都将从历史记录中删除。 历史记录已成功删除 %s加入了聊天 %s现在是管理员 @@ -540,10 +529,7 @@ 来电%s 描述 编辑会议 - 您想取消会议并向所有参与者发送通知吗? 取消会议 - 删除会议? - 您想删除会议吗? 删除会议 会议已创建 会议已更新 @@ -671,12 +657,6 @@ 复制 下载 分享 - 黄色 - 绿色 - 蓝色 - 红色 - 粉红色 - 紫色 橙色 选定的参与者将显示在此处 账户连接错误 @@ -750,7 +730,6 @@ 打开文件选择器 单击以编辑此聊天的主题 压制此聊天使其不说话开/关 - 正在删除聊天 停止语音信息录制 开始录制语音信息 在聊天中发送消息 @@ -810,7 +789,6 @@ 添加到收藏夹 我们已给您的电话号码%1$s发送了验证码。\n\n请在下面输入验证码: 选择如何恢复您的账户。 - 输入与您的账户关联的电话号码,我们会向您发送一个验证码。 未能将参与者添加到聊天中 录制音频:这样您的对话方就可以听到您的声音并录制语音信息。 对方已结束通话 @@ -842,4 +820,102 @@ 连接已断开 订阅在线状态信息 该账户处于离线状态,可能是由于您当前未连接到互联网。 + 搜索 + 从本地通讯簿中清除导入的联系人 + 尚无法打开受密码保护的PDF + 参与者 + 编辑 + 陶土 + 珊瑚 + <已编辑> + 除非你删除联系人权限,否则下次应用启动时它们会再次被导入 + 支持推送通知的域名列表(用逗号分隔) + 如果填写了这个字段,出站代理将会自动启用。留空则禁用它。 + 未加密的对话 + 此消息已被删除 + 未找到参与者 + 此消息已被删除 + 移除参与者? + 这个参与者将被移出群聊。 + 删除会议? + 这次会议只会从本设备的会议列表中移除。 + 请勿取消 + 账户错误通知 + 打开扬声器 + 关闭扬声器 + 账户%s注册失败! + 打开&appName;以刷新注册 + 端到端加密 + 矿物蓝 + 为所有人删除 + 停录 + 手机号验证不可用,请使用邮箱注册流程。 + 无法验证电话号码 + 点击通知以便修复 + 未找到孤立的认证信息 + 显示过去的会议 + 所有字段都必须填写 + LDAP 服务器已保存 + 将 LDAP 日志添加到 &appName; 的日志中 + 这个通讯录是只读的 + 早期媒体 + 再点击一次以启用开发者设置 + 显示高级通话统计 + 导入的联系人已被删除 + 清除的认证信息不再关联到任何账户 + + 已移除%s孤立身份验证信息 + + 连接状态 + 出站SIP代理 + 搜索结果已达上限,请优化你的搜索。 + 消息没有端到端加密,记得不要分享敏感信息哦! + 在这段对话中交换的消息可能会被除了你的通信对象以外的人拦截和阅读,保密性无法保证! + 已编辑 + 为我删除 + 你真的要删除这次聊天吗? + 所有消息也会从聊天记录中删除。 + 你真的想离开这个聊天吗? + 你将无法发送或接收新消息,但你的消息记录仍然可用。 + HDMI + ICE: %s + IP 家族: %s + 蜂蜜 + 勃艮第 + 薄荷 + 李子 + + 取消消息编辑 + 已理解 + 你真的想删除这个通话记录吗? + 只有这个条目会被删除。 + 拒绝 + 应答 + 通话录音已保存 + 麦克风 + 您的对话方没有在听! + 薰衣草 + LIME 算法(用逗号分隔) + 靠近耳朵时使用接近传感器关闭屏幕 + 打不开这个 PDF,文件可能已损坏 + 允许的值有:c25519、c448、c25519k512、c25519mlk512 和 c448mlk1024 + 不要在安卓通知中显示消息内容 + 联系人排序 + 使用 &appName; 联系编辑以获取本地联系人 + 隐藏没有 SIP 地址或电话号码的联系人 + 对DTMF使用RFC 2833 + 使用 SIP INFO 发送 DTMF + 高级通话设置 + 自动应答,双向视频已启用 + 自动应答 + 再点击两次以启用开发者设置 + 在通话中启用录音/播放音量VU表 + 切换账户 + 消息正在编辑 + 会议将被取消 + 你想给所有参与者发送通知吗? + 关闭菜单 + 应用后台保活通知 + 用邮箱注册 + 铃声选择器不可用! diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index ea34103d9e..e4c801ef67 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -12,10 +12,7 @@ - - - @@ -48,6 +45,7 @@ + @@ -79,5 +77,6 @@ + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 10aaf2d1b1..c2ae7003bb 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -5,15 +5,13 @@ #FFFFFF #99000000 #10000000 + #3CFFFFFF #191919 #303030 #FFEACB - #80FFEACB - #FFB266 #FF5E00 - #B72D00 #EEF6F8 #DFECF2 @@ -53,44 +51,37 @@ #4AA8FF #64B4FF + #AF9308 #DBB820 #E1C133 #80FFFFFF - #FFF5D6 - #80FFF5D6 - #FFE799 - #F5BC00 - #A37D00 - - #DCF9E7 - #80DCF9E7 - #A8F0C2 - #25D366 - #1C9C4B - - #D6F4FF - #80D6F4FF - #99E4FF - #00aff0 - #0078A3 - - #FBE1DA - #80FBE1DA - #F5B53A - #E14318 - #A63211 - - #FFD6F1 - #80FFD6F1 - #FF99DD - #FF00A9 - #B8007A - - #FFD6FF - #80FFD6FF - #FF99FF - #800080 - #520052 + #F2D2C7 + #C86B45 + + #E9E7F2 + #7F79B5 + + #FBFCE9 + #D9A441 + + #F6BBC3 + #7A1E39 + + #DEF2E8 + #7AC9A1 + + #FFD3D6 + #F26B5E + + #DBBED9 + #9B4F96 + + #D9D9D9 + #8A939B + + #C5E0F3 + #669ED7 + \ No newline at end of file diff --git a/app/src/main/res/values/dimen.xml b/app/src/main/res/values/dimen.xml index 7e68dc955a..dc0eecb016 100644 --- a/app/src/main/res/values/dimen.xml +++ b/app/src/main/res/values/dimen.xml @@ -5,12 +5,13 @@ 10dp - 75dp + 80dp 350dp 300dp 14dp 24dp + 32dp 48dp 100dp 30dp @@ -42,12 +43,17 @@ 90dp 11dp + 20dp + 6dp + 12dp 20dp 20dp + 20dp 54dp 55dp 70dp + 400dp 400dp 350dp 400dp @@ -55,8 +61,8 @@ 400dp 400dp 250dp - 15dp - 20dp + 10dp + 11sp 70dp 110dp @@ -68,6 +74,7 @@ 10dp 15dp 65dp + 120dp 30dp 200dp @@ -83,8 +90,10 @@ 8dp 2dp 8dp + 14sp + 45sp 25dp - 175dp + 350dp 87dp 178dp 271dp @@ -99,11 +108,12 @@ 10dp 300dp - 300dp + 200dp 425dp - 340dp 15dp 30dp 15dp + + 4 \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a59f3a19a5..805256ec5e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -19,6 +19,7 @@ linphone_6.0_notification_service_id linphone_6.0_notification_chat_id linphone_6.0.1_notification_incoming_call_id + linphone_6.1_account_error_id ❤️ 👍 @@ -36,7 +37,7 @@ https://linphone.org/en/privacy-policy https://linphone.org/en/terms-of-use https://subscribe.linphone.org/register/email - https://subscribe.linphone.org/ + https://subscribe.linphone.org https://weblate.linphone.org/ https://wiki.linphone.org/xwiki/wiki/public/view/Linphone/Third%20party%20components%20/#Hlinphone-android https://linphone.org/en/features/#security @@ -61,6 +62,7 @@ %s day %s days + Search %s selected @@ -72,7 +74,6 @@ Accept Cancel Continue - OK Call Delete Install @@ -81,14 +82,17 @@ Yes Remove Confirm + Understood + Do not cancel - &appName; active calls notifications - &appName; incoming calls notifications - &appName; missed calls notifications - &appName; service notification + Active calls notifications + Incoming calls notifications + Missed calls notifications + Service notification This service will run all the time to keep app alive and allow you to receive calls and messages without push notifications. - &appName; instant messages notifications + Instant messages notifications + Account error notifications Reacted by %1$s to: %2$s Mark as read Reply @@ -110,6 +114,14 @@ %s, %s Click to open + This notification keeps app alive while in background + Turn on speaker + Turn off speaker + Account %s registration failed! + Open &appName; to refresh the registration + Your correspondent does not hear you! + Click on this notification to fix it + <redacted> Welcome @@ -123,7 +135,6 @@ SIP address copied into clipboard New account configured - Connection error! File has been exported to native gallery Error trying to export file to native gallery File has been exported to documents @@ -140,6 +151,9 @@ By continuing, you accept our %1$s and %2$s. Confirm phone number Are you sure your phone number is %s? + Phone number validation not available + Phone number validation is not available, please use email account creation process. + Register with an email Login Scan QR code Invalid QR code! @@ -158,6 +172,8 @@ Create an account with your email on: Already have an account? Transport + LIME algorithm(s) (comma separated) + Allowed values are: c25519, c448, c25519k512, c25519mlk512 and c448mlk1024 Some features require a &appName; account, such as group messaging, video conferences…\n\nThese features are hidden when you register with a third party SIP account.\n\nTo enable it in a commercial project, please contact us. I prefer to create a &appName; account I understand @@ -174,13 +190,11 @@ Push notifications: Allows you to receive a message or call. Record audio: So you can be heard when making and receiving calls. Access camera: So you can scan the QR Code to connect your app. - Password forgotten + Account recovery Choose how to recover your account. You created your account using: An email A phone number - Enter the phone number linked to your account, we\'ll send you a code. - Send the code Push notifications do not seem to be available on your device, but they are mandatory for recovering a phone number account in the mobile app. @@ -229,7 +243,6 @@ Debug logs have been cleaned Failed to upload debug logs Show configuration - Clear imported contacts from native address book Settings @@ -250,17 +263,24 @@ %s ms failed Adaptive rate control - Enable video Enable video FEC Vibrate while incoming call is ringing Automatically start recording calls Change ringtone Pick ringtone + Ringtone picker isn\'t available! + Use RFC 2833 for DTMFs + Use SIP INFO for DTMFs + Use proximity sensor to turn OFF the screen when near the ear Conversations Auto-download files Make downloaded media public Mark conversation as read when dismissing message notification + Do not show message content in Android notification Contacts + Sort contacts by + Use &appName; contact editor for native contacts + Hide contacts without SIP address nor phone number Subscribe to presence info Add LDAP server Edit LDAP server @@ -276,11 +296,13 @@ Synchronization error! CardDAV account removed Please fill at least the display name and the server URL + This address-book is read-only Enabled Server URL (can\'t be empty) Bind DN Password Use TLS + Add LDAP logs to &appName; ones Search base (can\'t be empty) Filter Max results @@ -290,9 +312,11 @@ Name attributes SIP attributes SIP domain + LDAP server saved A error occurred, LDAP server not saved! - Server URL can\'t be empty + All fields must be filled Meetings + Show past meetings Default layout Active speaker Mosaic @@ -325,17 +349,21 @@ Alpha-numerical characters only File sharing server URL Logs sharing server URL + Advanced calls settings Record video calls using H265/AV1 Will use a proprietary file format Media encryption Media encryption mandatory Create end-to-end encrypted meetings & group calls + Early-media Accept early media Ring during incoming early media call Allow outgoing early media + Auto-answer Auto answer incoming calls Delay before auto answering call Delay in milliseconds + Auto answer with video enabled in both directions Remote provisioning URL Download & apply Audio devices @@ -349,8 +377,22 @@ Developer settings Show developer settings + Click 2 more times to enable developer settings + Click 1 more time to enable developer settings Developer settings enabled Developer settings already enabled + Enable record/playback volume vu meters while in call + Show advanced call statistics + List of push notifications compatible domains (comma separated) + Clear imported contacts from native address book + They will be imported again the next time the app starts unless you remove the contacts permission + Imported contacts have been deleted + Clear authentication info no longer associated to any account + No orphan authentication info found + + %s orphan authentication info removed + %s orphans authentication info removed + Manage account @@ -360,6 +402,7 @@ Add a picture Edit picture Remove picture + Connection status This account in online, everybody can call you. This account in offline, probably because you aren\'t connected to internet right now. Account has been disabled, you won\'t receive any call or message. @@ -379,13 +422,15 @@ Last connection: Sign out of your account? If you wish to delete your account permanently, go to: https://sip.linphone.org + Outbound SIP Proxy + If this field is filled, the outbound proxy will be enabled automatically. Leave it empty to disable it. Account settings Allow push notifications Push notifications aren\'t available! IM encryption mandatory - SIP proxy server URL - Outbound proxy + Registrar URI + Outbound SIP Proxy URI NAT policy settings STUN/TURN server URL Enable ICE @@ -397,7 +442,7 @@ Conference factory URI Audio/video conference factory URI CCMP server URL - E2E encryption keys server URL + End-to-end encryption keys server URL Bundle mode Use CPIM in \"basic\" conversations Voicemail URI @@ -412,20 +457,22 @@ Failed to find matching account! - Call history New call Search contact or history call Create a group call No suggestion and no contact for the moment… Set group call subject Group call subject - No call for the moment… + No call from/to this account was found… + Change account Conversation Do you really want to delete all calls history? - All calls will be removed from the history + All calls will be removed from the history. Do you really want to delete the history with that person? - All calls will be removed from the history + All calls will be removed from the history. + Do you really want to delete this call log? + Only this entry will be removed. No contact for the moment… @@ -436,6 +483,7 @@ See all See &appName; contacts See SIP contacts + Search results limit reached, refine your search. New contact Edit contact @@ -488,7 +536,7 @@ Unnamed device - No conversation for the moment… + No conversation related to this account for the moment… Removal in progress… %s: @@ -534,13 +582,14 @@ No media found… No document found… End-to-end encrypted conversation - Messages in this conversation are e2e encrypted. Only your correspondent can decrypt them. + Messages in this conversation are end-to-end encrypted. Only your correspondent can decrypt them. Guaranteed confidentiality Thanks to end-to-end encryption technology in &appName;, messages, calls and meetings confidentiality are guaranteed. No-one can decrypt exchanged data, not even ourselves. + Messages aren\'t end-to-end encrypted, make sure you don\'t share sensitive information! + Unencrypted conversation + Messages exchanged in this conversation can be intercepted and read by other people than your correspondent, confidentiality is not guaranteed! This conversation is not encrypted! For your safety, this conversation was disabled. - Mandatory encryption - You enabled mandatory encryption. Non encrypted conversations are disabled for your safety. You can re-create this conversation or disable mandatory encryption in your account parameters. Maximum number of attachments reached! Set conversation subject Edit conversation subject @@ -574,7 +623,16 @@ Open gallery Pick file File can\'t be opened! - Can\'t open password protected PDFs yet + Can\'t open password protected PDFs yet + Can\'t open this PDF, file may be corrupted + Message being edited + Edited + Delete for me + Delete for everyone + This message has been deleted + You have deleted this message + Participants + No participants found Group members (%s) Add participants @@ -586,7 +644,11 @@ See contact profile Add to contacts Do you really want to delete all messages? - All messages will be removed from the history + All messages will be removed from the history. + Do you really want to delete this conversation? + All messages will also be removed from the history. + Do you really want to leave this conversation? + You will no longer be able to send or receive new messages, but your message history will still be available. History has been successfully deleted %s joined the conversation %s left the conversation @@ -596,6 +658,8 @@ No address to add to contact Start a group call? All participants will receive a call. + Remove participant? + This participant will be removed from the group conversation. You have joined the group You have left the group @@ -661,11 +725,11 @@ Meeting cannot be found! Description Edit meeting - Cancel the meeting? - Do you want to cancel the meeting and send a notification to all participants? + Meeting will be cancelled + Do you want to send a notification to all participants? + Delete meeting? + This meeting will only be removed from this device meetings list. Cancel meeting - Delete the meeting? - Do you want to delete the meeting? Delete meeting Meeting has been created Meeting has been updated @@ -706,7 +770,10 @@ Pause Resume Record + Stop recording Hang up + Decline + Answer Layout In progress Ringing @@ -719,12 +786,14 @@ Waiting for encryption… End-to-end encrypted by ZRTP Point-to-point encrypted by ZRTP + End-to-end encrypted Validate ZRTP SAS again Validation required Point-to-point encrypted by SRTP Call is not encrypted Calls list Call is being recorded + Call recording has been saved %s is recording %s calls %s paused calls @@ -743,18 +812,22 @@ Try again This call confidentiality may be compromise! + Microphone Earpiece Speaker Bluetooth (%s) Hearing aid (%s) Headset Headphones + HDMI Audio Codec: %s Bandwidth: %s Loss rate: %s Jitter buffer: %s + ICE: %s + IP Family: %s Video Resolution: %s FPS: %s @@ -829,6 +902,7 @@ Invite Re-send Delivery status + Edit Reply Forward Copy @@ -837,12 +911,15 @@ Orange - Yellow - Green - Blue - Red - Pink - Purple + Terracotta + Lavender + Honey + Burgundy + Mint + Coral + Plum + Titanium + Mineral Blue No result found… @@ -869,7 +946,7 @@ Skip - Forgotten password? + Forgotten or unknown password? Skip @@ -937,11 +1014,11 @@ Starts recording a voice message Sends message in conversation Message will no longer be a reply to a previous message + Cancels message edition Opens emoji picker Opens file picker Click to edit the subject of this conversation Silences on/off this conversation - Conversation is being removed This conversation isn\'t secured Search up Search down @@ -968,6 +1045,7 @@ Voice message are available Long press to dial voicemail Click to cancel files or text pending sharing + Closes menu diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index b77910e78e..9bcabea19c 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -38,7 +38,7 @@ @@ -67,6 +67,12 @@ ?attr/color_main2_500 13sp + + + + + - - - + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index b19b6bd3eb..05cdab3e59 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -15,10 +15,7 @@ ?attr/color_main1_500 @color/orange_main_100 - @color/orange_main_100_alpha_50 - @color/orange_main_300 @color/orange_main_500 - @color/orange_main_700 @color/bc_white @color/gray_main2_100 @@ -51,6 +48,7 @@ @color/blue_info_500 @color/orange_warning_600 + @color/orange_plain_text_security_warning_600 @color/bc_white @color/bc_white @@ -82,60 +80,62 @@ @color/gray_main2_300 @color/gray_100 + + @color/gray_200 + + + + + + + - - - - - - @@ -143,27 +143,39 @@ @color/gray_600 - + + + + + + - - - - - diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml index 3cbb8fac2a..273961c42f 100644 --- a/app/src/main/res/xml/locales_config.xml +++ b/app/src/main/res/xml/locales_config.xml @@ -6,6 +6,7 @@ + diff --git a/docker-files/bc-dev-android-36 b/docker-files/bc-dev-android-36 new file mode 100644 index 0000000000..2a55c4ce89 --- /dev/null +++ b/docker-files/bc-dev-android-36 @@ -0,0 +1,55 @@ +FROM debian:trixie + +MAINTAINER Sylvain BERFINI + +# Configure locale +RUN apt-get update && \ + apt-get install -y locales && \ + apt-get clean && \ + echo "en_US.UTF-8 UTF-8" > /etc/locale.gen && \ + locale-gen +ENV LANG='en_US.UTF-8' LANGUAGE='en_US:en' LC_ALL='en_US.UTF-8' + +ENV SHELL=/bin/bash + +# Define environment +ENV ANDROID_HOME /opt/android-sdk-linux + +# Install common general tools +RUN apt-get update && \ + apt-get install -y curl nano sudo unzip vim wget rsync ssh git openjdk-21-jdk-headless && \ + apt-get clean + +# Get latest Android command line tools, otherwise won't work with JDK 21 +RUN mkdir -p $ANDROID_HOME/cmdline-tools/latest +RUN wget https://dl.google.com/android/repository/commandlinetools-linux-9477386_latest.zip && \ + unzip commandlinetools-linux-9477386_latest.zip && \ + cp -R ./cmdline-tools/* $ANDROID_HOME/cmdline-tools/latest/ && \ + rm -rf ./cmdline-tools && \ + rm -rf commandlinetools-linux-9477386_latest.zip + +# Update path to include all Android SDK tools +ENV PATH $ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/tools:$ANDROID_HOME/tools/bin:$ANDROID_HOME/platform-tools:$PATH + +# Get the Android SDK +RUN echo "y" | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "platform-tools" "tools" "platforms;android-36" + +# Accept Android SDK licenses +RUN yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses + +# Give write rights +RUN chmod -R ugo+rwx $ANDROID_HOME + +# Consider all git repositories as safe +RUN git --version +RUN git config --global --add safe.directory '*' + +# Configure user bc +RUN useradd -ms /bin/bash bc && \ + echo 'bc:cotcot' | chpasswd && \ + echo 'bc ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers + +USER bc +WORKDIR /home/bc +ENV PS1='\[\e[34m\]\u@bc-dev-android>\[\e[0m\] ' +CMD bash diff --git a/gradle.properties b/gradle.properties index 306eac9f55..faaba61bb2 100644 --- a/gradle.properties +++ b/gradle.properties @@ -23,4 +23,8 @@ kotlin.code.style=official android.nonTransitiveRClass=true android.nonFinalResIds=true kapt.use.k2=true + +android.builtInKotlin=false +android.newDsl=false + LinphoneSdkBuildDir= \ No newline at end of file diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000000..42bccabdc0 --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,13 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/56a19bc915b9ba2eb62ba7554c61b919/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/398ffe3949748bfb1d5636f023d228fd/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/56a19bc915b9ba2eb62ba7554c61b919/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/398ffe3949748bfb1d5636f023d228fd/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/e99bae143b75f9a10ead10248f02055e/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/04e088f8677de3b384108493cc9481d0/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/56a19bc915b9ba2eb62ba7554c61b919/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/398ffe3949748bfb1d5636f023d228fd/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/2ddfb13e430f2b3a94c9c937d8d2f67e/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/932015f6361ccaead0c6d9b8717ed96e/redirect +toolchainVendor=JETBRAINS +toolchainVersion=21 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3b9da4149c..a7836484bb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,39 +1,40 @@ [versions] -agp = "8.9.0" -kotlin = "2.0.21" -gmsGoogleServices = "4.4.2" -firebaseCrashlytics = "3.0.3" -firebaseBomVersion = "33.10.0" -ktlint = "12.1.2" +agp = "9.2.1" +kotlin = "2.3.21" +gmsGoogleServices = "4.4.4" +firebaseCrashlytics = "3.0.7" +firebaseBomVersion = "34.13.0" +#noinspection NewerVersionAvailable +ktlint = "12.3.0" -annotations = "1.9.1" -activity = "1.10.1" -appcompat = "1.7.0" +annotations = "1.10.0" +appcompat = "1.7.1" constraintLayout = "2.2.1" -coreKtx = "1.15.0" -splashscreen = "1.2.0-beta01" -telecom = "1.0.0" -media = "1.7.0" +coreKtx = "1.18.0" +splashscreen = "1.2.0" +telecom = "1.1.0-alpha05" +media = "1.8.0" recyclerview = "1.4.0" +swipeRefreshLayout = "1.2.0" slidingpanelayout = "1.2.0" -window = "1.3.0" -gridlayout = "1.0.0" -securityCryptoKtx = "1.1.0-alpha06" -navigation = "2.8.8" -emoji2 = "1.5.0" -car = "1.7.0-rc01" +window = "1.5.1" +gridlayout = "1.1.0" +securityCryptoKtx = "1.1.0" +navigation = "2.9.8" +emoji2 = "1.6.0" +car = "1.7.0" flexbox = "3.0.0" -material = "1.12.0" +material = "1.14.0" +#noinspection NewerVersionAvailable protobuf = "3.25.5" -coil = "3.1.0" +coil = "3.4.0" dotsIndicator = "5.1.0" photoview = "2.3.0" openidAppauth = "0.11.1" -linphone = "5.4.+" +linphone = "5.5.+" [libraries] androidx-annotations = { group = "androidx.annotation", name = "annotation", version.ref = "annotations" } -androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } androidx-constraint-layout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintLayout" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -41,6 +42,7 @@ androidx-splashscreen = { group = "androidx.core", name = "core-splashscreen", v androidx-telecom = { group = "androidx.core", name = "core-telecom", version.ref = "telecom" } androidx-media = { group = "androidx.media", name = "media", version.ref = "media" } androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerview" } +androidx-swiperefreshlayout = { group = "androidx.swiperefreshlayout", name = "swiperefreshlayout", version.ref = "swipeRefreshLayout" } androidx-slidingpanelayout = { group = "androidx.slidingpanelayout", name = "slidingpanelayout", version.ref = "slidingpanelayout" } androidx-window = { group = "androidx.window", name = "window", version.ref = "window" } androidx-gridlayout = { group = "androidx.gridlayout", name = "gridlayout", version.ref = "gridlayout" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 1b33c55baa..e708b1c023 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ff23a68d70..5dd3c0121b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index 23d15a9367..4f906e0c81 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ -#!/bin/sh +#!/usr/bin/env sh # -# Copyright © 2015-2021 the original authors. +# Copyright 2015 the original author or authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,115 +15,81 @@ # See the License for the specific language governing permissions and # limitations under the License. # -# SPDX-License-Identifier: Apache-2.0 -# ############################################################################## -# -# Gradle start up script for POSIX generated by Gradle. -# -# Important for running: -# -# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is -# noncompliant, but you have some other compliant shell such as ksh or -# bash, then to run this script, type that shell name before the whole -# command line, like: -# -# ksh Gradle -# -# Busybox and similar reduced shells will NOT work, because this script -# requires all of these POSIX shell features: -# * functions; -# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», -# «${var#prefix}», «${var%suffix}», and «$( cmd )»; -# * compound commands having a testable exit status, especially «case»; -# * various built-in commands including «command», «set», and «ulimit». -# -# Important for patching: -# -# (2) This script targets any POSIX shell, so it avoids extensions provided -# by Bash, Ksh, etc; in particular arrays are avoided. -# -# The "traditional" practice of packing multiple parameters into a -# space-separated string is a well documented source of bugs and security -# problems, so this is (mostly) avoided, by progressively accumulating -# options in "$@", and eventually passing that to Java. -# -# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, -# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; -# see the in-line comments for details. -# -# There are tweaks for specific operating systems such as AIX, CygWin, -# Darwin, MinGW, and NonStop. -# -# (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt -# within the Gradle project. -# -# You can find Gradle at https://github.com/gradle/gradle/. -# +## +## Gradle start up script for UN*X +## ############################################################################## # Attempt to set APP_HOME - # Resolve links: $0 may be a link -app_path=$0 - -# Need this for daisy-chained symlinks. -while - APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path - [ -h "$app_path" ] -do - ls=$( ls -ld "$app_path" ) - link=${ls#*' -> '} - case $link in #( - /*) app_path=$link ;; #( - *) app_path=$APP_HOME$link ;; - esac +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null -# This is normally unused -# shellcheck disable=SC2034 -APP_BASE_NAME=${0##*/} -# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD=maximum +MAX_FD="maximum" warn () { echo "$*" -} >&2 +} die () { echo echo "$*" echo exit 1 -} >&2 +} # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "$( uname )" in #( - CYGWIN* ) cygwin=true ;; #( - Darwin* ) darwin=true ;; #( - MSYS* | MINGW* ) msys=true ;; #( - NONSTOP* ) nonstop=true ;; +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; esac -CLASSPATH="\\\"\\\"" +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD=$JAVA_HOME/jre/sh/java + JAVACMD="$JAVA_HOME/jre/sh/java" else - JAVACMD=$JAVA_HOME/bin/java + JAVACMD="$JAVA_HOME/bin/java" fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -132,120 +98,88 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD=java - if ! command -v java >/dev/null 2>&1 - then - die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." - fi fi # Increase the maximum file descriptors if we can. -if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then - case $MAX_FD in #( - max*) - # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - MAX_FD=$( ulimit -H -n ) || - warn "Could not query maximum file descriptor limit" - esac - case $MAX_FD in #( - '' | soft) :;; #( - *) - # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - ulimit -n "$MAX_FD" || - warn "Could not set maximum file descriptor limit to $MAX_FD" - esac +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi fi -# Collect all arguments for the java command, stacking in reverse order: -# * args from the command line -# * the main class name -# * -classpath -# * -D...appname settings -# * --module-path (only if needed) -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi # For Cygwin or MSYS, switch paths to Windows format before running java -if "$cygwin" || "$msys" ; then - APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) - - JAVACMD=$( cygpath --unix "$JAVACMD" ) - +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi # Now convert the arguments - kludge to limit ourselves to /bin/sh - for arg do - if - case $arg in #( - -*) false ;; # don't mess with options #( - /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath - [ -e "$t" ] ;; #( - *) false ;; - esac - then - arg=$( cygpath --path --ignore --mixed "$arg" ) + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" fi - # Roll the args list around exactly as many times as the number of - # args, so each arg winds up back in the position where it started, but - # possibly modified. - # - # NB: a `for` loop captures its iteration list before it begins, so - # changing the positional parameters here affects neither the number of - # iterations, nor the values presented in `arg`. - shift # remove old arg - set -- "$@" "$arg" # push replacement arg + i=`expr $i + 1` done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac fi +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' - -# Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, -# and any embedded shellness will be escaped. -# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be -# treated as '${Hostname}' itself on the command line. - -set -- \ - "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ - "$@" - -# Stop when "xargs" is not available. -if ! command -v xargs >/dev/null 2>&1 -then - die "xargs is not available" -fi - -# Use "xargs" to parse quoted args. -# -# With -n1 it outputs one arg per line, with the quotes and backslashes removed. -# -# In Bash we could simply go: -# -# readarray ARGS < <( xargs -n1 <<<"$var" ) && -# set -- "${ARGS[@]}" "$@" -# -# but POSIX shell has neither arrays nor command substitution, so instead we -# post-process each arg (as a line of input to sed) to backslash-escape any -# character that might be a shell metacharacter, then use eval to reverse -# that process (while maintaining the separation between arguments), and wrap -# the whole thing up as a single "set" statement. -# -# This will of course break if any of these variables contains a newline or -# an unmatched quote. -# - -eval "set -- $( - printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | - xargs -n1 | - sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | - tr '\n' ' ' - )" '"$@"' +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index 5eed7ee845..ac1b06f938 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -13,10 +13,8 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem -@rem SPDX-License-Identifier: Apache-2.0 -@rem -@if "%DEBUG%"=="" @echo off +@if "%DEBUG%" == "" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -27,8 +25,7 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused +if "%DIRNAME%" == "" set DIRNAME=. set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -43,13 +40,13 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute +if "%ERRORLEVEL%" == "0" goto execute -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. goto fail @@ -59,34 +56,32 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. goto fail :execute @rem Setup the command line -set CLASSPATH= +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* :end @rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd +if "%ERRORLEVEL%"=="0" goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 :mainEnd if "%OS%"=="Windows_NT" endlocal diff --git a/settings.gradle.kts b/settings.gradle.kts index c0d0a1f84f..e2c7042407 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -5,6 +5,9 @@ pluginManagement { gradlePluginPortal() } } +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)