Compare commits

..

No commits in common. "master" and "4.6.0-beta" have entirely different histories.

1640 changed files with 54457 additions and 112428 deletions

View file

@ -7,59 +7,35 @@ assignees: ''
---
First of all, please say "Hi" or "Hello", it doesn't cost much.
1. **Describe the bug** (mandatory)
**Describe the bug**
A clear and concise description of what the bug is.
Also, if applicable, **do you reproduce it with linphone-android latest release from the Play Store?**
**If the issue is about the SDK (build, issue, etc...) open the ticket in the [linphone-sdk](https://github.com/BelledonneCommunications/linphone-sdk) repository or one of it's submodules!**
2. **To Reproduce** (mandatory)
Please detail steps to reproduce the behavior.
3. **Expected behavior** (mandatory)
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
4. **Please complete the following information** (mandatory)
**Please complete the following information**
- Device: [e.g. Samsung Note 20 Ultra]
- OS: [e.g. Android 12]
- Version of the App [e.g. 4.6.11]
- Version of the SDK [e.g 5.1.48]
- Where you did got it from (Play Store, F-Droid, local build)
- Please tell us if your Android is a Lineage OS or another variant.
- OS: [e.g. Android 11]
- Version of the App [e.g. 4.3.1]
- Version of the SDK [e.g 4.4.16]
If you are using a SDK that isn't the latest release, please update first as it's likely your issue is already solved.
**SDK logs**
Enable debug logs in advanced section of the settings, restart the app, reproduce the issue and then go to About page, click on "Send logs" and copy/paste the link here.
5. **SDK logs** (mandatory)
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).
In case of a call issue, please attach logs from both devices!
6. **Adb logcat logs** (mandatory if native crash)
In case of a crash of the app, please also provide the symbolized stack trace of the crash using adb logcat.
Here's the command for a arm64 device: `adb logcat | grep ndk-stack -sym <sdk build directory>/libs-debug/arm64-v8a/`
For more information, please refer to [this section of the README](https://github.com/BelledonneCommunications/linphone-android#native-crash) file.
7. **Screenshots** (optionnal)
**Adb logcat logs**
In case of a crash of the app, please also provide the stack trace of the crash using adb logcat.
**Screenshots**
If applicable, add screenshots to help explain your problem.
8. **Additional context** (optionnal)
**Additional context**
Add any other context about the problem here.
Thank you in advance for filling bug reports properly!

View file

@ -1,14 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: SDK issues
url: https://github.com/BelledonneCommunications/linphone-sdk/issues
about: Please post issues about the SDK here.
- name: Desktop issues
url: https://github.com/BelledonneCommunications/linphone-desktop/issues
about: Please post issues about the Desktop (Linux, MacOSX, Windows) application here.
- name: iOS issues
url: https://github.com/BelledonneCommunications/linphone-iphone/issues
about: Please post issues about the iPhone application here.
- name: Contacts
url: https://www.linphone.org/contact
about: For any contacts like commercial, licensing, mailing-lists

38
.gitignore vendored
View file

@ -1,14 +1,28 @@
*.iml
.gradle
/local.properties
*.orig
*.rej
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
.gradle
.idea
.settings
adb.pid
bc-android.keystore
build
*.iml
lint.xml
local.properties
app/debug/
app/release/
.idea/
app/bc-android.keystore
.kotlin/
res/.DS_Store
res/raw/lpconfig.xsd
.d
.*clang*
**/*.iml
**/.classpath
**/.project
**/*.kdev4
**/.vscode
res/value-hi_IN
linphone-sdk-android/*.aar
app/debug
app/release
app/releaseAppBundle
keystore.properties
app/src/main/res/xml/contacts.xml

View file

@ -2,15 +2,14 @@ job-android:
stage: build
tags: [ "docker-android" ]
image: gitlab.linphone.org:4567/bc/public/linphone-android/bc-dev-android-36:20260120_trixie_java21_android36_gradle9
image: gitlab.linphone.org:4567/bc/public/linphone-android/bc-dev-android:20210903_java11_android31
before_script:
- if ! [ -z ${SCP_PRIVATE_KEY+x} ]; then eval $(ssh-agent -s); fi
- if ! [ -z ${SCP_PRIVATE_KEY+x} ]; then echo "$SCP_PRIVATE_KEY" | tr -d '\r' | ssh-add - > /dev/null; fi
- if ! [ -z ${ANDROID_SETTINGS_GRADLE+x} ]; then echo "$ANDROID_SETTINGS_GRADLE" > settings.gradle.kts; fi
- git config --global --add safe.directory /builds/BC/public/linphone-android
script:
- sdkmanager
- scp -oStrictHostKeyChecking=no $DEPLOY_SERVER:$ANDROID_KEYSTORE_PATH app/
- scp -oStrictHostKeyChecking=no $DEPLOY_SERVER:$ANDROID_GOOGLE_SERVICES_PATH app/
- echo storePassword=$ANDROID_KEYSTORE_PASSWORD > keystore.properties
@ -18,15 +17,15 @@ job-android:
- echo keyAlias=$ANDROID_KEYSTORE_KEY_ALIAS >> keystore.properties
- echo storeFile=$ANDROID_KEYSTORE_FILE >> keystore.properties
- ./gradlew app:dependencies | grep org.linphone
- ./gradlew clean
- ./gradlew assembleDebug
- ./gradlew assembleRelease
artifacts:
paths:
- ./app/build/outputs/apk/debug/linphone-android-debug-*.apk
- ./app/build/outputs/apk/release/linphone-android-release-*.apk
when: always
expire_in: 1 day
expire_in: 1 week
.scheduled-job-android:

View file

@ -1,20 +1,12 @@
job-android-upload:
stage: deploy
tags: [ "docker-deploy" ]
tags: [ "deploy" ]
only:
- schedules
dependencies:
- job-android
before_script:
- if ! [ -z ${SCP_PRIVATE_KEY+x} ] && ! [ -z ${DEPLOY_SERVER_HOST_KEYS+x} ]; then eval $(ssh-agent -s); fi
- if ! [ -z ${SCP_PRIVATE_KEY+x} ]; then echo "$SCP_PRIVATE_KEY" | tr -d '\r' | ssh-add - > /dev/null; fi
- if ! [ -z ${DEPLOY_SERVER_HOST_KEYS+x} ]; then mkdir -p ~/.ssh && chmod 700 ~/.ssh; fi
- if ! [ -z ${DEPLOY_SERVER_HOST_KEYS+x} ]; then echo "$DEPLOY_SERVER_HOST_KEYS" >> ~/.ssh/known_hosts; fi
script:
# Launches rsync in partial mode, which means that we are using a temp_dir in case of a transfer issue
# Upon a job relaunch, the files in temp_dir would then be re-used, and deleted if the transfer succeeds
- cd app/build/outputs/apk/ && rsync --partial --partial-dir=$CI_PIPELINE_ID_$CI_JOB_NAME ./release/*.apk $DEPLOY_SERVER:$ANDROID_DEPLOY_DIRECTORY
- cd app/build/outputs/apk/ && rsync ./debug/*.apk $DEPLOY_SERVER:$ANDROID_DEPLOY_DIRECTORY

3
.idea/.gitignore generated vendored
View file

@ -1,3 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml

1
.idea/.name generated
View file

@ -1 +0,0 @@
Linphone

View file

@ -1,123 +0,0 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<JetCodeStyleSettings>
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="XML">
<option name="FORCE_REARRANGE_MODE" value="1" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
<arrangement>
<rules>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:android</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:id</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>style</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>ANDROID_ATTRIBUTE_ORDER</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>.*</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
</rules>
</arrangement>
</codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</codeStyleSettings>
</code_scheme>
</component>

View file

@ -1,5 +0,0 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

6
.idea/compiler.xml generated
View file

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
</component>
</project>

10
.idea/misc.xml generated
View file

@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>

6
.idea/vcs.xml generated
View file

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

View file

@ -10,924 +10,33 @@ 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.1.0] - Unreleased
### 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 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
- 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
- Added a vu meter for recording & playback volumes (must be enabled in developer settings)
- Added support for HDMI audio devices
### Changed
- No longer follow TelecomManager audio endpoint during calls, using our own routing policy
- 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)
- Simplified audio device name in settings
- Reworked some settings (moved calls related ones from advanced settings to advanced calls settings)
- 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
### Fixed
- Copy raw message content instead of modified one when it contains a participant mention ("@username")
## [6.0.22] - 2026-01-20
### Changed
- Close search bar when opening bottom sheet and vice versa
### Fixed
- Sending a file from another app using Android shortcut not working if conversation was already opened
- Trying to workaround an issue where ForegroundService notification isn't displayed in the allowed timeframe, causing an Exception
## [6.0.21] - 2025-12-16
### Added
- Allow linphone-config: scheme URIs in in-app QR code scanner
### Changed
- Workaround for audio focus & audio manager mode on devices that do not support TelecomManager APIs
- Set front camera as default after using back camera when scanning a QR code
- Added back largeHeap flag in AndroidManifest.xml
### Fixed
- Fixed call recording indicator not showing local record in progress in case UPDATE isn't answered
- Fixed native addressbook reload when a contact is updated in the OS default app
- Fixed issue with linphone-config scheme URIs if scheme is followed by "//"
- Fixed Job & Company contact field not updated if field content was removed
- Fixed local avatar not displayed when calling ourselves
- Prevent crashes due to some ActivityNotFound exceptions
- Prevent crash due to empty clipboard on some devices
## [6.0.20] - 2025-11-21
### Changed
- Added shrink resources to release config in gradle
### 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
- Spanish and Slovakian translations thanks to Weblate contributors
### Changed
- SIP addresses domain hidden in Suggestions if it matches the currently selected account SIP identity domain
- Start proximity sensor when an incoming call is answered from the notification (disabling screen when device is near)
### Fixed
- Black screen when trying to scan a QR Code right after granting CAMERA permission (only happened on some devices)
- Possible crash due to ConcurrentModificationException
- Camera preview in conference that was black sometimes after switching layout
- Possibly wrong screen sharing participant name in conference
- Presence SUBSCRIBE that was only sent for sip.linphone.org accounts
- Keyboard suggestions in participant picker textfield
- Account labelled as Disabled instead of Disconnected when network isn't reachable
- Suggestions generated avatar if username starts with '+'
- Two LDAP fields label where swapped
## [6.0.18] - 2025-09-15
### Added
- Added menu icon next to currently selected account avatar to make the drawer menu easier to understand
- Added missing dialpad floating action button in the call transfer fragment
### Changed
- Improved bodyless friendlist presence process when it's received
### Fixed
- Fixed "End-to-end encrypted call" label while in conference, the call may be end-to-end encrypted but only to the conference server, not to all participants
- Fixed missing meeting subject when calling the conference SIP URI if the conference info doesn't exist yet
- Finish CallActivity if no call is found when trying to answer/decline a call from the IncomingCallFragment
- Prevent empty screen when rotating the device and clicking on the empty part next to the list while in landscape and then rotating the device back to portrait
## [6.0.17] - 2025-09-02
### Changed
- Portuguese translation updated from Weblate (still not complete)
### Fixed
- Vibrator not stopped when call is terminated sometimes (SDK fix)
- Chat conversation not visible sometimes (SDK fix)
## [6.0.16] - 2025-08-25
## Added
- Access to Help/Troubleshooting pages from Assistant
## Fixed
- Some Core methods being called from UI thread causing either a crash or a deadlock sometimes
- Scrolling issue when doing a search in a conversation with only one result
- Contacts not updated after body less presence notify was received
- VFS issue due to encrypted.pref file being backed up by Android OS
## [6.0.15] - 2025-08-11
### Fixed
- Crash due to changes in SDK triggering fatal error if linphone_core_stop() is called from linphone_core_iterate() loop (which was done when scanning QR code)
### Changed
- Prevent leaving assistant after doing a remote provisioning if there is still no account after it (if there was no account before and no account was provided in downloaded config)
## [6.0.14] - 2025-08-06
### Fixed
- Fixed ANR due to deadlock caused by method being called from wrong thread
- Fixed microphone not always recording audio while app in background or if screen is turned off
- Fixed missing favorites in start call / create conversation views
- Fixed outgoing call view in full screen
- Fixed generated avatar for SIP URIs without username
## [6.0.13] - 2025-07-31
### Fixed
- Missing favourites if contacts list size exceeds magic search max results setting
- Muted call on some devices due to Telecom Manager quickly muting/unmuting call
- Full screen without video during outgoing early media call if video has been declined by remote end
- Removed duplicated week label if "no meeting today" is the first entry for current week
- Prevent crash during file export if no app on the device can handle it
- Prevent crash that could happen with chat message notification if sender name (or group chat room subject) is empty
### Changed
- Back gesture / navigation button will close the numpad bottom sheet if it's open instead of leaving the page directly
- Updated bell and bell_slash icons
## [6.0.12] - 2025-07-18
### Fixed
- Reactions list in bottom sheet update while opened
- Crashes due to late init properties being used before initialized
## [6.0.11] - 2025-07-11
### Added
- Added toggle in LDAP configuration to allow to quickly enable/disable it
### Changed
- Reduced maximum number of contacts displayed in contacts list, new call/conversation, meeting participant selection etc...
- Updated translations
### Fixed
- Calls top bar wrong notification label when going from two calls to one.
## [6.0.10] - 2025-06-27
### Added
- Added a new top bar alert area for pending file/text sharing.
### Changed
- Reworked in-app top bar alerts, now can show both an account alert and an active call alert.
- Hide SIP address/phone number picker dialog if contact has exactly one SIP address matching the default domain and currently default account domain.
### Fixed
- Bluetooth not being used automatically when device is connected during a call.
- Call encryption status label stuck in "Waiting for encryption".
- Group chat room creation if LIME server URL isn't set.
- Participant mention if more than one in the same chat message.
- Force default account in call params when starting one.
## [6.0.9] - 2025-06-06
### Added
- German translation (88% complete)
- Link to user guide in Help section
- Missing scroll views for help & debug layouts
### Changed
- Prevent port from being set in the SIP identity address in third party account login + remove port (if any) from SIP identity for existing accounts
- Show last message timestamp instead of conversation last updated timestamp in conversations list
### Fixed
- Prevent blinking in conversations list when removing message from chat room
- Prevent empty (can even lead to crash) display name in call notification (using all identification fields from vCard)
## [6.0.8] - 2025-05-23
### Added
- Ukrainian & simplified Chinese translations from Weblate
- Sliding answer/decline button in incoming call fragment if device is locked (will help prevent calls from being unintentionally picked up or hung up while the device is being removed from a pocket)
### Changed
- Show files with square design when more than one (as it is for media files)
- Outgoing chat bubbles will now display the sent file size (as it is for received messages)
### Fixed
- Fixed issue with bluetooth hearing aids
- Fixed audio call being answered on speakerphone
- Fixed events related to joined/left conversation being briefly visible sometimes for 1-1 conversations
- Fixed files/media grid in chat bubble using more than 3 columns in landscape
- Fixed logs upload server URL setting
## [6.0.7] - 2025-05-16
### Added
- CS, NL and RU translations from Weblate
### Changed
- Improved find contact performances
- Make sure speaker audio device is used for playing the ringtone during early media
- Reworked bottom navigation bar in portrait and unread count indicators
- No longer delete conversations when deleting account (for now); causes user to leave group which is an issue when using multiple devices
### Fixed
- Fixed no default account after remote provisioning
- Prevent lists from refreshing too many times when using LDAP or remote CardDAV contact directories
- Fixed black miniatures in conference if bundle mode is disabled in account params
- Fixed long press on a chat message containing a SIP URI triggering call
- Disable IMDN bottom sheet for incoming messages in groups instead of showing it empty
- Refresh conversations list after clearing conversation history
- Fixed another race condition issue related to foreground call service
## [6.0.6] - 2025-05-02
### Added
- Added recover phone account when clicking on "Forgotten password" in the assistant
- Improved message when contacts list is empty depending on the currently selected filter and added a button to open the filter popup menu for users that didn't notice the icon on the top right corner of the screen when contacts list is empty and "SIP contacts only" filter is set.
- Added "Logs collection sharing server URL" setting in developper area
- Added "Disable sending logs to Crashlytics" advanced setting.
### Changed
- Improved VFS message in confirmation dialog
- Moved "Print logs in logcat" and "File sharing server URL" settings to developper area
### Fixed
- Fixed crash when opening a password protected PDF
- Fixed chat room lookup while in 1-1 call, using SDK method for getting chat room from conference
- Fixed newly created contact not being visible in contacts list without reloading it
- Fixed missing event icon for group conversations
- Another attempts at preventing crashes due to In-Call service not being started as foreground before being stopped
## [6.0.5] - 2025-04-18
### Changed
- When calling a SIP URI that looks like a phone number in the username and an IP in the domain, replace the domain with the one of the currently selected account to workaround issue with PBXs using IPs instead of domains in From header
- Improved account creation page UI when push notifications aren't available
- Improved called account display on incoming call screen when more than one account configured
- Updated telecom package from beta to release candidate
### Fixed
- Fixed transfer call view numpad button starting a new call instead of forwarding the current one
- Fixed incoming call not displayed in call history depending on how the From & To headers are formatted (SDK fix)
- Fixed crashes related to foreground service not being started
- Fixed crash due to lateinit property not being initialized before used
## [6.0.4] - 2025-04-11
### Changed
- Third party SIP accounts push notifications will be disabled and setting will be hidden unless if list of supported domains (to prevent issues, specifically when used with UDP transport protocol causing bigger packets getting lost)
### Fixed
- Prevent refresh of views due to contacts changes to happen to frequently at startup
- Prevent crash in Help view if app is built without Firebase
## [6.0.3] - 2025-04-04
### Added
- Show alert when default account is disabled
- Refesh list details when going back from background after one hour or more (when keep app alive using service is enabled)
- Click to copy SIP URI in call history shortcut
- Added developper settings, must click 8 times on version (in Help) to make it appear (E2E encryption for meetings & group calls setting was moved there)
- Circular indicator while search is in progress in contacts lists
### Changed
- Force some default values on notifications channels
- Contacts list filter is now applied to new call / conversation & other contact pickers
- Attach file icon stays visible while typing message in conversation instead of emoji picker icon
### Fixed
- No default account being selected if the default one is removed
- Navigation bar turning orange when opening search bar
- Incoming call showed as video even if video is disabled locally
- Concurrent modification crash in Contacts loader
- Meetings list not properly sorted when CCMP is used
- POST_NOTIFICATIONS permission check on old Android devices
## [6.0.2] - 2025-03-28
### Added
- Show on top bar if FULL_SCREEN_INTENT permission isn't granted, clicking on it sends to the matching settings so user can fix it easily, without it incoming call screen won't be displayed if screen is off
- Ring during incoming early media call setting added back
- Added a floating action button to open dialpad during outgoing early media call
### Changed
- Delete all related call history / conversations / meetings when removing an account
- Delay / use a separated thread for heavy contacts related tasks to ensure call is correctly handled and foreground service is started quickly enough
- Newly created account in app will be kept disabled until SMS code validation is done
- Keep app alive foreground service notification no shows a content message to ease clicking on it to open the app & workaround a crash on some devices
- Automatically show dialpad setting will now also work on new / transfer call while in call as well
### Fixed
- Improved POST_NOTIFICATIONS permission check on Android 13 and newer, should prevent crashes
- Fixed contact lookup if phone number starts by "00" instead of "+"
- Fixed "delete all call history" sometimes not removing all call logs
- Fixed LDAP / remote CardDAV contacts sometimes not displayed in contacts list when doing a search
- Fixed issue where contact filter could be set to only show sip.linphone.org contacts even when third party account was being selected
- Fixed sometimes wrong displayed SIP URI in detailed call history
- Fixed invisible meeting icon in status bar
- Fixed missed call count indicator behavior with some third party providers
- Prevent today indicator & meeting icon in bottom nav bar from blinking / briefly appearing
- Fixed bottom nav bar sometimes being hidden
- Fixed missing share logs server URL when migrating from 5.2 if that value was removed back then
- Other crashes fixed
## [6.0.1] - 2025-03-21
### Added
- Start at boot & auto answer settings added back
- Interface setting to have dialpad automatically opened in start call view
- Replace "+" by "00" and do not apply prefix for calls & chat account settings
- Setting to let user choose whether to record calls using MKV or SMFF format (the later allows to record H265/AV1 video but is a proprietary file format that can't be read outside of Linphone)
### Changed
- Reverted the way of playing incoming call ringone (you may have to configure your own ringtone again), was causing various issues depending on devices/firmwares
- Show all call history entries if only one account is configured (workaround for missing history for now until a proper fix will be done in SDK)
### Fixed
- Issue preventing bluetooth Hearing Aids from working properly (and fixed earpiece/hearing aids icon)
- Prevent Qr Code scanner to use static picture camera
- Prevent user from connecting the same account multiple times
- Quit menu visibility not updated when changing Keep Alive setting
- Participant selection in group when typing "@"
- Recordings order has been reversed to have newest ones at top
- Improved message when network is not reachable due to "Wifi only mode" being enabled
- Various crash & bug fixes
## [6.0.0] - 2025-03-11
6.0.0 release is a complete rework of Linphone Android, with a fully redesigned UI, so it is impossible to list everything here.
### Changed
- Separated threads: Contrary to previous versions, our SDK is now running in it's own thread, meaning it won't freeze the UI anymore in case of heavy work, thus reducing the number of ANR and greatly increasing the fluidity of the app.
- Asymmetrical video : you no longer need to send your own camera feed to receive the one from the remote end of the call, and vice versa.
- Improved multi account: you'll only see history, conversations, meetings etc... related to currently selected account, and you can switch the default account in two clicks.
- Call transfer: Blind & Attended call transfer have been merged into one: during a call, if you initiate a transfer action, either pick another call to do the attended transfer or select a contact from the list (you can input a SIP URI not already in the suggestions list) to start a blind transfer.
- User can only send up to 12 files in a single chat message.
- IMDNs are now only sent to the message sender, preventing huge traffic in large groups, and thus the delivery status icon for received messages is now hidden in groups (as it was in 1-1 conversations).
- Settings: a lot of them are gone, the one that are still there have been reworked to increase user friendliness.
- Default screen (between contacts, call history, conversations & meetings list) will change depending on where you were when the app was paused or killed, and you will return to that last visited screen on the next startup.
- Gradle files have been migrated from Groovy to Kotlin DSL, and dependencies are now in a separated file (libs.versions.toml).
- Account creation no longer allows you to use your phone number as username, but it is still required to provide it to receive activation code by SMS.
- Minimum supported Android OS version is now 9 (API level 28).
- Telecom Manager support is now based on androidx.core.core-telecom package.
- Some settings have changed name and/or section in linphonerc file.
### Added
- Contacts trust: contacts for which all devices have been validated through a ZRTP call with SAS exchange are now highlighted with a blue circle (and with a red one in case of mistrust). That trust is now handled at contact level (instead of conversation level in previous versions).
- Media & documents exchanged in a conversation can be easily found through a dedicated screen.
- A brand new chat message search feature has been added to conversations.
- You can now react to a chat message using any emoji.
- If next message is also a voice recording, playback will automatically start after the currently playing one ends.
- Chat while in call: a shortcut to a conversation screen with the remote.
- Chat while in a conference: if the conference has a text stream enabled, you can chat with the other participants of the conference while it lasts. At the end, you'll find the messages history in the call history (and not in the list of conversations).
- Auto export of media to native gallery even when auto download is enabled (but still not if VFS is enabled nor for ephemeral messages).
- Save / export document & media from ephemeral messages will be disabled, and secure policy that prevents screenshots will be enforced in file viewer even if the setting is disabled.
- Notification showing upload/download of files shared through chat will let user know the progress and keep the app alive during that process.
- Screen sharing in conference: only desktop app starting with 6.0 version is able to start it, but on mobiles you'll be able to see it.
- You can choose whatever ringtone you'd like for incoming calls (in Android notification channel settings).
- Security focus: security & trust is more visible than ever, and unsecure conversations & calls are even more visible than before.
- CardDAV: you can configure as many CardDAV servers you want to synchronize you contacts in Linphone (in addition or in replacement of native addressbook import).
- OpenID: when used with a SSO compliant SIP server (such as Flexisip), we support single-sign-on login.
- MWI support: display and allow to call your voicemail when you have new messages (if supported by your VoIP provider and properly configured in your account params).
- CCMP support: if you configure a CCMP server URL in your accounts params, it will be used when scheduling meetings & to fetch list of meetings you've organized/been invited to.
- Devices list: check on which device your sip.linphone.org account is connected and the last connection date & time (like on subscribe.linphone.org).
- Protobuf dependency to allow logging native crashes stack traces at next app startup.
- Android 15 startup listener, allowing us to log type of start (cold, warm, etc...) and some other useful info.
- Dialer & in-call numpad show letters under the digit.
### Removed
- Dialer: the previous home screen (dialer) has been removed, you'll find it as an input option in the new start call screen.
- Peer-to-peer: a SIP account (sip.linphone.org or other) is now required.
- Contacts: we no longer add contacts created in-app in the native addressbook (WRITE_CONTACTS permission was removed), but we still import them if you grant us the READ_CONTACTS permission.
### Fixed
- No longer trying to play vocal messages & call recordings using bluetooth when connected to an Android Auto car, causing playback issues.
- AAudio driver no longer causes delay when switching between devices (SDK fix).
## [5.2.5] - 2024-05-03
### Changed
- Updated translations
## [5.2.4] - 2024-04-22
### Fixed
- Active speaker video hidden when you are the first one to join a meeting
- Show camera icon instead of microphone for incoming video calls
- SIP URI parsing from native contact due to international prefix being applied when it shouldn't
- Various fixes for broadcast mode
## [5.2.3] - 2024-01-31
### Fixed
- Crash due to OOM for some images sent/received in chat
- Crash while navigating to account settings
### Changed
- Updated translations (Romanian, Polish, Portuguese)
## [5.2.2] - 2024-01-15
### Fixed
- Local conference created my merging audio streams
## [5.2.1] - 2023-12-23
### Fixed
- Crash when Service starts before CoreContext
## [5.2.0] - 2023-12-21
### Added
- Chat messages emoji "reactions"
- Hearing aids should be working the same way bluetooth headset does
- Hardware video codecs (H264, H265) are now used in priority when possible (SDK)
- Broadcast mode for scheduled meetings (hidden)
- Android 14 support
### Changed
- BLUETOOTH_CONNECT permission is no longer required
### Fixed
- Correctly switching to either bottom or back microphone depending on wether the earpiece or the speaker is used,
and also use the same device for input and output if the one set as output as RECORD capability
(fixes echo issue while on speakerphone on some devices such as Samsung's)
- Connection status & color when in refreshing state
- Sent content type for files attached to a chat message
- Toggle mute mic while in conference
- Calling right after creating a chat room
## [5.1.4] - 2023-10-20
### Fixed
- Various fixes in the SDK (5.2.110)
### Changed
- Updated translations from Weblate
## [5.1.3] - 2023-09-23
### Fixed
- Core not able to open database due to issue in 5.2.107 SDK from last update
- Incoming call activity and lock screen interaction
- Selected "meeting" filter icon color
## [5.1.2] - 2023-09-22
### Added
- Italian translation completed
### Fixed
- Multiple authentication requested dialogs stacking above each other sometimes
- Downgraded navigation version to try to prevent some crashes reported on the Play Store
## [5.1.1] - 2023-09-06
### Fixed
- Fixed issue in SDK randomly generated password when creating account from app
- Various issues reported on the Play Store
## [5.1.0] - 2023-08-21
### Added
- Showing short term presence for contacts whom publish it + added setting to disable it (enabled by default for sip.linphone.org accounts)
- Confirmation dialog before removing account
- Attended transfer instead of blind transfer if there is more than 1 call
- Last sent message delivery status (IMDN) icon in chat rooms list
- Emoji picker in chat room, and increase size of text if it only contains emojis
- Hidden setting to disable video completely
- Hidden setting to prevent adding / editing / removing native contacts
- Hidden setting to protect settings access using account password
- SIP URI in call can be selected using long press
- Dialog showing up asking for correct account password in case of failed authentication
### Changed
- Switched Account Creator backend from XMLRPC to FlexiAPI, it now requires to be able to receive a push notification
- Email account creation form is now only available if TELEPHONY feature is not available, not related to screen size anymore
- Replaced voice recordings file name by localized placeholder text, like for video conferences invitations
- Decline incoming calls with Busy reason if there is at least another active call
- Open keyboard when replying to a message if no text / file / voice record is pending
- Removed jetifier as it is not needed
- Switched from gradle 7.5 to 8.0, requires JDK 17 (instead of 11)
### Fixed
- Messages not marked as reply in basic chat room if sending more than 1 content
- Chat message video attachment display when failing to get a preview picture
## [5.0.14] - 2023-06-20
### Changed
- SDK update only
## [5.0.13] - 2023-06-15
### Changed
- SDK update only
## [5.0.12] - 2023-05-23
### Fixed
- Crash if notification manager throws an exception
- Video preview not moving if call was started in audio only
## [5.0.11] - 2023-05-09
### Fixed
- Wrong call displayed when hanging up a call while an incoming one is ringing
- Crash related to call history
- Crash due to wrongly format string
- Add/remove missing listener on FriendLists created after Core has been created
### Changed
- Improved GSM call interruption
- Updated translations
## [5.0.11] - 2023-05-09
### Fixed
- Wrong call displayed when hanging up a call while an incoming one is ringing
- Crash related to call history
- Crash due to wrongly format string
- Add/remove missing listener on FriendLists created after Core has been created
### Changed
- Improved GSM call interruption
- Updated translations
## [5.0.10] - 2023-04-04
### Fixed
- Plain copy of encrypted files (when VFS is enabled) not cleaned
- Avatar display issue if contact's "initials" contains more than 1 emoji or an emoji + a character
## [5.0.9] - 2023-03-30
### Fixed
- Admin weren't visible for non admin users in group chat rooms
- Crash when clicking on URI in chat if not matching app is found on Android to handle it
- LIME update threshold wasn't set, causing a request to be made after each REGISTER
### Changed
- Now SDK automatically handles TextureView's listener, removed it from app
- Bumped license year to 2023
- Force remove LIME X3DH server URL for third party accounts
## [5.0.8] - 2023-03-20
### Fixed
- Trying to prevent crash in call history
- Color icon in dark mode in chat for files & replies
### Changed
- Updated translations
## [5.0.7] - 2023-02-27
### Fixed
- Fixed navigating to a contact that doesn't have a native ID, but using it's SIP address instead
- Fixed account creator resolved country name & create button not enabled
### Changed
- Updated translations
## [5.0.6] - 2023-02-17
### Fixed
- Wrong country displayed in assistant after picking it in the list if another country has the same international prefix (such as +1)
- SIP URI clickable pattern missing '~'
- Crash that happens sometimes when CallActivity is destroyed
- Pressing send message button while recording a voice message not sending it
- Missing ephemeral icon next to send message icon
- Headers colors in IMDN details
- Pixel issue in call quality indicator 2 icon
### Changed
- Improved incoming call layout when receiving early-media video
- Hidden "Echo Tester" setting unless in debug mode as it can mislead user and isn't useful for end user
## [5.0.5] - 2023-01-19
### Fixed
- Issue with how replies where added to chat message notification from reply action
## [5.0.4] - 2023-01-18
### Added
- Show a progress bar while importing files to the chat sending area
### Changed
- Prevent keyboard from auto-replacing some user input such as username, breaking SIP URIs unknowingly
### Fixed
- Prevent copy of files that weren't sent in chat to be kept in app local folder
## [5.0.3] - 2023-01-13
### Added
- Voice message recording/playback will use bluetooth/headset/headphones/hearing aid device if available
- Chat message notifications are now compatible with Android Auto
### Changed
- In video conference, when in active speaker layout, currently speaking participant miniature will be hidden
- Attach file, voice recording and send message icons are now a bit bigger
- Updated Firebase BoM, gradle & some dependencies
### Fixed
- ANR happening sometimes during voice message playback
## [5.0.2] - 2023-01-05
### Changed
- Export files to native gallery is now available even if automatically download files setting is enabled
### Fixed
- Makes sure sip.linphone.org accounts have a LIME X3DH server URL for E2E chat messages encryption
- Files not being exported to native gallery sometimes
- Crashes reported by Google Play Store & Crashlytics
## [5.0.1] - 2022-12-16
### Changed
- File transfer progress indication & error status improvements
### Fixed
- Wrong LIME status for participant that has multiple devices
- No longer sends video when switching from audio only to another conference layout
- SIP URI regex pattern to prevent HTTP URLs containing '@' to be handled as SIP URI
## [5.0.0] - 2022-12-06
### Added
- Post Quantum encryption when using ZRTP
- Conference creation with scheduling, video, different layouts, showing who is speaking and who is muted, etc...
- Group calls directly from group chat rooms
- Chat rooms can be individually muted (no notification when receiving a chat message)
- When a message is received wait a short amount of time to check if more are to be received to notify them all at once
- Outgoing call video in early-media if requested by callee
- Image & Video in-app viewers allow for full-screen display
- Display name can be set during assistant when creating / logging in a sip.linphone.org account
- Android 13 support, using new post notifications & media permissions
- Call recordings can be exported
- Setting to prevent international prefix from account to be applied to call & chat
- Themed app icon is now supported for Android 13+
### Changed
- In-call views have been re-designed
- "Media Encryption Mandatory" setting now allows for any media encryption (instead of only the one selected in the above setting previously)
- Improved how call logs are handled to improve performances
- Improved how contact avatars are generated
- 3-dots menu even for basic chat rooms with more options
- Phone numbers & email addresses are now clickable links in chat messages
- Go to call activity when you click on launcher icon if there is at least one active call
### Fixed
- Multiple file download attempt from the same chat bubble at the same time needed app restart to properly download each file
- Call stopped when removing app from recent tasks
- Generated avatars in dark mode
- Call state in self-managed TelecomManager service if it takes longer to be created than the call to be answered
- Show service notification sooner to prevent crash if Core creation takes too long
- Incoming call screen not being showed up to user (& screen staying off) when using app in Samsung secure folder
- One to one chat room creation process waiting indefinitely if chat room already exists
- Contact edition (SIP addresses & phone numbers) not working due to original value being lost in Friend parsing
- Automatically start call recording
- "Blinking" in some views when presence is being received
- Trying to keep the preferred driver (OpenSLES / AAudio) when switching device
- Issues when storing presence in native contacts + potentially duplicated SIP addresses in contact details
- Chat room scroll position lost when going into sub-view
- Trim user input to remove any space at end of string due to keyboard auto completion
- No longer makes requests to our LIME server (end-to-end encryption keys server) for non sip.linphone.org accounts
- Fixed incoming call/notification not ringing if Do not Disturb mode is enabled except for favorite contacts
## [4.6.14] - 2022-09-19
### Fixed
- ANR that happens sometimes when playing voice recording
### Changed
- Improved contact loader by querying only relevant fields
## [4.6.13] - 2022-08-25
### Fixed
- Disable Telecom Manager feature on Android < 10 to prevent crash due to Android 9 OS bug
- Fixed crash due to AAudio's waitForStateChange (SDK fix)
## [4.6.12] - 2022-07-29
### Fixed
- Call notification not being removed if service channel is disabled & background mode is enabled
- Wrong display name in chat notification sometimes
- Removed secure chat button if no LIME server configured or no conference factory URI set
- Disable TelecomManager feature when the device doesn't support it
### Changed
- ContactsLoader have been updated, shouldn't crash anymore
## [4.6.11] - 2022-06-27
### Fixed
- Various crashes due to unhandled exceptions
- Echo canceller calibration not using speaker (SDK fix)
## [4.6.10] - 2022-06-07
### Fixed
- Fixed contact address used instead of identity address when creating a basic chat room from history or contact details
- Fixed call notification still visible after call ended on some devices
- Fixed incoming call activity not displayed on some devices
- Fixed Malaysian dial plan (SDK fix)
- Fixed incoming call ringing even if Do not disturb mode is enabled (SDK fix)
## [4.6.9] - 2022-05-30
### Fixed
- ANR when screen turns OFF/ON while app is in foreground
- Crash due to missing CoreContext instance in TelecomManager service
- One-to-One encrypted chat room creation if it already exists
- Crash if ConnectionService feature isn't supported by the device
### Changed
- Updated translations from Weblate
- Improved audio devices logs
## [4.6.8] - 2022-05-23
### Fixed
- Crash due to missing CoreContext in CoreService
- Crash in BootReceiver if auto start is disabled
- Other crashes
## [4.6.7] - 2022-05-04
### Changed
- Do not start Core in Application, prevents service notification from appearing by itself
- When switching from bluetooth or headset device to earpiece/speaker, also change microphone
- Prevent empty chat bubble by sending only space character(s)
### Fixed
- Phone numbers with non-ASCII labels missing from address book
- Wrong audio device displayed in call statistics
- Various issues from Crashlytics
## [4.6.6] - 2022-04-26
### Changed
- Prevent requests to LIME X3DH & long term presence servers when not using a sip.linphone.org account
- Updated DE & RU translations
- Improved UI on landscape tablets
### Fixed
- Catching exceptions in new ContactsLoader reported on PlayStore
- Missing phone numbers in contacts when label contains a space character (5.1.24 SDK fix)
- Prevent app from starting by itself due to DummySyncService
- Hide chat rooms settings not working properly
## [4.6.5] - 2022-04-11
### Changed
- Only display phone number if it matches SIP address username
- Using new MagicSearch API to improve contacts list performances
### Fixed
- Prevent concurrent exception while loading native address book contacts
## [4.6.4] - 2022-04-06
### Added
- Set video information in CallStyle incoming call notification
### Changed
- Massive rework of how native contacts from address book are handled to improve performances
- Only display phone number from LDAP search result if it matches SIP address' username
### Fixed
- Do not use CallStyle notification on Samsung devices, they are currently displayed badly
- Fixed microphone muted when starting a new call if microphone was muted at the end of the previous one
- Added LDAP contact display name to SIP address
- Prevent read-only 1-1 chat room
- Fixed chat room last updated time not updated sometimes
## [4.6.3] - 2022-03-08
### Added
- Improvements in contacts matching
### Changed
- "Operation in progress" spinner hidden when contacts display/filter takes less than 200ms
### Fixed
- Contacts order when multiple address book contacts share the same number / SIP address
- Wrongly formatted phone numbers not displayed anymore
- Incoming call activity not displayed on LineageOS sometimes
- Various crashes related to Telecom Manager exceptions not being caught
## [4.6.2] - 2022-03-01
### Added
- Request BLUETOOTH_CONNECT permission on Android 12+ devices, if not we won't be notified when a BT device is being connected/disconnected while app is alive.
- LDAP settings if SDK is built with OpenLDAP (requires 5.1.1 or higher linphone-sdk), will add contacts if any
- SIP addresses & phone numbers can be selected in history & contact details view
- Text can be selected in file viewer & config viewer
- Prevent screen to turn off while recording a voice message
### Changed
- Contacts lists now show LDAP contacts if any
### Fixed
- Negative gain in audio settings is allowed again
- STUN server URL setting not enabling it for non sip.linphone.org accounts
- Contacts list header case comparison
- Stop voice recording playback when sending chat message
- Call activity not finishing when hanging up sometimes
- Auto start setting disabled not working if background mode setting was enabled
## [4.6.1] - 2022-02-14
### Fixed
- Quit button not working when background mode was enabled
- Crash when background mode was enabled and service notification channel was disabled
- Crashes while changing audio route
- Crash while fetching contacts
- Crash when rotating the device (SDK fix)
## [4.6.0] - 2022-02-09
## [4.6.0] - Unreleased
### Added
- Reply to chat message feature (with original message preview)
- Swipe action on chat messages to reply / delete
- Voice recordings in chat feature
- Allow video recording in chat file sharing
- Unread messages indicator in chat conversation that separates read & unread messages
- Notify incoming/outgoing calls on bluetooth devices using self-managed connections from telecom manager API (disables SDK audio focus)
- Ask Android to not process what user types in an encrypted chat room to improve privacy, see [IME_FLAG_NO_PERSONALIZED_LEARNING](https://developer.android.com/reference/android/view/inputmethod/EditorInfo#IME_FLAG_NO_PERSONALIZED_LEARNING)
- SIP URIs in chat messages are clickable to easily initiate a call
- Notify incoming/outgoing calls on bluetooth devices using self-managed connections from telecom manager API
- New video call UI on foldable device like Galaxy Z Fold
- Setting to automatically record all calls
- When using a physical keyboard, use left control + enter keys to send message
- Using CallStyle notifications for calls for devices running Android 12 or newer
- New fragment explaining generic SIP account limitations contrary to sip.linphone.org SIP accounts
- Link to Weblate added in about page
### Changed
- UI has been reworked around SlidingPane component to better handle tablets & foldable devices
- No longer scroll to bottom of chat room when new messages are received, a new button shows up to do it and it displays conversation's unread messages count
- Animations have been replaced to use com.google.android.material.transition ones
- Using new [Unified Content API](https://developer.android.com/about/versions/12/features/unified-content-api) to share files from keyboard (or other sources)
- Received messages are now trimmed
- Bumped dependencies, gradle updated from 4.2.2 to 7.0.2
- Target Android SDK version set to 31 (Android 12)
- Splashscreen is using new APIs
- SDK updated to 5.1.0 release
- Updated translations
### Fixed
- Chat notifications disappearing when app restarts
- "Infinite backstack", now each view is stored (at most) once in the backstack
- Voice messages / call recordings will be played on headset/headphones instead of speaker, if possible
- Going back to the dialer when pressing back in a chat room after clicking on a chat message notification
- Missing international prefix / phone number in assistant after granting permission
- Display issue for incoming call notification preventing to use answer/hangup actions on some Xiaomi devices (like Redmi Note 9S)
- Missing foreground service notification for background mode
### Removed
- Launcher Activity has been replaced by [Splash Screen API](https://developer.android.com/reference/kotlin/androidx/core/splashscreen/SplashScreen)
- Dialer will no longer make DTMF sound when pressing digits
- Launcher activity
- Global push notification setting in Network, use the switch in each Account instead
- No longer need to monitor device rotation and give information to the Core, it does it by itself
## [4.5.6] - 2021-11-08
### Changed
- SDK updated to 5.0.49
## [4.5.5] - 2021-10-28
@ -970,11 +79,16 @@ and also use the same device for input and output if the one set as output as RE
- Fixed various crashes & other issues
- SDK bumped to 5.0.10
## [4.5.1] - 2021-07-15
## [4.5.1] - Unreleased
### Added
- Reply to chat message feature
- Voice recordings messages
### Changed
- Bugs & crashes have been fixed
- SDK bumped to 5.0.1
- Navigation was reworked using SlidingPane widget, reducing code & improving UI on foldables
### Removed
## [4.5.0] - 2021-07-08

View file

@ -1,18 +1,12 @@
[![pipeline status](https://gitlab.linphone.org/BC/public/linphone-android/badges/master/pipeline.svg)](https://gitlab.linphone.org/BC/public/linphone-android/commits/master)
[![weblate status](https://weblate.linphone.org/widget/linphone/linphone-android-6-0/status-badge.png)](https://weblate.linphone.org/engage/linphone/)
[![pipeline status](https://gitlab.linphone.org/BC/public/linphone-android/badges/master/pipeline.svg)](https://gitlab.linphone.org/BC/public/linphone-android/commits/master)
Linphone is an open source softphone for voice and video over IP calling and instant messaging.
It is fully SIP-based, for all calling, presence and IM features.
General description is available from [linphone web site](https://linphone.org).
### How to get it
[<img src="metadata/google-play-badge.png" height="60" alt="Get it on Google Play">](https://play.google.com/store/apps/details?id=org.linphone)[<img src="metadata/f-droid-badge.png" height="60" alt="Get it on F-Droid">](https://f-droid.org/en/packages/org.linphone/)
You can also download APKs signed with our key from [our website](https://download.linphone.org/releases/android/?C=M;O=D).
General description is available from [linphone web site](https://www.linphone.org/technical-corner/linphone).
### License
@ -22,11 +16,11 @@ Linphone is dual licensed, and is available either :
- under a [GNU/GPLv3 license](https://www.gnu.org/licenses/gpl-3.0.en.html), for free (open source). Please make sure that you understand and agree with the terms of this license before using it (see LICENSE file for details).
- under a proprietary license, for a fee, to be used in closed source applications. Contact [Belledonne Communications](https://linphone.org/contact) for any question about costs and services.
- under a proprietary license, for a fee, to be used in closed source applications. Contact [Belledonne Communications](https://www.linphone.org/contact) for any question about costs and services.
### Documentation
- Supported features and RFCs : https://www.linphone.org/linphone-softphone/#linphone-fonctionnalites
- Supported features and RFCs : https://www.linphone.org/technical-corner/linphone/features
- Linphone public wiki : https://wiki.linphone.org/xwiki/wiki/public/view/Linphone/
@ -34,11 +28,16 @@ Linphone is dual licensed, and is available either :
# What's new
6.0.0 release is a completely new version, designed with UX/UI experts and marks a turning point in design, features, and user experience. The improvements make this version smoother and simpler for both developers and users.
App has been totally rewritten in Kotlin using modern components such as Navigation, Data Binding, View Models, coroutines, etc...
Check the [CHANGELOG](./CHANGELOG.md) file for a more detailled list.
The first linphone-android release that will be based on this will be 4.5.0, using 5.0.0 SDK.
You can take a look at the [CHANGELOG.md](CHANGELOG.md) file for a non-exhaustive list of changes of this new version and of the newly added features, the most exciting ones being the improved fluidity, a real multi-accounts support and asymmetrical video in calls.
We're also taking a fresh start regarding translations so less languages will be available for a while.
If you want to contribute, you are welcome to do so, check the [Translations](#Translations) section below.
This release only works on Android OS 9.0 and newer.
org.linphone.legacy flavor (old java wrapper if you didn't migrate your app code to the new one yet) is no longer supported starting 5.0.0 SDK.
The sample project has been removed, we now recommend you to take a look at our [tutorials](https://gitlab.linphone.org/BC/public/tutorials/-/tree/master/android/kotlin).
# Building the app
@ -93,64 +92,29 @@ LinphoneSdkBuildDir=/home/<username>/linphone-sdk/build/
## Known issues
- If you have the following build issue `AAPT: error: resource drawable/linphone_logo_tinted (aka org.linphone:drawable/linphone_logo_tinted) not found`, delete the `app/src/main/res/xml/contacts.xml` file (you can do it simply with `git clean -f` command) and start the build again.
- If you encounter the `couldn't find "libc++_shared.so"` crash when the app starts, simply clean the project in Android Studio (under Build menu) and build again.
Also check you have built the SDK for the right CPU architecture using the `-DLINPHONESDK_ANDROID_ARCHS=armv7,arm64,x86,x86_64` cmake parameter.
- Push notification might not work when app has been started by Android Studio consecutively to an install. Remove the app from the recent activity view and start it again using the launcher icon to resolve this.
## Troubleshooting
## Troubleshouting
### Behavior issue
When submitting an issue on our [Github repository](https://github.com/BelledonneCommunications/linphone-android), please follow the template and attach the matching library logs:
When submitting an issue on our [Github repository](https://github.com/BelledonneCommunications/linphone-android), please follow the template and attach the matching library logs.
1. To enable them, go to Settings -> Advanced and toggle `Debug Mode`. If they are already enabled, clear them first using the `Reset logs` button on the About page.
Starting 6.0.0 release, logs are always enabled and stored locally on the device, you can clear them/upload them securely on our server for sharing by going into the Help → Troubleshooting page.
2. Then restart the app, reproduce the issue and upload the logs using the `Send logs` button on the About page.
### Native crash
First of all, to be able to get a symbolized stack trace, you need the debug version of our libraries.
If you haven't built the SDK locally (see [building a local SDK](#BuildingalocalSDK)), here's how to get them:
1. Go to our [maven repository](https://download.linphone.org/maven_repository/org/linphone/linphone-sdk-android/) and find the directory that matches the version of our SDK that crashed.
2. Download the linphone-sdk-android-<version>-libs-debug.zip archive.
3. Extract the symbolized libraries somewhere on your computer, it will create a ```libs-debug``` directory.
Now you need the ```ndk-stack``` tool and possibly ```adb logcat```.
If your computer isn't used for Android development, you can download those tools from [Google website](https://developer.android.com/studio#downloads), in the ```Command line tools only``` section.
Once you have the debug libraries and the proper tools installed, you can use the ```ndk-stack``` tool to symbolize your stacktrace. Note that you also need to know the architecture (armv7, arm64, x86, etc...) of the libraries that were used.
If you know the CPU architecture of your device (most probably arm64 if it's a recent device) you can use the following to get the stacktrace from a device plugged to a computer:
```
adb logcat -d | ndk-stack -sym ./libs-debug/arm64-v8a/
```
If you don't know the CPU architecture, use the following instead:
```
adb logcat -d | ndk-stack -sym ./libs-debug/`adb shell getprop ro.product.cpu.abi | tr -d '\r'`
```
Warning: This command won't print anything until you reproduce the crash!
Starting [NDK r29](https://github.com/android/ndk/wiki/Changelog-r29) you will be able to directly use the ```libs-debug.zip``` file in ```ndk-stack -sym``` argument.
3. Finally paste the link to the uploaded logs (link is already in the clipboard after a sucessful upload).
## Create an APK with a different package name
Simply edit the ```app/build.gradle.kts``` file and change the value of the ```packageName``` variable.
Before the 4.1 release, there were a lot of files to edit to change the package name.
Now, simply edit the app/build.gradle file and change the value returned by method ```getPackageName()```
The next build will automatically use this value everywhere thanks to ```manifestPlaceholders``` feature of gradle and Android.
We no longer build the debug flavor with a different package name, but if you still want that behavior you only have to change the value of ```useDifferentPackageNameForDebugBuild``` to ```true```. When enabled, app built and installed by Android studio will have ```org.linphone.debug``` package name instead of ```org.linphone```.
If you encounter
```
Execution failed for task ':app:processDebugGoogleServices'.
> No matching client found for package name 'your package name'
```
error when building, make sure you have replaced the ```app/google-services.json``` file by yours (containing your package name).
If you don't have such file because you don't rely on Firebase Cloud Messaging features nor Crashlytics, delete the file instead.
You may have already noticed that the app installed by Android Studio has ```org.linphone.debug``` package name.
If you build the app as release, the package name will be ```org.linphone```.
## Firebase push notifications
@ -168,15 +132,11 @@ 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.
<a href="https://weblate.linphone.org/engage/linphone/">
<img src="https://weblate.linphone.org/widget/linphone/linphone-android-6-0/multi-auto.svg" alt="Translation status" />
</a>
# CONTRIBUTIONS
In order to submit a patch for inclusion in linphone's source code:
1. First make sure your patch applies to latest git sources before submitting: patches made to old versions can't and won't be merged.
2. Fill out and send us an email with the link of pull-request and the [Contributor Agreement](https://linphone.org/sites/default/files/bc-contributor-agreement_0.pdf) for your patch to be included in the git tree.
2. Fill out and send us an email with the link of pullrequest and the [Contributor Agreement](https://linphone.org/sites/default/files/bc-contributor-agreement_0.pdf) for your patch to be included in the git tree.
The goal of this agreement to grant us peaceful exercise of our rights on the linphone source code, while not losing your rights on your contribution.

2
app/.gitignore vendored
View file

@ -1 +1 @@
/build
/build

289
app/build.gradle Normal file
View file

@ -0,0 +1,289 @@
plugins {
id 'com.android.application'
id 'kotlin-android'
id 'kotlin-kapt'
id 'org.jlleitschuh.gradle.ktlint'
}
static def getPackageName() {
return "org.linphone"
}
def firebaseEnabled = new File(projectDir.absolutePath +'/google-services.json').exists()
def crashlyticsEnabled = new File(projectDir.absolutePath +'/google-services.json').exists() && new File(LinphoneSdkBuildDir + '/libs/').exists() && new File(LinphoneSdkBuildDir + '/libs-debug/').exists()
if (firebaseEnabled) {
apply plugin: 'com.google.gms.google-services'
}
if (crashlyticsEnabled) {
apply plugin: 'com.google.firebase.crashlytics'
}
def gitBranch = new ByteArrayOutputStream()
task getGitVersion() {
def gitVersion = "4.6.0"
def gitVersionStream = new ByteArrayOutputStream()
def gitCommitsCount = new ByteArrayOutputStream()
def gitCommitHash = new ByteArrayOutputStream()
try {
exec {
executable "git" args "describe", "--abbrev=0"
standardOutput = gitVersionStream
}
exec {
executable "git" args "rev-list", gitVersionStream.toString().trim() + "..HEAD", "--count"
standardOutput = gitCommitsCount
}
exec {
executable "git" args "rev-parse", "--short", "HEAD"
standardOutput = gitCommitHash
}
exec {
executable "git" args "name-rev", "--name-only", "HEAD"
standardOutput = gitBranch
}
if (gitCommitsCount.toString().toInteger() == 0) {
gitVersion = gitVersionStream.toString().trim()
} else {
gitVersion = gitVersionStream.toString().trim() + "." + gitCommitsCount.toString().trim() + "+" + gitCommitHash.toString().trim()
}
println("Git version: " + gitVersion)
} catch (ignored) {
println("Git not found")
}
project.version = gitVersion
}
configurations {
customImplementation.extendsFrom implementation
}
task linphoneSdkSource() {
doLast {
configurations.customImplementation.getIncoming().each {
it.getResolutionResult().allComponents.each {
if (it.id.getDisplayName().contains("linphone-sdk-android")) {
println 'Linphone SDK used is ' + it.moduleVersion.version + ' from ' + it.properties["repositoryName"]
}
}
}
}
}
project.tasks['preBuild'].dependsOn 'getGitVersion'
project.tasks['preBuild'].dependsOn 'linphoneSdkSource'
android {
compileSdkVersion 31
buildToolsVersion '31.0.0'
defaultConfig {
minSdkVersion 23
targetSdkVersion 31
versionCode 4600
versionName "${project.version}"
applicationId getPackageName()
}
applicationVariants.all { variant ->
variant.outputs.all {
outputFileName = "linphone-android-${variant.buildType.name}-${project.version}.apk"
}
// See https://developer.android.com/studio/releases/gradle-plugin#3-6-0-behavior for why extractNativeLibs is set to true in debug flavor
if (variant.buildType.name == "release" || variant.buildType.name == "releaseAppBundle") {
variant.getMergedFlavor().manifestPlaceholders = [linphone_address_mime_type: "vnd.android.cursor.item/vnd." + getPackageName() + ".provider.sip_address",
linphone_file_provider: getPackageName() + ".fileprovider",
appLabel: "@string/app_name",
extractNativeLibs: "false"]
} else {
variant.getMergedFlavor().manifestPlaceholders = [linphone_address_mime_type: "vnd.android.cursor.item/vnd." + getPackageName() + ".provider.sip_address",
linphone_file_provider: getPackageName() + ".debug.fileprovider",
appLabel: "@string/app_name_debug",
extractNativeLibs: "true"]
}
}
def keystorePropertiesFile = rootProject.file("keystore.properties")
def keystoreProperties = new Properties()
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
signingConfigs {
release {
storeFile file(keystoreProperties['storeFile'])
storePassword keystoreProperties['storePassword']
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
}
}
buildTypes {
release {
minifyEnabled true
signingConfig signingConfigs.release
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
resValue "string", "linphone_app_branch", gitBranch.toString().trim()
resValue "string", "sync_account_type", getPackageName() + ".sync"
resValue "string", "file_provider", getPackageName() + ".fileprovider"
resValue "string", "linphone_address_mime_type", "vnd.android.cursor.item/vnd." + getPackageName() + ".provider.sip_address"
if (!firebaseEnabled) {
resValue "string", "gcm_defaultSenderId", "none"
}
resValue "bool", "crashlytics_enabled", "false"
}
releaseWithCrashlytics {
initWith release
resValue "bool", "crashlytics_enabled", crashlyticsEnabled.toString()
if (crashlyticsEnabled) {
firebaseCrashlytics {
nativeSymbolUploadEnabled true
unstrippedNativeLibsDir file(LinphoneSdkBuildDir + '/libs-debug/').toString()
}
}
}
debug {
applicationIdSuffix ".debug"
debuggable true
jniDebuggable true
resValue "string", "linphone_app_branch", gitBranch.toString().trim()
resValue "string", "sync_account_type", getPackageName() + ".sync"
resValue "string", "file_provider", getPackageName() + ".debug.fileprovider"
resValue "string", "linphone_address_mime_type", "vnd.android.cursor.item/vnd." + getPackageName() + ".provider.sip_address"
if (!firebaseEnabled) {
resValue "string", "gcm_defaultSenderId", "none"
}
resValue "bool", "crashlytics_enabled", crashlyticsEnabled.toString()
if (crashlyticsEnabled) {
firebaseCrashlytics {
nativeSymbolUploadEnabled true
unstrippedNativeLibsDir file(LinphoneSdkBuildDir + '/libs-debug/').toString()
}
}
}
}
buildFeatures {
dataBinding = true
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
}
repositories {
maven {
name "local linphone-sdk maven repository"
url file(LinphoneSdkBuildDir + '/maven_repository/')
content {
includeGroup "org.linphone"
}
}
maven {
name "linphone.org maven repository"
url "https://linphone.org/maven_repository"
content {
includeGroup "org.linphone"
}
}
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'androidx.appcompat:appcompat:1.3.1'
implementation 'androidx.media:media:1.4.3'
implementation 'androidx.fragment:fragment-ktx:1.4.0-rc01'
implementation 'androidx.core:core-ktx:1.7.0'
def nav_version = "2.4.0-beta02"
implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
implementation "androidx.slidingpanelayout:slidingpanelayout:1.2.0-beta01"
implementation "androidx.window:window:1.0.0-beta03"
implementation 'androidx.constraintlayout:constraintlayout:2.1.1'
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.0'
implementation 'androidx.recyclerview:recyclerview:1.2.1'
implementation "androidx.security:security-crypto-ktx:1.1.0-alpha03"
implementation 'com.google.android.material:material:1.4.0'
implementation 'com.google.android.flexbox:flexbox:3.0.0'
implementation 'androidx.emoji:emoji:1.1.0'
implementation 'androidx.emoji:emoji-bundled:1.1.0'
implementation 'com.github.bumptech.glide:glide:4.12.0'
kapt 'com.github.bumptech.glide:compiler:4.12.0'
implementation 'com.github.chrisbanes:PhotoView:2.3.0'
implementation platform('com.google.firebase:firebase-bom:26.4.0')
if (crashlyticsEnabled) {
implementation 'com.google.firebase:firebase-crashlytics-ndk'
} else {
compileOnly 'com.google.firebase:firebase-crashlytics-ndk'
}
if (firebaseEnabled) {
implementation 'com.google.firebase:firebase-messaging'
}
implementation 'org.linphone:linphone-sdk-android:5.1+'
// Only enable leak canary prior to release
//debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.4'
}
task generateContactsXml(type: Copy) {
from 'contacts.xml'
into "src/main/res/xml/"
outputs.upToDateWhen { file('src/main/res/xml/contacts.xml').exists() }
filter {
line -> line
.replaceAll('%%AUTO_GENERATED%%', 'This file has been automatically generated, do not edit or commit !')
.replaceAll('%%PACKAGE_NAME%%', getPackageName())
}
}
project.tasks['preBuild'].dependsOn 'generateContactsXml'
ktlint {
android = true
ignoreFailures = true
}
project.tasks['preBuild'].dependsOn 'ktlintFormat'
if (crashlyticsEnabled) {
afterEvaluate {
assembleDebug.finalizedBy(uploadCrashlyticsSymbolFileDebug)
packageDebugBundle.finalizedBy(uploadCrashlyticsSymbolFileDebug)
assembleReleaseWithCrashlytics.finalizedBy(uploadCrashlyticsSymbolFileReleaseWithCrashlytics)
packageReleaseWithCrashlytics.finalizedBy(uploadCrashlyticsSymbolFileReleaseWithCrashlytics)
}
}

View file

@ -1,330 +0,0 @@
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.BufferedReader
import java.io.FileInputStream
import java.util.Properties
plugins {
alias(libs.plugins.androidApplication)
alias(libs.plugins.kapt)
alias(libs.plugins.ktlint)
alias(libs.plugins.jetbrainsKotlinAndroid)
alias(libs.plugins.navigation)
}
val packageName = "org.linphone"
val useDifferentPackageNameForDebugBuild = false
val sdkPath = providers.gradleProperty("LinphoneSdkBuildDir").get()
val googleServices = File(projectDir.absolutePath + "/google-services.json")
val linphoneLibs = File("$sdkPath/libs/")
val linphoneDebugLibs = File("$sdkPath/libs-debug/")
val firebaseCloudMessagingAvailable = googleServices.exists()
val crashlyticsAvailable = googleServices.exists() && linphoneLibs.exists() && linphoneDebugLibs.exists()
if (firebaseCloudMessagingAvailable) {
println("google-services.json found, enabling Firebase CloudMessaging feature")
apply<GoogleServicesPlugin>()
} else {
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<CrashlyticsPlugin>()
} 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 gitVersion = "6.1.0-alpha"
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"
}
} catch (e: Exception) {
println("Git not found [$e], using $gitVersion")
}
println("Computed git version: $gitVersion")
configurations {
implementation { isCanBeResolved = true }
}
tasks.register("linphoneSdkSource") {
doLast {
configurations.implementation.get().incoming.resolutionResult.allComponents.forEach {
if (it.id.displayName.contains("linphone-sdk-android")) {
println("Linphone SDK used is ${it.moduleVersion?.version}")
}
}
}
}
project.tasks.preBuild.dependsOn("linphoneSdkSource")
android {
namespace = "org.linphone"
compileSdk = 36
defaultConfig {
applicationId = packageName
minSdk = 28
targetSdk = 36
versionCode = 601002 // 6.01.002
versionName = "6.1.0-alpha"
manifestPlaceholders["appAuthRedirectScheme"] = packageName
ndk {
//noinspection ChromeOsAbiSupport
abiFilters += listOf("armeabi-v7a", "arm64-v8a")
}
}
applicationVariants.all {
val variant = this
variant.outputs
.map { it as com.android.build.gradle.internal.api.BaseVariantOutputImpl }
.forEach { output ->
output.outputFileName = "linphone-android-${variant.buildType.name}-${project.version}.apk"
}
}
val keystorePropertiesFile = rootProject.file("keystore.properties")
val keystoreProperties = Properties()
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
signingConfigs {
create("release") {
val keyStorePath = keystoreProperties["storeFile"] as String
val keyStore = project.file(keyStorePath)
if (keyStore.exists()) {
storeFile = keyStore
storePassword = keystoreProperties["storePassword"] as String
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
println("Signing config release is using keystore [$storeFile]")
} else {
println("Keystore [$storeFile] doesn't exists!")
}
}
}
buildTypes {
getByName("debug") {
if (useDifferentPackageNameForDebugBuild) {
applicationIdSuffix = ".debug"
}
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_openid_callback_scheme", packageName)
if (crashlyticsAvailable) {
val path = File("$sdkPath/libs-debug/").toString()
configure<CrashlyticsExtension> {
nativeSymbolUploadEnabled = true
unstrippedNativeLibsDir = path
}
} else {
resValue("string", "com.crashlytics.android.build_id", "none")
}
buildConfigField("Boolean", "CRASHLYTICS_ENABLED", crashlyticsAvailable.toString())
}
getByName("release") {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
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_openid_callback_scheme", packageName)
if (crashlyticsAvailable) {
val path = File("$sdkPath/libs-debug/").toString()
configure<CrashlyticsExtension> {
nativeSymbolUploadEnabled = true
unstrippedNativeLibsDir = path
}
} else {
resValue("string", "com.crashlytics.android.build_id", "none")
}
buildConfigField("Boolean", "CRASHLYTICS_ENABLED", crashlyticsAvailable.toString())
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
buildFeatures {
dataBinding = true
buildConfig = true
resValues = true
}
lint {
abortOnError = false
}
}
dependencies {
implementation(libs.androidx.annotations)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.constraint.layout)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.splashscreen)
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)
implementation(libs.androidx.security.crypto.ktx)
implementation(libs.androidx.navigation.fragment.ktx)
implementation(libs.androidx.navigation.ui.ktx)
implementation(libs.androidx.emoji2)
implementation(libs.androidx.car)
// https://github.com/google/flexbox-layout/blob/main/LICENSE Apache v2.0
implementation(libs.google.flexbox)
// https://github.com/material-components/material-components-android/blob/master/LICENSE Apache v2.0
implementation(libs.google.material)
// To be able to parse native crash tombstone and print them with SDK logs the next time the app will start
implementation(libs.google.protobuf)
implementation(platform(libs.google.firebase.bom))
implementation(libs.google.firebase.messaging)
implementation(libs.google.firebase.crashlytics)
// https://github.com/coil-kt/coil/blob/main/LICENSE.txt Apache v2.0
implementation(libs.coil)
implementation(libs.coil.gif)
implementation(libs.coil.svg)
implementation(libs.coil.video)
// https://github.com/tommybuonomo/dotsindicator/blob/master/LICENSE Apache v2.0
implementation(libs.dots.indicator)
// https://github.com/Baseflow/PhotoView/blob/master/LICENSE Apache v2.0
implementation(libs.photoview)
// https://github.com/openid/AppAuth-Android/blob/master/LICENSE Apache v2.0
implementation(libs.openid.appauth)
implementation(libs.linphone)
}
configure<org.jlleitschuh.gradle.ktlint.KtlintExtension> {
android.set(true)
ignoreFailures.set(true)
additionalEditorconfig.set(
mapOf(
"max_line_length" to "120",
"ktlint_standard_max-line-length" to "disabled",
"ktlint_standard_function-signature" to "disabled",
"ktlint_standard_no-blank-line-before-rbrace" to "disabled",
"ktlint_standard_no-empty-class-body" to "disabled",
"ktlint_standard_annotation-spacing" to "disabled",
"ktlint_standard_class-signature" to "disabled",
"ktlint_standard_function-expression-body" to "disabled",
"ktlint_standard_function-type-modifier-spacing" to "disabled",
"ktlint_standard_if-else-wrapping" to "disabled",
"ktlint_standard_argument-list-wrapping" to "disabled",
"ktlint_standard_trailing-comma-on-call-site" to "disabled",
"ktlint_standard_trailing-comma-on-declaration-site" to "disabled",
"ktlint_standard_no-empty-first-line-in-class-body" to "disabled",
"ktlint_standard_no-empty-first-line-in-method-block" to "disabled",
"ktlint_standard_no-trailing-spaces" to "disabled",
"ktlint_standard_no-blank-line-in-list" to "disabled",
"ktlint_standard_no-multi-spaces" to "disabled",
"ktlint_standard_try-catch-finally-spacing" to "disabled",
"ktlint_standard_block-comment-initial-star-alignment" to "disabled",
"ktlint_standard_spacing-between-declarations-with-comments" to "disabled",
"ktlint_standard_no-consecutive-comments" to "disabled",
"ktlint_standard_multiline-expression-wrapping" to "disabled",
"ktlint_standard_parameter-list-wrapping" to "disabled",
"ktlint_standard_comment-wrapping" to "disabled",
"ktlint_standard_discouraged-comment-location" to "disabled",
"ktlint_standard_string-template-indent" to "disabled",
"ktlint_standard_parameter-list-spacing" to "disabled",
"ktlint_standard_statement-wrapping" to "disabled",
"ktlint_standard_import-ordering" to "disabled",
"ktlint_standard_paren-spacing" to "disabled",
"ktlint_standard_curly-spacing" to "disabled",
"ktlint_standard_indent" to "disabled",
)
)
}
project.tasks.preBuild.dependsOn("ktlintFormat")
if (crashlyticsAvailable) {
afterEvaluate {
tasks.getByName("assembleDebug").finalizedBy(
tasks.getByName("uploadCrashlyticsSymbolFileDebug"),
)
tasks.getByName("packageDebug").finalizedBy(
tasks.getByName("uploadCrashlyticsSymbolFileDebug"),
)
tasks.getByName("assembleRelease").finalizedBy(
tasks.getByName("uploadCrashlyticsSymbolFileRelease"),
)
tasks.getByName("packageRelease").finalizedBy(
tasks.getByName("uploadCrashlyticsSymbolFileRelease"),
)
}
}

11
app/contacts.xml Normal file
View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<ContactsSource xmlns:android="http://schemas.android.com/apk/res/android">
<!-- %%AUTO_GENERATED%% -->
<ContactsDataKind
android:detailColumn="data3"
android:detailSocialSummary="true"
android:icon="@drawable/linphone_logo_tinted"
android:mimeType="vnd.android.cursor.item/vnd.%%PACKAGE_NAME%%.provider.sip_address"
android:summaryColumn="data2" />
<!-- You can't use @string/linphone_address_mime_type above ! You have to hardcode it... -->
</ContactsSource>

View file

@ -1,6 +1,6 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.kts.
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
@ -18,4 +18,8 @@
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
#-renamesourcefileattribute SourceFile
-keep public class * extends androidx.fragment.app.Fragment { *; }
-keep public class * extends com.bumptech.glide.module.AppGlideModule
-keep class com.bumptech.glide.GeneratedAppGlideModuleImpl

View file

@ -1,86 +1,66 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
package="org.linphone">
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- To be able to display contacts list & match calling/called numbers -->
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.WRITE_CONTACTS" />
<!-- Starting Android 13 we need to ask notification permission -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<!-- Helps filling phone number and country code in assistant -->
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<!-- Needed for auto start at boot and to ensure the service won't be killed by OS while in call -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- Needed for full screen intent in incoming call notifications -->
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<!-- To vibrate while receiving an incoming call -->
<!-- To vibrate when pressing DTMF keys on numpad -->
<uses-permission android:name="android.permission.VIBRATE" />
<!-- Needed for foreground service
(https://developer.android.com/guide/components/foreground-services) -->
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<!-- Needed for Android 14
https://developer.android.com/about/versions/14/behavior-changes-14#fgs-types -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
<!-- Required for foreground service started when a push is being received,
without it app won't be able to access network if data saver is ON (for example) -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!-- Needed to keep a permanent foreground service and keep app alive to be able to receive
messages & calls for third party accounts for which push notifications aren't available,
and starting Android 15 dataSync is limited to 6 hours per day
and can't be used with RECEIVE_BOOT_COMPLETED intent either -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<!-- Needed to shared downloaded files if setting is on -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<!-- Needed for auto start at boot if keep alive service is enabled -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- Both permissions below are for contacts sync account -->
<uses-permission android:name="android.permission.AUTHENTICATE_ACCOUNTS" />
<uses-permission android:name="android.permission.GET_ACCOUNTS" />
<!-- Needed for Telecom Manager -->
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS"/>
<uses-permission android:name="android.permission.READ_PHONE_NUMBERS" />
<!-- Needed for overlay -->
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<application
android:name=".LinphoneApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="${appLabel}"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name"
android:enableOnBackInvokedCallback="true"
android:localeConfig="@xml/locales_config"
android:theme="@style/Theme.Linphone"
android:appCategory="social"
android:largeHeap="true"
tools:targetApi="35">
<!-- Required for chat message & call notifications to be displayed in Android auto -->
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc"/>
<!--<meta-data
android:name="androidx.car.app.minCarApiLevel"
android:value="1"/>-->
android:extractNativeLibs="${extractNativeLibs}"
android:theme="@style/AppTheme"
android:allowNativeHeapPointerTagging="false">
<activity
android:name=".ui.main.MainActivity"
android:theme="@style/AppSplashScreenTheme"
android:windowSoftInputMode="adjustResize"
android:name=".activities.launcher.LauncherActivity"
android:exported="true"
android:launchMode="singleTask">
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
android:noHistory="true"
android:theme="@style/AppTheme">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity>
<activity android:name=".activities.main.MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize">
<nav-graph android:value="@navigation/main_nav_graph" />
<intent-filter>
<action android:name="android.intent.action.VIEW_LOCUS" />
@ -106,83 +86,68 @@
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.DIAL" />
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="${linphone_address_mime_type}" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<action android:name="android.intent.action.DIAL" />
<action android:name="android.intent.action.CALL" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="tel" />
<data android:scheme="sip" />
<data android:scheme="callto" />
<data android:scheme="sips" />
<data android:scheme="linphone" />
<data android:scheme="sip-linphone" />
<data android:scheme="sips-linphone" />
<data android:scheme="linphone-sip" />
<data android:scheme="linphone-sips" />
<data android:scheme="linphone-config" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<action android:name="android.intent.action.SENDTO" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="sms" />
<data android:scheme="smsto" />
<data android:scheme="mms" />
<data android:scheme="mmsto" />
</intent-filter>
</activity>
<activity
android:name=".ui.welcome.WelcomeActivity"
android:windowSoftInputMode="adjustResize"
android:launchMode="singleTask"
android:resizeableActivity="true" />
<activity android:name=".activities.assistant.AssistantActivity"
android:windowSoftInputMode="adjustResize"/>
<activity
android:name=".ui.assistant.AssistantActivity"
android:windowSoftInputMode="adjustResize"
android:launchMode="singleTask"
android:resizeableActivity="true" />
<activity
android:name=".ui.fileviewer.MediaViewerActivity"
android:windowSoftInputMode="adjustResize"
android:launchMode="singleTask"
android:resizeableActivity="true" />
<activity
android:name=".ui.fileviewer.FileViewerActivity"
android:windowSoftInputMode="adjustResize"
android:launchMode="singleTask"
android:resizeableActivity="true" />
<activity
android:name=".ui.call.CallActivity"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.LinphoneInCall"
android:launchMode="singleInstance"
android:turnScreenOn="true"
android:showWhenLocked="true"
<activity android:name=".activities.call.CallActivity"
android:launchMode="singleTop"
android:resizeableActivity="true"
android:supportsPictureInPicture="true" />
<activity android:name=".activities.call.IncomingCallActivity"
android:launchMode="singleTop"
android:noHistory="true" />
<activity android:name=".activities.call.OutgoingCallActivity"
android:launchMode="singleTop"
android:noHistory="true" />
<activity
android:name=".activities.chat_bubble.ChatBubbleActivity"
android:allowEmbedded="true"
android:documentLaunchMode="always"
android:resizeableActivity="true" />
<!-- Services -->
<service
android:name=".core.CoreInCallService"
android:name=".core.CoreService"
android:exported="false"
android:foregroundServiceType="phoneCall|camera|microphone"
android:stopWithTask="false"
android:label="@string/app_name" />
<service
android:name=".core.CorePushService"
android:exported="false"
android:foregroundServiceType="dataSync"
android:stopWithTask="false"
android:label="@string/app_name" />
<service
android:name=".core.CoreFileTransferService"
android:exported="false"
android:foregroundServiceType="dataSync"
android:stopWithTask="false"
android:label="@string/app_name" />
<service android:name="org.linphone.core.tools.firebase.FirebaseMessaging"
android:enabled="true"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
@ -190,24 +155,38 @@
</service>
<service
android:name=".core.CoreKeepAliveThirdPartyAccountsService"
android:exported="false"
android:foregroundServiceType="specialUse"
android:stopWithTask="false"
android:label="@string/app_name">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Needed to keep app alive to be able to receive messages and calls from third party SIP servers for which push notifications aren't available." />
</service>
<!--<service
android:name=".telecom.auto.AndroidAutoService"
android:name=".contact.DummySyncService"
android:exported="true">
<intent-filter>
<action android:name="androidx.car.app.CarAppService"/>
<category android:name="androidx.car.app.category.CALLING"/>
<action android:name="android.content.SyncAdapter" />
</intent-filter>
</service>-->
<meta-data
android:name="android.content.SyncAdapter"
android:resource="@xml/sync_adapter" />
<meta-data
android:name="android.provider.CONTACTS_STRUCTURE"
android:resource="@xml/contacts" />
</service>
<service android:name=".contact.DummyAuthenticationService"
android:exported="true">
<intent-filter>
<action android:name="android.accounts.AccountAuthenticator" />
</intent-filter>
<meta-data
android:name="android.accounts.AccountAuthenticator"
android:resource="@xml/authenticator" />
</service>
<service android:name=".telecom.TelecomConnectionService"
android:permission="android.permission.BIND_TELECOM_CONNECTION_SERVICE"
android:exported="true">
<intent-filter>
<action android:name="android.telecom.ConnectionService" />
</intent-filter>
</service>
<!-- Receivers -->
@ -235,7 +214,7 @@
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="@string/file_provider"
android:authorities="${linphone_file_provider}"
android:exported="false"
android:grantUriPermissions="true">
<meta-data

View file

@ -4,7 +4,6 @@
<entry name="avpf" overwrite="true">0</entry>
<entry name="dial_escape_plus" overwrite="true">0</entry>
<entry name="publish" overwrite="true">0</entry>
<entry name="publish_expires" overwrite="true">-1</entry>
<entry name="quality_reporting_collector" overwrite="true"></entry>
<entry name="quality_reporting_enabled" overwrite="true">0</entry>
<entry name="quality_reporting_interval" overwrite="true">0</entry>
@ -16,23 +15,20 @@
<entry name="nat_policy_ref" overwrite="true"></entry>
<entry name="realm" overwrite="true"></entry>
<entry name="conference_factory_uri" overwrite="true"></entry>
<entry name="audio_video_conference_factory_uri" overwrite="true"></entry>
<entry name="push_notification_allowed" overwrite="true">0</entry>
<entry name="cpim_in_basic_chat_rooms_enabled" overwrite="true">0</entry>
<entry name="rtp_bundle" overwrite="true">0</entry>
<entry name="lime_server_url" overwrite="true"></entry>
<entry name="lime_algo" overwrite="true"></entry>
<entry name="supported" overwrite="true">outbound</entry>
</section>
<section name="nat_policy_default_values">
<entry name="stun_server" overwrite="true">stun.linphone.org</entry>
<entry name="protocols" overwrite="true">stun,ice</entry>
<entry name="stun_server" overwrite="true"></entry>
<entry name="protocols" overwrite="true"></entry>
</section>
<section name="sip">
<entry name="media_encryption">srtp</entry>
<entry name="media_encryption_mandatory" overwrite="true">0</entry>
</section>
<section name="ui">
<entry name="automatically_show_dialpad" overwrite="true">1</entry>
<section name="assistant">
<entry name="domain" overwrite="true"></entry>
<entry name="algorithm" overwrite="true">MD5</entry>
<entry name="password_max_length" overwrite="true">-1</entry>
<entry name="password_min_length" overwrite="true">0</entry>
<entry name="username_length" overwrite="true">-1</entry>
<entry name="username_max_length" overwrite="true">128</entry>
<entry name="username_min_length" overwrite="true">1</entry>
<entry name="username_regex" overwrite="true">^[a-zA-Z0-9+_.\-]*$</entry>
</section>
</config>

View file

@ -3,8 +3,7 @@
<section name="proxy_default_values">
<entry name="avpf" overwrite="true">1</entry>
<entry name="dial_escape_plus" overwrite="true">0</entry>
<entry name="publish" overwrite="true">1</entry>
<entry name="publish_expires" overwrite="true">120</entry>
<entry name="publish" overwrite="true">0</entry>
<entry name="quality_reporting_collector" overwrite="true">sip:voip-metrics@sip.linphone.org;transport=tls</entry>
<entry name="quality_reporting_enabled" overwrite="true">1</entry>
<entry name="quality_reporting_interval" overwrite="true">180</entry>
@ -16,20 +15,26 @@
<entry name="nat_policy_ref" overwrite="true">nat_policy_default_values</entry>
<entry name="realm" overwrite="true">sip.linphone.org</entry>
<entry name="conference_factory_uri" overwrite="true">sip:conference-factory@sip.linphone.org</entry>
<entry name="audio_video_conference_factory_uri" overwrite="true">sip:videoconference-factory@sip.linphone.org</entry>
<entry name="push_notification_allowed" overwrite="true">1</entry>
<entry name="cpim_in_basic_chat_rooms_enabled" overwrite="true">1</entry>
<entry name="rtp_bundle" overwrite="true">1</entry>
<entry name="lime_server_url" overwrite="true">https://lime.linphone.org/lime-server/lime-server.php</entry>
<entry name="lime_algo" overwrite="true">c25519</entry>
<entry name="supported" overwrite="true"></entry>
</section>
<section name="nat_policy_default_values">
<entry name="stun_server" overwrite="true">stun.linphone.org</entry>
<entry name="protocols" overwrite="true">stun,ice</entry>
</section>
<section name="sip">
<entry name="media_encryption" overwrite="true">srtp</entry>
<entry name="media_encryption_mandatory">1</entry>
<entry name="rls_uri" overwrite="true">sips:rls@sip.linphone.org</entry>
</section>
<section name="lime">
<entry name="x3dh_server_url" overwrite="true">https://lime.linphone.org/lime-server/lime-server.php</entry>
</section>
<section name="assistant">
<entry name="domain" overwrite="true">sip.linphone.org</entry>
<entry name="algorithm" overwrite="true">SHA-256</entry>
<entry name="password_max_length" overwrite="true">-1</entry>
<entry name="password_min_length" overwrite="true">1</entry>
<entry name="username_length" overwrite="true">-1</entry>
<entry name="username_max_length" overwrite="true">64</entry>
<entry name="username_min_length" overwrite="true">1</entry>
<entry name="username_regex" overwrite="true">^[a-z0-9+_.\-]*$</entry>
</section>
</config>

View file

@ -10,10 +10,6 @@ sip_port=-1
sip_tcp_port=-1
sip_tls_port=-1
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"
@ -22,38 +18,24 @@ upload_bw=0
[video]
size=vga
automatically_accept=1
automatically_initiate=0
automatically_accept_direction=2 #receive only
[app]
tunnel=disabled
auto_download_incoming_voice_recordings=1
auto_download_incoming_icalendars=1
auto_start=1
[tunnel]
host=
port=443
[misc]
log_collection_upload_server_url=https://files.linphone.org/http-file-transfer-server/hft.php
file_transfer_server_url=https://files.linphone.org/http-file-transfer-server/hft.php
version_check_url_root=https://download.linphone.org/releases
log_collection_upload_server_url=https://www.linphone.org:444/lft.php
file_transfer_server_url=https://www.linphone.org:444/lft.php
version_check_url_root=https://www.linphone.org/releases
max_calls=10
history_max_size=100
conference_layout=1
hide_empty_chat_rooms=1
[fec]
fec_enabled=1
[magic_search]
return_empty_friends=1
[chat]
imdn_to_everybody_threshold=1
[ui]
contacts_filter=sip.linphone.org
[in-app-purchase]
server_url=https://subscribe.linphone.org:444/inapp.php
purchasable_items_ids=test_account_subscription
## End of default rc

View file

@ -8,9 +8,6 @@
mtu=1300
force_ice_disablement=0
[rtp]
accept_any_encryption=1
[sip]
guess_hostname=1
register_only_when_network_is_up=1
@ -18,42 +15,32 @@ auto_net_state_mon=1
auto_answer_replacing_calls=1
ping_with_options=0
use_cpim=1
zrtp_key_agreements_suites=MS_ZRTP_KEY_AGREEMENT_K255_MLK512,MS_ZRTP_KEY_AGREEMENT_K255_KYB512
chat_messages_aggregation_delay=1000
chat_messages_aggregation=1
update_presence_model_timestamp_before_publish_expires_refresh=1
[sound]
#remove this property for any application that is not Linphone public version itself
ec_calibrator_cool_tones=1
disable_ringing=0
[audio]
android_disable_audio_focus_requests=1
android_monitor_audio_devices=0
[video]
displaytype=MSAndroidTextureDisplay
auto_resize_preview_to_keep_ratio=1
max_conference_size=vga
[misc]
enable_basic_to_client_group_chat_room_migration=0
enable_simple_group_chat_message_state=0
aggregate_imdn=1
notify_each_friend_individually_when_presence_received=0
store_friends=0
[app]
record_aware=1
activation_code_length=4
prefer_basic_chat_room=1
[assistant]
xmlrpc_url=https://subscribe.linphone.org:444/wizard.php
[account_creator]
url=https://subscribe.linphone.org/api/
backend=0
[lime]
lime_update_threshold=86400
[alerts]
alerts_enabled=1
lime_update_threshold=-1
## End of factory rc

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2010-2023 Belledonne Communications SARL.
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
@ -22,142 +22,53 @@ package org.linphone
import android.annotation.SuppressLint
import android.app.Application
import android.content.Context
import android.os.PowerManager
import androidx.annotation.MainThread
import coil3.ImageLoader
import coil3.SingletonImageLoader
import coil3.disk.DiskCache
import coil3.disk.directory
import coil3.imageLoader
import coil3.memory.MemoryCache
import coil3.request.CachePolicy
import coil3.request.crossfade
import coil3.svg.SvgDecoder
import coil3.video.VideoFrameDecoder
import com.google.android.material.color.DynamicColors
import org.linphone.compatibility.Compatibility
import org.linphone.core.CoreContext
import org.linphone.core.CorePreferences
import org.linphone.core.Factory
import org.linphone.core.LogCollectionState
import org.linphone.core.LogLevel
import org.linphone.core.VFS
import org.linphone.core.*
import org.linphone.core.tools.Log
@MainThread
class LinphoneApplication : Application(), SingletonImageLoader.Factory {
class LinphoneApplication : Application() {
companion object {
private const val TAG = "[Linphone Application]"
@SuppressLint("StaticFieldLeak")
lateinit var corePreferences: CorePreferences
@SuppressLint("StaticFieldLeak")
lateinit var coreContext: CoreContext
fun ensureCoreExists(context: Context, pushReceived: Boolean = false) {
if (::coreContext.isInitialized && !coreContext.stopped) {
Log.d("[Application] Skipping Core creation (push received? $pushReceived)")
return
}
Factory.instance().setLogCollectionPath(context.filesDir.absolutePath)
Factory.instance().enableLogCollection(LogCollectionState.Enabled)
corePreferences = CorePreferences(context)
corePreferences.copyAssetsFromPackage()
if (corePreferences.vfsEnabled) {
CoreContext.activateVFS()
}
val config = Factory.instance().createConfigWithFactory(corePreferences.configPath, corePreferences.factoryConfigPath)
corePreferences.config = config
val appName = context.getString(R.string.app_name)
Factory.instance().setLoggerDomain(appName)
Factory.instance().enableLogcatLogs(corePreferences.logcatLogsOutput)
if (corePreferences.debugLogs) {
Factory.instance().loggingService.setLogLevel(LogLevel.Message)
}
Log.i("[Application] Core context created ${if (pushReceived) "from push" else ""}")
coreContext = CoreContext(context, config)
coreContext.start()
}
}
override fun onCreate() {
super.onCreate()
val context = applicationContext
val powerManager = context.getSystemService(POWER_SERVICE) as PowerManager
val wakeLock = powerManager.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
"Linphone:AppCreation"
)
wakeLock.acquire(20000L) // 20 seconds
Factory.instance().setLogCollectionPath(context.filesDir.absolutePath)
Factory.instance().enableLogCollection(LogCollectionState.Enabled)
// For VFS
Factory.instance().setCacheDir(context.cacheDir.absolutePath)
corePreferences = CorePreferences(context)
corePreferences.copyAssetsFromPackage()
if (VFS.isEnabled(context)) {
VFS.setup(context)
}
val config = Factory.instance().createConfigWithFactory(
corePreferences.configPath,
corePreferences.factoryConfigPath
)
corePreferences.config = config
val appName = context.getString(R.string.app_name)
Factory.instance().setLoggerDomain(appName)
Factory.instance().loggingService.setLogLevel(LogLevel.Message)
Factory.instance().enableLogcatLogs(corePreferences.printLogsInLogcat)
Log.i("$TAG Report Core preferences initialized")
Compatibility.setupAppStartupListener(context)
coreContext = CoreContext(context)
coreContext.start()
DynamicColors.applyToActivitiesIfAvailable(this)
wakeLock.release()
}
override fun onTrimMemory(level: Int) {
Log.w("$TAG onTrimMemory called with level [${trimLevelToString(level)}]($level) !")
when (level) {
TRIM_MEMORY_RUNNING_LOW,
TRIM_MEMORY_RUNNING_CRITICAL,
TRIM_MEMORY_MODERATE,
TRIM_MEMORY_COMPLETE -> {
Log.i("$TAG Memory trim required, clearing imageLoader memory cache")
imageLoader.memoryCache?.clear()
}
else -> {}
}
super.onTrimMemory(level)
}
override fun newImageLoader(context: Context): ImageLoader {
// When VFS is enabled, prevent Coil from keeping plain version of files on disk
val diskCachePolicy = if (VFS.isEnabled(applicationContext)) {
CachePolicy.DISABLED
} else {
CachePolicy.ENABLED
}
return ImageLoader.Builder(this)
.crossfade(false)
.components {
add(VideoFrameDecoder.Factory())
// add(GifDecoder.Factory) // Do not add it, GIFs are properly rendered without it and adding it breaks resizing...
add(SvgDecoder.Factory())
}
.memoryCache {
MemoryCache.Builder()
.maxSizePercent(context, 0.25)
.build()
}
.diskCache {
val cache = cacheDir.resolve("image_cache")
DiskCache.Builder()
.directory(cache)
.maxSizePercent(0.02)
.build()
}
.networkCachePolicy(CachePolicy.ENABLED)
.diskCachePolicy(diskCachePolicy)
.memoryCachePolicy(CachePolicy.ENABLED)
.build()
}
private fun trimLevelToString(level: Int): String {
return when (level) {
TRIM_MEMORY_UI_HIDDEN -> "Hidden UI"
TRIM_MEMORY_RUNNING_MODERATE -> "Moderate (Running)"
TRIM_MEMORY_RUNNING_LOW -> "Low"
TRIM_MEMORY_RUNNING_CRITICAL -> "Critical"
TRIM_MEMORY_BACKGROUND -> "Background"
TRIM_MEMORY_MODERATE -> "Moderate"
TRIM_MEMORY_COMPLETE -> "Complete"
else -> level.toString()
}
val appName = getString(R.string.app_name)
android.util.Log.i("[$appName]", "Application is being created")
ensureCoreExists(applicationContext)
Log.i("[Application] Created")
}
}

View file

@ -0,0 +1,147 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities
import android.annotation.SuppressLint
import android.content.pm.ActivityInfo
import android.content.res.Configuration
import android.os.Bundle
import android.util.DisplayMetrics
import android.view.Display
import android.view.Surface
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.lifecycle.lifecycleScope
import androidx.navigation.ActivityNavigator
import androidx.window.layout.FoldingFeature
import androidx.window.layout.WindowInfoRepository.Companion.windowInfoRepository
import androidx.window.layout.WindowLayoutInfo
import java.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.LinphoneApplication.Companion.corePreferences
import org.linphone.LinphoneApplication.Companion.ensureCoreExists
import org.linphone.R
import org.linphone.core.tools.Log
abstract class GenericActivity : AppCompatActivity() {
private var timer: Timer? = null
private var _isDestructionPending = false
val isDestructionPending: Boolean
get() = _isDestructionPending
open fun onLayoutChanges(foldingFeature: FoldingFeature?) { }
@SuppressLint("SourceLockedOrientationActivity")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
ensureCoreExists(applicationContext)
lifecycleScope.launch(Dispatchers.Main) {
windowInfoRepository().windowLayoutInfo.collect { newLayoutInfo ->
updateCurrentLayout(newLayoutInfo)
}
}
requestedOrientation = if (corePreferences.forcePortrait) {
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
} else {
ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
}
_isDestructionPending = false
val nightMode = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
val darkModeEnabled = corePreferences.darkMode
when (nightMode) {
Configuration.UI_MODE_NIGHT_NO, Configuration.UI_MODE_NIGHT_UNDEFINED -> {
if (darkModeEnabled == 1) {
// Force dark mode
Log.w("[Generic Activity] Forcing night mode")
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
_isDestructionPending = true
}
}
Configuration.UI_MODE_NIGHT_YES -> {
if (darkModeEnabled == 0) {
// Force light mode
Log.w("[Generic Activity] Forcing day mode")
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
_isDestructionPending = true
}
}
}
updateScreenSize()
}
override fun onResume() {
super.onResume()
var degrees = 270
val orientation = windowManager.defaultDisplay.rotation
when (orientation) {
Surface.ROTATION_0 -> degrees = 0
Surface.ROTATION_90 -> degrees = 270
Surface.ROTATION_180 -> degrees = 180
Surface.ROTATION_270 -> degrees = 90
}
Log.i("[Generic Activity] Device orientation is $degrees (raw value is $orientation)")
val rotation = (360 - degrees) % 360
coreContext.core.deviceRotation = rotation
// Remove service notification if it has been started by device boot
coreContext.notificationsManager.stopForegroundNotificationIfPossible()
}
override fun finish() {
super.finish()
ActivityNavigator.applyPopAnimationsToPendingTransition(this)
}
fun isTablet(): Boolean {
return resources.getBoolean(R.bool.isTablet)
}
private fun updateScreenSize() {
val metrics = DisplayMetrics()
val display: Display = windowManager.defaultDisplay
display.getRealMetrics(metrics)
val screenWidth = metrics.widthPixels.toFloat()
val screenHeight = metrics.heightPixels.toFloat()
coreContext.screenWidth = screenWidth
coreContext.screenHeight = screenHeight
}
private fun updateCurrentLayout(newLayoutInfo: WindowLayoutInfo) {
if (newLayoutInfo.displayFeatures.isEmpty()) {
onLayoutChanges(null)
} else {
for (feature in newLayoutInfo.displayFeatures) {
val foldingFeature = feature as? FoldingFeature
if (foldingFeature != null) {
onLayoutChanges(foldingFeature)
}
}
}
}
}

View file

@ -0,0 +1,101 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.OnBackPressedCallback
import androidx.core.view.doOnPreDraw
import androidx.databinding.DataBindingUtil
import androidx.databinding.ViewDataBinding
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import com.google.android.material.transition.MaterialSharedAxis
import org.linphone.LinphoneApplication.Companion.corePreferences
import org.linphone.core.tools.Log
abstract class GenericFragment<T : ViewDataBinding> : Fragment() {
private var _binding: T? = null
protected val binding get() = _binding!!
protected var useMaterialSharedAxisXForwardAnimation = true
protected val onBackPressedCallback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
goBack()
}
}
abstract fun getLayoutId(): Int
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
_binding = DataBindingUtil.inflate(inflater, getLayoutId(), container, false)
return _binding!!.root
}
override fun onResume() {
super.onResume()
requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, onBackPressedCallback)
}
override fun onPause() {
onBackPressedCallback.remove()
super.onPause()
}
override fun onStart() {
super.onStart()
if (useMaterialSharedAxisXForwardAnimation && corePreferences.enableAnimations) {
enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
postponeEnterTransition()
binding.root.doOnPreDraw { startPostponedEnterTransition() }
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
protected open fun goBack() {
try {
if (!findNavController().popBackStack()) {
if (!findNavController().navigateUp()) {
onBackPressedCallback.isEnabled = false
requireActivity().onBackPressed()
}
}
} catch (ise: IllegalStateException) {
Log.e("[Generic Fragment] [$this] Can't go back: $ise")
onBackPressedCallback.isEnabled = false
requireActivity().onBackPressed()
}
}
}

View file

@ -0,0 +1,913 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities
import android.net.Uri
import android.os.Bundle
import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
import androidx.navigation.NavController
import androidx.navigation.NavOptions
import androidx.navigation.findNavController
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.fragment.findNavController
import androidx.slidingpanelayout.widget.SlidingPaneLayout
import org.linphone.R
import org.linphone.activities.assistant.fragments.*
import org.linphone.activities.main.MainActivity
import org.linphone.activities.main.chat.fragments.ChatRoomCreationFragment
import org.linphone.activities.main.chat.fragments.DetailChatRoomFragment
import org.linphone.activities.main.chat.fragments.GroupInfoFragment
import org.linphone.activities.main.chat.fragments.MasterChatRoomsFragment
import org.linphone.activities.main.contact.fragments.ContactEditorFragment
import org.linphone.activities.main.contact.fragments.DetailContactFragment
import org.linphone.activities.main.contact.fragments.MasterContactsFragment
import org.linphone.activities.main.dialer.fragments.DialerFragment
import org.linphone.activities.main.fragments.TabsFragment
import org.linphone.activities.main.history.fragments.DetailCallLogFragment
import org.linphone.activities.main.history.fragments.MasterCallLogsFragment
import org.linphone.activities.main.settings.fragments.*
import org.linphone.activities.main.sidemenu.fragments.SideMenuFragment
import org.linphone.contact.NativeContact
import org.linphone.core.Address
internal fun Fragment.findMasterNavController(): NavController {
return parentFragment?.parentFragment?.findNavController() ?: findNavController()
}
fun popupTo(
popUpTo: Int = -1,
popUpInclusive: Boolean = false,
singleTop: Boolean = true
): NavOptions {
val builder = NavOptions.Builder()
builder.setPopUpTo(popUpTo, popUpInclusive).setLaunchSingleTop(singleTop)
return builder.build()
}
/* Main activity related */
internal fun MainActivity.navigateToDialer(args: Bundle?) {
findNavController(R.id.nav_host_fragment).navigate(
R.id.action_global_dialerFragment,
args,
popupTo(R.id.dialerFragment, true)
)
}
/* Tabs fragment related */
internal fun TabsFragment.navigateToCallHistory() {
val action = when (findNavController().currentDestination?.id) {
R.id.masterContactsFragment -> R.id.action_masterContactsFragment_to_masterCallLogsFragment
R.id.dialerFragment -> R.id.action_dialerFragment_to_masterCallLogsFragment
R.id.masterChatRoomsFragment -> R.id.action_masterChatRoomsFragment_to_masterCallLogsFragment
else -> 0
}
if (action == 0) return
findNavController().navigate(
action,
null,
popupTo(R.id.masterCallLogsFragment, true)
)
}
internal fun TabsFragment.navigateToContacts() {
val action = when (findNavController().currentDestination?.id) {
R.id.masterCallLogsFragment -> R.id.action_masterCallLogsFragment_to_masterContactsFragment
R.id.dialerFragment -> R.id.action_dialerFragment_to_masterContactsFragment
R.id.masterChatRoomsFragment -> R.id.action_masterChatRoomsFragment_to_masterContactsFragment
else -> 0
}
if (action == 0) return
findNavController().navigate(
action,
null,
popupTo(R.id.masterContactsFragment, true)
)
}
internal fun TabsFragment.navigateToDialer() {
val action = when (findNavController().currentDestination?.id) {
R.id.masterCallLogsFragment -> R.id.action_masterCallLogsFragment_to_dialerFragment
R.id.masterContactsFragment -> R.id.action_masterContactsFragment_to_dialerFragment
R.id.masterChatRoomsFragment -> R.id.action_masterChatRoomsFragment_to_dialerFragment
else -> 0
}
if (action == 0) return
findNavController().navigate(
action,
null,
popupTo(R.id.dialerFragment, true)
)
}
internal fun TabsFragment.navigateToChatRooms() {
val action = when (findNavController().currentDestination?.id) {
R.id.masterCallLogsFragment -> R.id.action_masterCallLogsFragment_to_masterChatRoomsFragment
R.id.masterContactsFragment -> R.id.action_masterContactsFragment_to_masterChatRoomsFragment
R.id.dialerFragment -> R.id.action_dialerFragment_to_masterChatRoomsFragment
else -> 0
}
if (action == 0) return
findNavController().navigate(
action,
null,
popupTo(R.id.masterChatRoomsFragment, true)
)
}
/* Dialer related */
internal fun DialerFragment.navigateToContacts(uriToAdd: String?) {
val deepLink = "linphone-android://contact/new/$uriToAdd"
findNavController().navigate(
Uri.parse(deepLink),
popupTo(R.id.masterContactsFragment, true)
)
}
internal fun DialerFragment.navigateToConfigFileViewer() {
val bundle = bundleOf("Secure" to true)
findMasterNavController().navigate(
R.id.action_global_configViewerFragment,
bundle,
popupTo()
)
}
/* Chat related */
internal fun MasterChatRoomsFragment.navigateToChatRoom(args: Bundle) {
val navHostFragment =
childFragmentManager.findFragmentById(R.id.chat_nav_container) as NavHostFragment
val previousBackStackEntry = navHostFragment.navController.currentBackStackEntry
val popUpToFragmentId = when (previousBackStackEntry?.destination?.id) {
R.id.detailChatRoomFragment -> R.id.detailChatRoomFragment
R.id.chatRoomCreationFragment -> R.id.chatRoomCreationFragment
else -> R.id.emptyChatFragment
}
navHostFragment.navController.navigate(
R.id.action_global_detailChatRoomFragment,
args,
popupTo(popUpToFragmentId, true)
)
}
internal fun MasterChatRoomsFragment.navigateToChatRoomCreation(
createGroupChatRoom: Boolean = false,
slidingPane: SlidingPaneLayout
) {
val bundle = bundleOf("createGroup" to createGroupChatRoom)
val navHostFragment =
childFragmentManager.findFragmentById(R.id.chat_nav_container) as NavHostFragment
val previousBackStackEntry = navHostFragment.navController.currentBackStackEntry
val popUpToFragmentId = when (previousBackStackEntry?.destination?.id) {
R.id.detailChatRoomFragment -> R.id.detailChatRoomFragment
R.id.chatRoomCreationFragment -> R.id.chatRoomCreationFragment
else -> R.id.emptyChatFragment
}
navHostFragment.navController.navigate(
R.id.action_global_chatRoomCreationFragment,
bundle,
popupTo(popUpToFragmentId, true)
)
if (!slidingPane.isOpen) slidingPane.openPane()
}
internal fun MasterChatRoomsFragment.clearDisplayedChatRoom() {
if (findNavController().currentDestination?.id == R.id.masterChatRoomsFragment) {
val navHostFragment =
childFragmentManager.findFragmentById(R.id.chat_nav_container) as NavHostFragment
navHostFragment.navController.navigate(
R.id.action_global_emptyChatFragment,
null,
popupTo(R.id.emptyChatFragment, true)
)
}
}
internal fun DetailChatRoomFragment.navigateToContacts(sipUriToAdd: String) {
val deepLink = "linphone-android://contact/new/$sipUriToAdd"
findMasterNavController().navigate(Uri.parse(deepLink))
}
internal fun DetailChatRoomFragment.navigateToImdn(args: Bundle?) {
if (findNavController().currentDestination?.id == R.id.detailChatRoomFragment) {
findNavController().navigate(
R.id.action_detailChatRoomFragment_to_imdnFragment,
args,
popupTo()
)
}
}
internal fun DetailChatRoomFragment.navigateToDevices() {
if (findNavController().currentDestination?.id == R.id.detailChatRoomFragment) {
findNavController().navigate(
R.id.action_detailChatRoomFragment_to_devicesFragment,
null,
popupTo()
)
}
}
internal fun DetailChatRoomFragment.navigateToGroupInfo() {
if (findNavController().currentDestination?.id == R.id.detailChatRoomFragment) {
findNavController().navigate(
R.id.action_detailChatRoomFragment_to_groupInfoFragment,
null,
popupTo(R.id.groupInfoFragment, true)
)
}
}
internal fun DetailChatRoomFragment.navigateToEphemeralInfo() {
if (findNavController().currentDestination?.id == R.id.detailChatRoomFragment) {
findNavController().navigate(
R.id.action_detailChatRoomFragment_to_ephemeralFragment,
null,
popupTo()
)
}
}
internal fun DetailChatRoomFragment.navigateToTextFileViewer(secure: Boolean) {
val bundle = bundleOf("Secure" to secure)
findMasterNavController().navigate(
R.id.action_global_textViewerFragment,
bundle,
popupTo()
)
}
internal fun DetailChatRoomFragment.navigateToPdfFileViewer(secure: Boolean) {
val bundle = bundleOf("Secure" to secure)
findMasterNavController().navigate(
R.id.action_global_pdfViewerFragment,
bundle,
popupTo()
)
}
internal fun DetailChatRoomFragment.navigateToImageFileViewer(secure: Boolean) {
val bundle = bundleOf("Secure" to secure)
findMasterNavController().navigate(
R.id.action_global_imageViewerFragment,
bundle,
popupTo()
)
}
internal fun DetailChatRoomFragment.navigateToVideoFileViewer(secure: Boolean) {
val bundle = bundleOf("Secure" to secure)
findMasterNavController().navigate(
R.id.action_global_videoViewerFragment,
bundle,
popupTo()
)
}
internal fun DetailChatRoomFragment.navigateToAudioFileViewer(secure: Boolean) {
val bundle = bundleOf("Secure" to secure)
findMasterNavController().navigate(
R.id.action_global_audioViewerFragment,
bundle,
popupTo()
)
}
internal fun DetailChatRoomFragment.navigateToEmptyChatRoom() {
findNavController().navigate(
R.id.action_global_emptyChatFragment,
null,
popupTo(R.id.emptyChatFragment, true)
)
}
internal fun ChatRoomCreationFragment.navigateToGroupInfo() {
if (findNavController().currentDestination?.id == R.id.chatRoomCreationFragment) {
findNavController().navigate(
R.id.action_chatRoomCreationFragment_to_groupInfoFragment,
null,
popupTo(R.id.groupInfoFragment, true)
)
}
}
internal fun ChatRoomCreationFragment.navigateToChatRoom(args: Bundle) {
if (findNavController().currentDestination?.id == R.id.chatRoomCreationFragment) {
findNavController().navigate(
R.id.action_chatRoomCreationFragment_to_detailChatRoomFragment,
args,
popupTo(R.id.detailChatRoomFragment, true)
)
}
}
internal fun ChatRoomCreationFragment.navigateToEmptyChatRoom() {
findNavController().navigate(
R.id.action_global_emptyChatFragment,
null,
popupTo(R.id.emptyChatFragment, true)
)
}
internal fun GroupInfoFragment.navigateToChatRoomCreation(args: Bundle?) {
if (findNavController().currentDestination?.id == R.id.groupInfoFragment) {
findNavController().navigate(
R.id.action_groupInfoFragment_to_chatRoomCreationFragment,
args,
popupTo(R.id.chatRoomCreationFragment, true)
)
}
}
internal fun GroupInfoFragment.navigateToChatRoom(args: Bundle?, created: Boolean) {
if (findNavController().currentDestination?.id == R.id.groupInfoFragment) {
val popUpToFragmentId = if (created) { // To remove all creation fragments from back stack
R.id.chatRoomCreationFragment
} else {
R.id.detailChatRoomFragment
}
findNavController().navigate(
R.id.action_groupInfoFragment_to_detailChatRoomFragment,
args,
popupTo(popUpToFragmentId, true)
)
}
}
/* Contacts related */
internal fun MasterContactsFragment.navigateToContact() {
if (findNavController().currentDestination?.id == R.id.masterContactsFragment) {
val navHostFragment =
childFragmentManager.findFragmentById(R.id.contacts_nav_container) as NavHostFragment
val previousBackStackEntry = navHostFragment.navController.currentBackStackEntry
val popUpToFragmentId = when (previousBackStackEntry?.destination?.id) {
R.id.detailContactFragment -> R.id.detailContactFragment
R.id.contactEditorFragment -> R.id.contactEditorFragment
else -> R.id.emptyContactFragment
}
navHostFragment.navController.navigate(
R.id.action_global_detailContactFragment,
null,
popupTo(popUpToFragmentId, true)
)
}
}
internal fun MasterContactsFragment.navigateToContactEditor(
sipUriToAdd: String? = null,
slidingPane: SlidingPaneLayout
) {
if (findNavController().currentDestination?.id == R.id.masterContactsFragment) {
val bundle = if (sipUriToAdd != null) bundleOf("SipUri" to sipUriToAdd) else Bundle()
val navHostFragment =
childFragmentManager.findFragmentById(R.id.contacts_nav_container) as NavHostFragment
navHostFragment.navController.navigate(
R.id.action_global_contactEditorFragment,
bundle,
popupTo(R.id.emptyContactFragment, true)
)
if (!slidingPane.isOpen) slidingPane.openPane()
}
}
internal fun MasterContactsFragment.clearDisplayedContact() {
if (findNavController().currentDestination?.id == R.id.masterContactsFragment) {
val navHostFragment =
childFragmentManager.findFragmentById(R.id.contacts_nav_container) as NavHostFragment
navHostFragment.navController.navigate(
R.id.action_global_emptyContactFragment,
null,
popupTo(R.id.emptyContactFragment, true)
)
}
}
internal fun ContactEditorFragment.navigateToContact(contact: NativeContact) {
val bundle = Bundle()
bundle.putString("id", contact.nativeId)
findNavController().navigate(
R.id.action_contactEditorFragment_to_detailContactFragment,
bundle,
popupTo(R.id.contactEditorFragment, true)
)
}
internal fun ContactEditorFragment.navigateToEmptyContact() {
findNavController().navigate(
R.id.action_global_emptyContactFragment,
null,
popupTo(R.id.emptyContactFragment, true)
)
}
internal fun DetailContactFragment.navigateToChatRoom(args: Bundle?) {
findMasterNavController().navigate(
R.id.action_global_masterChatRoomsFragment,
args,
popupTo(R.id.masterChatRoomsFragment, true)
)
}
internal fun DetailContactFragment.navigateToDialer(args: Bundle?) {
findMasterNavController().navigate(
R.id.action_global_dialerFragment,
args,
popupTo(R.id.dialerFragment, true)
)
}
internal fun DetailContactFragment.navigateToContactEditor() {
if (findNavController().currentDestination?.id == R.id.detailContactFragment) {
findNavController().navigate(
R.id.action_detailContactFragment_to_contactEditorFragment,
null,
popupTo(R.id.contactEditorFragment, true)
)
}
}
internal fun DetailContactFragment.navigateToEmptyContact() {
findNavController().navigate(
R.id.action_global_emptyContactFragment,
null,
popupTo(R.id.emptyContactFragment, true)
)
}
/* History related */
internal fun MasterCallLogsFragment.navigateToCallHistory(slidingPane: SlidingPaneLayout) {
if (findNavController().currentDestination?.id == R.id.masterCallLogsFragment) {
val navHostFragment =
childFragmentManager.findFragmentById(R.id.history_nav_container) as NavHostFragment
navHostFragment.navController.navigate(
R.id.action_global_detailCallLogFragment,
null,
popupTo(R.id.detailCallLogFragment, true)
)
if (!slidingPane.isOpen) slidingPane.openPane()
}
}
internal fun MasterCallLogsFragment.clearDisplayedCallHistory() {
if (findNavController().currentDestination?.id == R.id.masterCallLogsFragment) {
val navHostFragment =
childFragmentManager.findFragmentById(R.id.history_nav_container) as NavHostFragment
navHostFragment.navController.navigate(
R.id.action_global_emptyFragment,
null,
popupTo(R.id.emptyCallHistoryFragment, true)
)
}
}
internal fun MasterCallLogsFragment.navigateToDialer(args: Bundle?) {
findNavController().navigate(
R.id.action_global_dialerFragment,
args,
popupTo(R.id.dialerFragment, true)
)
}
internal fun DetailCallLogFragment.navigateToContacts(sipUriToAdd: String) {
val deepLink = "linphone-android://contact/new/$sipUriToAdd"
findMasterNavController().navigate(Uri.parse(deepLink))
}
internal fun DetailCallLogFragment.navigateToContact(contact: NativeContact) {
val deepLink = "linphone-android://contact/view/${contact.nativeId}"
findMasterNavController().navigate(Uri.parse(deepLink))
}
internal fun DetailCallLogFragment.navigateToFriend(friendAddress: Address) {
val deepLink = "linphone-android://contact/new/${friendAddress.asStringUriOnly()}"
findMasterNavController().navigate(Uri.parse(deepLink))
}
internal fun DetailCallLogFragment.navigateToChatRoom(args: Bundle?) {
if (findNavController().currentDestination?.id == R.id.detailCallLogFragment) {
findMasterNavController().navigate(
R.id.action_global_masterChatRoomsFragment,
args,
popupTo(R.id.masterChatRoomsFragment, true)
)
}
}
internal fun DetailCallLogFragment.navigateToDialer(args: Bundle?) {
if (findNavController().currentDestination?.id == R.id.detailCallLogFragment) {
findMasterNavController().navigate(
R.id.action_global_dialerFragment,
args,
popupTo(R.id.dialerFragment, true)
)
}
}
internal fun DetailCallLogFragment.navigateToEmptyCallHistory() {
if (findNavController().currentDestination?.id == R.id.detailCallLogFragment) {
findNavController().navigate(
R.id.action_global_emptyFragment,
null,
popupTo(R.id.emptyCallHistoryFragment, true)
)
}
}
/* Settings related */
internal fun SettingsFragment.navigateToAccountSettings(identity: String) {
if (findNavController().currentDestination?.id == R.id.settingsFragment) {
val bundle = bundleOf("Identity" to identity)
val navHostFragment =
childFragmentManager.findFragmentById(R.id.settings_nav_container) as NavHostFragment
navHostFragment.navController.navigate(
R.id.action_global_accountSettingsFragment,
bundle,
popupTo(R.id.accountSettingsFragment, true)
)
}
}
internal fun SettingsFragment.navigateToTunnelSettings(slidingPane: SlidingPaneLayout) {
if (findNavController().currentDestination?.id == R.id.settingsFragment) {
val navHostFragment =
childFragmentManager.findFragmentById(R.id.settings_nav_container) as NavHostFragment
navHostFragment.navController.navigate(
R.id.action_global_tunnelSettingsFragment,
null,
popupTo(R.id.tunnelSettingsFragment, true)
)
if (!slidingPane.isOpen) slidingPane.openPane()
}
}
internal fun SettingsFragment.navigateToAudioSettings(slidingPane: SlidingPaneLayout) {
if (findNavController().currentDestination?.id == R.id.settingsFragment) {
val navHostFragment =
childFragmentManager.findFragmentById(R.id.settings_nav_container) as NavHostFragment
navHostFragment.navController.navigate(
R.id.action_global_audioSettingsFragment,
null,
popupTo(R.id.audioSettingsFragment, true)
)
if (!slidingPane.isOpen) slidingPane.openPane()
}
}
internal fun SettingsFragment.navigateToVideoSettings(slidingPane: SlidingPaneLayout) {
if (findNavController().currentDestination?.id == R.id.settingsFragment) {
val navHostFragment =
childFragmentManager.findFragmentById(R.id.settings_nav_container) as NavHostFragment
navHostFragment.navController.navigate(
R.id.action_global_videoSettingsFragment,
null,
popupTo(R.id.videoSettingsFragment, true)
)
if (!slidingPane.isOpen) slidingPane.openPane()
}
}
internal fun SettingsFragment.navigateToCallSettings(slidingPane: SlidingPaneLayout) {
if (findNavController().currentDestination?.id == R.id.settingsFragment) {
val navHostFragment =
childFragmentManager.findFragmentById(R.id.settings_nav_container) as NavHostFragment
navHostFragment.navController.navigate(
R.id.action_global_callSettingsFragment,
null,
popupTo(R.id.callSettingsFragment, true)
)
if (!slidingPane.isOpen) slidingPane.openPane()
}
}
internal fun SettingsFragment.navigateToChatSettings(slidingPane: SlidingPaneLayout) {
if (findNavController().currentDestination?.id == R.id.settingsFragment) {
val navHostFragment =
childFragmentManager.findFragmentById(R.id.settings_nav_container) as NavHostFragment
navHostFragment.navController.navigate(
R.id.action_global_chatSettingsFragment,
null,
popupTo(R.id.chatSettingsFragment, true)
)
if (!slidingPane.isOpen) slidingPane.openPane()
}
}
internal fun SettingsFragment.navigateToNetworkSettings(slidingPane: SlidingPaneLayout) {
if (findNavController().currentDestination?.id == R.id.settingsFragment) {
val navHostFragment =
childFragmentManager.findFragmentById(R.id.settings_nav_container) as NavHostFragment
navHostFragment.navController.navigate(
R.id.action_global_networkSettingsFragment,
null,
popupTo(R.id.networkSettingsFragment, true)
)
if (!slidingPane.isOpen) slidingPane.openPane()
}
}
internal fun SettingsFragment.navigateToContactsSettings(slidingPane: SlidingPaneLayout) {
if (findNavController().currentDestination?.id == R.id.settingsFragment) {
val navHostFragment =
childFragmentManager.findFragmentById(R.id.settings_nav_container) as NavHostFragment
navHostFragment.navController.navigate(
R.id.action_global_contactsSettingsFragment,
null,
popupTo(R.id.contactsSettingsFragment, true)
)
if (!slidingPane.isOpen) slidingPane.openPane()
}
}
internal fun SettingsFragment.navigateToAdvancedSettings(slidingPane: SlidingPaneLayout) {
if (findNavController().currentDestination?.id == R.id.settingsFragment) {
val navHostFragment =
childFragmentManager.findFragmentById(R.id.settings_nav_container) as NavHostFragment
navHostFragment.navController.navigate(
R.id.action_global_advancedSettingsFragment,
null,
popupTo(R.id.advancedSettingsFragment, true)
)
if (!slidingPane.isOpen) slidingPane.openPane()
}
}
internal fun AccountSettingsFragment.navigateToPhoneLinking(args: Bundle?) {
if (findNavController().currentDestination?.id == R.id.accountSettingsFragment) {
findNavController().navigate(
R.id.action_accountSettingsFragment_to_phoneAccountLinkingFragment,
args,
popupTo()
)
}
}
internal fun PhoneAccountLinkingFragment.navigateToPhoneAccountValidation(args: Bundle?) {
if (findNavController().currentDestination?.id == R.id.phoneAccountLinkingFragment) {
findNavController().navigate(
R.id.action_phoneAccountLinkingFragment_to_phoneAccountValidationFragment,
args,
popupTo()
)
}
}
internal fun navigateToEmptySetting(navController: NavController) {
navController.navigate(
R.id.action_global_emptySettingsFragment,
null,
popupTo(R.id.emptySettingsFragment, true)
)
}
internal fun AccountSettingsFragment.navigateToEmptySetting() {
navigateToEmptySetting(findNavController())
}
internal fun AdvancedSettingsFragment.navigateToEmptySetting() {
navigateToEmptySetting(findNavController())
}
internal fun AudioSettingsFragment.navigateToEmptySetting() {
navigateToEmptySetting(findNavController())
}
internal fun CallSettingsFragment.navigateToEmptySetting() {
navigateToEmptySetting(findNavController())
}
internal fun ChatSettingsFragment.navigateToEmptySetting() {
navigateToEmptySetting(findNavController())
}
internal fun ContactsSettingsFragment.navigateToEmptySetting() {
navigateToEmptySetting(findNavController())
}
internal fun NetworkSettingsFragment.navigateToEmptySetting() {
navigateToEmptySetting(findNavController())
}
internal fun TunnelSettingsFragment.navigateToEmptySetting() {
navigateToEmptySetting(findNavController())
}
internal fun VideoSettingsFragment.navigateToEmptySetting() {
navigateToEmptySetting(findNavController())
}
/* Side menu related */
internal fun SideMenuFragment.navigateToAccountSettings(identity: String) {
val deepLink = "linphone-android://settings/$identity"
findNavController().navigate(Uri.parse(deepLink))
}
internal fun SideMenuFragment.navigateToSettings() {
findNavController().navigate(
R.id.action_global_settingsFragment,
null,
popupTo(R.id.settingsFragment, true)
)
}
internal fun SideMenuFragment.navigateToAbout() {
findNavController().navigate(
R.id.action_global_aboutFragment,
null,
popupTo(R.id.aboutFragment, true)
)
}
internal fun SideMenuFragment.navigateToRecordings() {
findNavController().navigate(
R.id.action_global_recordingsFragment,
null,
popupTo(R.id.recordingsFragment, true)
)
}
/* Assistant related */
internal fun WelcomeFragment.navigateToEmailAccountCreation() {
if (findNavController().currentDestination?.id == R.id.welcomeFragment) {
findNavController().navigate(
R.id.action_welcomeFragment_to_emailAccountCreationFragment,
null,
popupTo()
)
}
}
internal fun WelcomeFragment.navigateToPhoneAccountCreation() {
if (findNavController().currentDestination?.id == R.id.welcomeFragment) {
findNavController().navigate(
R.id.action_welcomeFragment_to_phoneAccountCreationFragment,
null,
popupTo()
)
}
}
internal fun WelcomeFragment.navigateToAccountLogin() {
if (findNavController().currentDestination?.id == R.id.welcomeFragment) {
findNavController().navigate(
R.id.action_welcomeFragment_to_accountLoginFragment,
null,
popupTo()
)
}
}
internal fun WelcomeFragment.navigateToGenericLogin() {
if (findNavController().currentDestination?.id == R.id.welcomeFragment) {
findNavController().navigate(
R.id.action_welcomeFragment_to_genericAccountLoginFragment,
null,
popupTo()
)
}
}
internal fun WelcomeFragment.navigateToRemoteProvisioning() {
if (findNavController().currentDestination?.id == R.id.welcomeFragment) {
findNavController().navigate(
R.id.action_welcomeFragment_to_remoteProvisioningFragment,
null,
popupTo()
)
}
}
internal fun AccountLoginFragment.navigateToEchoCancellerCalibration() {
if (findNavController().currentDestination?.id == R.id.accountLoginFragment) {
findNavController().navigate(
R.id.action_accountLoginFragment_to_echoCancellerCalibrationFragment,
null,
popupTo()
)
}
}
internal fun AccountLoginFragment.navigateToPhoneAccountValidation(args: Bundle?) {
if (findNavController().currentDestination?.id == R.id.accountLoginFragment) {
findNavController().navigate(
R.id.action_accountLoginFragment_to_phoneAccountValidationFragment,
args,
popupTo()
)
}
}
internal fun GenericAccountLoginFragment.navigateToEchoCancellerCalibration() {
if (findNavController().currentDestination?.id == R.id.genericAccountLoginFragment) {
findNavController().navigate(
R.id.action_genericAccountLoginFragment_to_echoCancellerCalibrationFragment,
null,
popupTo()
)
}
}
internal fun RemoteProvisioningFragment.navigateToQrCode() {
if (findNavController().currentDestination?.id == R.id.remoteProvisioningFragment) {
findNavController().navigate(
R.id.action_remoteProvisioningFragment_to_qrCodeFragment,
null,
popupTo()
)
}
}
internal fun RemoteProvisioningFragment.navigateToEchoCancellerCalibration() {
if (findNavController().currentDestination?.id == R.id.remoteProvisioningFragment) {
findNavController().navigate(
R.id.action_remoteProvisioningFragment_to_echoCancellerCalibrationFragment,
null,
popupTo()
)
}
}
internal fun EmailAccountCreationFragment.navigateToEmailAccountValidation() {
if (findNavController().currentDestination?.id == R.id.emailAccountCreationFragment) {
findNavController().navigate(
R.id.action_emailAccountCreationFragment_to_emailAccountValidationFragment,
null,
popupTo()
)
}
}
internal fun EmailAccountValidationFragment.navigateToAccountLinking(args: Bundle?) {
if (findNavController().currentDestination?.id == R.id.emailAccountValidationFragment) {
findNavController().navigate(
R.id.action_emailAccountValidationFragment_to_phoneAccountLinkingFragment,
args,
popupTo()
)
}
}
internal fun PhoneAccountCreationFragment.navigateToPhoneAccountValidation(args: Bundle?) {
if (findNavController().currentDestination?.id == R.id.phoneAccountCreationFragment) {
findNavController().navigate(
R.id.action_phoneAccountCreationFragment_to_phoneAccountValidationFragment,
args,
popupTo()
)
}
}
internal fun PhoneAccountValidationFragment.navigateToAccountSettings(args: Bundle?) {
if (findNavController().currentDestination?.id == R.id.phoneAccountValidationFragment) {
findNavController().navigate(
R.id.action_phoneAccountValidationFragment_to_accountSettingsFragment,
args,
popupTo(R.id.accountSettingsFragment, true)
)
}
}
internal fun PhoneAccountValidationFragment.navigateToEchoCancellerCalibration() {
if (findNavController().currentDestination?.id == R.id.phoneAccountValidationFragment) {
findNavController().navigate(
R.id.action_phoneAccountValidationFragment_to_echoCancellerCalibrationFragment,
null,
popupTo()
)
}
}
internal fun PhoneAccountLinkingFragment.navigateToEchoCancellerCalibration() {
if (findNavController().currentDestination?.id == R.id.phoneAccountLinkingFragment) {
findNavController().navigate(
R.id.action_phoneAccountLinkingFragment_to_echoCancellerCalibrationFragment,
null,
popupTo()
)
}
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2010-2024 Belledonne Communications SARL.
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
@ -17,15 +17,9 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.ui.main.model
package org.linphone.activities
data class ShortcutModel(
val label: String,
val iconUrl: String,
val link: String,
private val lambda: (link: String) -> Unit
) {
fun clicked() {
lambda.invoke(link)
}
interface SnackBarActivity {
fun showSnackBar(resourceId: Int)
fun showSnackBar(message: String)
}

View file

@ -0,0 +1,52 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.assistant
import android.os.Bundle
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.lifecycle.ViewModelProvider
import com.google.android.material.snackbar.Snackbar
import org.linphone.R
import org.linphone.activities.GenericActivity
import org.linphone.activities.SnackBarActivity
import org.linphone.activities.assistant.viewmodels.SharedAssistantViewModel
class AssistantActivity : GenericActivity(), SnackBarActivity {
private lateinit var sharedViewModel: SharedAssistantViewModel
private lateinit var coordinator: CoordinatorLayout
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.assistant_activity)
sharedViewModel = ViewModelProvider(this)[SharedAssistantViewModel::class.java]
coordinator = findViewById(R.id.coordinator)
}
override fun showSnackBar(resourceId: Int) {
Snackbar.make(coordinator, resourceId, Snackbar.LENGTH_LONG).show()
}
override fun showSnackBar(message: String) {
Snackbar.make(coordinator, message, Snackbar.LENGTH_LONG).show()
}
}

View file

@ -0,0 +1,95 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.assistant.adapters
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.BaseAdapter
import android.widget.Filter
import android.widget.Filterable
import android.widget.TextView
import kotlin.collections.ArrayList
import org.linphone.R
import org.linphone.core.DialPlan
import org.linphone.core.Factory
class CountryPickerAdapter : BaseAdapter(), Filterable {
private var countries: ArrayList<DialPlan>
init {
val dialPlans = Factory.instance().dialPlans
countries = arrayListOf()
countries.addAll(dialPlans)
}
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val view: View = convertView ?: LayoutInflater.from(parent.context).inflate(R.layout.assistant_country_picker_cell, parent, false)
val dialPlan: DialPlan = countries[position]
val name = view.findViewById<TextView>(R.id.country_name)
name.text = dialPlan.country
val dialCode = view.findViewById<TextView>(R.id.country_prefix)
dialCode.text = String.format("(%s)", dialPlan.countryCallingCode)
view.tag = dialPlan
return view
}
override fun getItem(position: Int): DialPlan {
return countries[position]
}
override fun getItemId(position: Int): Long {
return position.toLong()
}
override fun getCount(): Int {
return countries.size
}
override fun getFilter(): Filter {
return object : Filter() {
override fun performFiltering(constraint: CharSequence): FilterResults {
val filteredCountries = arrayListOf<DialPlan>()
for (dialPlan in Factory.instance().dialPlans) {
if (dialPlan.country.contains(constraint, ignoreCase = true) ||
dialPlan.countryCallingCode.contains(constraint)
) {
filteredCountries.add(dialPlan)
}
}
val filterResults = FilterResults()
filterResults.values = filteredCountries
return filterResults
}
@Suppress("UNCHECKED_CAST")
override fun publishResults(
constraint: CharSequence,
results: FilterResults
) {
countries = results.values as ArrayList<DialPlan>
notifyDataSetChanged()
}
}
}
}

View file

@ -0,0 +1,81 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.assistant.fragments
import android.content.pm.PackageManager
import androidx.databinding.ViewDataBinding
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.linphone.R
import org.linphone.activities.GenericFragment
import org.linphone.activities.assistant.viewmodels.AbstractPhoneViewModel
import org.linphone.compatibility.Compatibility
import org.linphone.core.tools.Log
import org.linphone.utils.PermissionHelper
import org.linphone.utils.PhoneNumberUtils
abstract class AbstractPhoneFragment<T : ViewDataBinding> : GenericFragment<T>() {
abstract val viewModel: AbstractPhoneViewModel
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
if (requestCode == 0) {
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
Log.i("[Assistant] READ_PHONE_STATE/READ_PHONE_NUMBERS permission granted")
updateFromDeviceInfo()
} else {
Log.w("[Assistant] READ_PHONE_STATE/READ_PHONE_NUMBERS permission denied")
}
}
}
protected fun checkPermission() {
if (!resources.getBoolean(R.bool.isTablet)) {
if (!PermissionHelper.get().hasReadPhoneStateOrPhoneNumbersPermission()) {
Log.i("[Assistant] Asking for READ_PHONE_STATE/READ_PHONE_NUMBERS permission")
Compatibility.requestReadPhoneStateOrNumbersPermission(requireActivity(), 0)
} else {
updateFromDeviceInfo()
}
}
}
private fun updateFromDeviceInfo() {
val phoneNumber = PhoneNumberUtils.getDevicePhoneNumber(requireContext())
val dialPlan = PhoneNumberUtils.getDialPlanForCurrentCountry(requireContext())
viewModel.updateFromPhoneNumberAndOrDialPlan(phoneNumber, dialPlan)
}
protected fun showPhoneNumberInfoDialog() {
MaterialAlertDialogBuilder(requireContext())
.setTitle(getString(R.string.assistant_phone_number_info_title))
.setMessage(
getString(R.string.assistant_phone_number_link_info_content) + "\n" +
getString(
R.string.assistant_phone_number_link_info_content_already_account
)
)
.setNegativeButton(getString(R.string.dialog_ok), null)
.show()
}
}

View file

@ -0,0 +1,140 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.assistant.fragments
import android.app.Dialog
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.View
import androidx.lifecycle.ViewModelProvider
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.R
import org.linphone.activities.assistant.AssistantActivity
import org.linphone.activities.assistant.viewmodels.AccountLoginViewModel
import org.linphone.activities.assistant.viewmodels.AccountLoginViewModelFactory
import org.linphone.activities.assistant.viewmodels.SharedAssistantViewModel
import org.linphone.activities.main.viewmodels.DialogViewModel
import org.linphone.activities.navigateToEchoCancellerCalibration
import org.linphone.activities.navigateToPhoneAccountValidation
import org.linphone.databinding.AssistantAccountLoginFragmentBinding
import org.linphone.utils.DialogUtils
class AccountLoginFragment : AbstractPhoneFragment<AssistantAccountLoginFragmentBinding>() {
override lateinit var viewModel: AccountLoginViewModel
private lateinit var sharedViewModel: SharedAssistantViewModel
override fun getLayoutId(): Int = R.layout.assistant_account_login_fragment
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.lifecycleOwner = viewLifecycleOwner
sharedViewModel = requireActivity().run {
ViewModelProvider(this)[SharedAssistantViewModel::class.java]
}
viewModel = ViewModelProvider(this, AccountLoginViewModelFactory(sharedViewModel.getAccountCreator()))[AccountLoginViewModel::class.java]
binding.viewModel = viewModel
if (resources.getBoolean(R.bool.isTablet)) {
viewModel.loginWithUsernamePassword.value = true
}
binding.setInfoClickListener {
showPhoneNumberInfoDialog()
}
binding.setSelectCountryClickListener {
CountryPickerFragment(viewModel).show(childFragmentManager, "CountryPicker")
}
binding.setForgotPasswordClickListener {
val intent = Intent(Intent.ACTION_VIEW)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
intent.data = Uri.parse(getString(R.string.assistant_forgotten_password_link))
startActivity(intent)
}
viewModel.goToSmsValidationEvent.observe(
viewLifecycleOwner,
{
it.consume {
val args = Bundle()
args.putBoolean("IsLogin", true)
args.putString("PhoneNumber", viewModel.accountCreator.phoneNumber)
navigateToPhoneAccountValidation(args)
}
}
)
viewModel.leaveAssistantEvent.observe(
viewLifecycleOwner,
{
it.consume {
coreContext.contactsManager.updateLocalContacts()
if (coreContext.core.isEchoCancellerCalibrationRequired) {
navigateToEchoCancellerCalibration()
} else {
requireActivity().finish()
}
}
}
)
viewModel.invalidCredentialsEvent.observe(
viewLifecycleOwner,
{
it.consume {
val dialogViewModel = DialogViewModel(getString(R.string.assistant_error_invalid_credentials))
val dialog: Dialog = DialogUtils.getDialog(requireContext(), dialogViewModel)
dialogViewModel.showCancelButton {
viewModel.removeInvalidProxyConfig()
dialog.dismiss()
}
dialogViewModel.showDeleteButton(
{
viewModel.continueEvenIfInvalidCredentials()
dialog.dismiss()
},
getString(R.string.assistant_continue_even_if_credentials_invalid)
)
dialog.show()
}
}
)
viewModel.onErrorEvent.observe(
viewLifecycleOwner,
{
it.consume { message ->
(requireActivity() as AssistantActivity).showSnackBar(message)
}
}
)
checkPermission()
}
}

View file

@ -0,0 +1,85 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.assistant.fragments
import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import android.view.*
import androidx.fragment.app.DialogFragment
import org.linphone.R
import org.linphone.activities.assistant.adapters.CountryPickerAdapter
import org.linphone.core.DialPlan
import org.linphone.databinding.AssistantCountryPickerFragmentBinding
class CountryPickerFragment(private val listener: CountryPickedListener) : DialogFragment() {
private var _binding: AssistantCountryPickerFragmentBinding? = null
private val binding get() = _binding!!
private lateinit var adapter: CountryPickerAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NO_TITLE, R.style.assistant_country_dialog_style)
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = AssistantCountryPickerFragmentBinding.inflate(inflater, container, false)
adapter = CountryPickerAdapter()
binding.countryList.adapter = adapter
binding.countryList.setOnItemClickListener { _, _, position, _ ->
if (position >= 0 && position < adapter.count) {
val dialPlan = adapter.getItem(position)
listener.onCountryClicked(dialPlan)
}
dismiss()
}
binding.searchCountry.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(s: Editable?) {
adapter.filter.filter(s)
}
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) { }
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { }
})
binding.setCancelClickListener {
dismiss()
}
return binding.root
}
interface CountryPickedListener {
fun onCountryClicked(dialPlan: DialPlan)
}
}

View file

@ -0,0 +1,77 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.assistant.fragments
import android.content.pm.PackageManager
import android.os.Bundle
import android.view.View
import androidx.lifecycle.ViewModelProvider
import org.linphone.R
import org.linphone.activities.GenericFragment
import org.linphone.activities.assistant.viewmodels.EchoCancellerCalibrationViewModel
import org.linphone.core.tools.Log
import org.linphone.databinding.AssistantEchoCancellerCalibrationFragmentBinding
import org.linphone.utils.PermissionHelper
class EchoCancellerCalibrationFragment : GenericFragment<AssistantEchoCancellerCalibrationFragmentBinding>() {
private lateinit var viewModel: EchoCancellerCalibrationViewModel
override fun getLayoutId(): Int = R.layout.assistant_echo_canceller_calibration_fragment
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.lifecycleOwner = viewLifecycleOwner
viewModel = ViewModelProvider(this)[EchoCancellerCalibrationViewModel::class.java]
binding.viewModel = viewModel
viewModel.echoCalibrationTerminated.observe(
viewLifecycleOwner,
{
it.consume {
requireActivity().finish()
}
}
)
if (!PermissionHelper.required(requireContext()).hasRecordAudioPermission()) {
Log.i("[Echo Canceller Calibration] Asking for RECORD_AUDIO permission")
requestPermissions(arrayOf(android.Manifest.permission.RECORD_AUDIO), 0)
} else {
viewModel.startEchoCancellerCalibration()
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
val granted = grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED
if (granted) {
Log.i("[Echo Canceller Calibration] RECORD_AUDIO permission granted")
viewModel.startEchoCancellerCalibration()
} else {
Log.w("[Echo Canceller Calibration] RECORD_AUDIO permission denied")
requireActivity().finish()
}
}
}

View file

@ -0,0 +1,70 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.assistant.fragments
import android.os.Bundle
import android.view.View
import androidx.lifecycle.ViewModelProvider
import org.linphone.R
import org.linphone.activities.GenericFragment
import org.linphone.activities.assistant.AssistantActivity
import org.linphone.activities.assistant.viewmodels.EmailAccountCreationViewModel
import org.linphone.activities.assistant.viewmodels.EmailAccountCreationViewModelFactory
import org.linphone.activities.assistant.viewmodels.SharedAssistantViewModel
import org.linphone.activities.navigateToEmailAccountValidation
import org.linphone.databinding.AssistantEmailAccountCreationFragmentBinding
class EmailAccountCreationFragment : GenericFragment<AssistantEmailAccountCreationFragmentBinding>() {
private lateinit var sharedViewModel: SharedAssistantViewModel
private lateinit var viewModel: EmailAccountCreationViewModel
override fun getLayoutId(): Int = R.layout.assistant_email_account_creation_fragment
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.lifecycleOwner = viewLifecycleOwner
sharedViewModel = requireActivity().run {
ViewModelProvider(this)[SharedAssistantViewModel::class.java]
}
viewModel = ViewModelProvider(this, EmailAccountCreationViewModelFactory(sharedViewModel.getAccountCreator()))[EmailAccountCreationViewModel::class.java]
binding.viewModel = viewModel
viewModel.goToEmailValidationEvent.observe(
viewLifecycleOwner,
{
it.consume {
navigateToEmailAccountValidation()
}
}
)
viewModel.onErrorEvent.observe(
viewLifecycleOwner,
{
it.consume { message ->
(requireActivity() as AssistantActivity).showSnackBar(message)
}
}
)
}
}

View file

@ -0,0 +1,75 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.assistant.fragments
import android.os.Bundle
import android.view.View
import androidx.lifecycle.ViewModelProvider
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.R
import org.linphone.activities.GenericFragment
import org.linphone.activities.assistant.AssistantActivity
import org.linphone.activities.assistant.viewmodels.*
import org.linphone.activities.navigateToAccountLinking
import org.linphone.databinding.AssistantEmailAccountValidationFragmentBinding
class EmailAccountValidationFragment : GenericFragment<AssistantEmailAccountValidationFragmentBinding>() {
private lateinit var sharedViewModel: SharedAssistantViewModel
private lateinit var viewModel: EmailAccountValidationViewModel
override fun getLayoutId(): Int = R.layout.assistant_email_account_validation_fragment
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.lifecycleOwner = viewLifecycleOwner
sharedViewModel = requireActivity().run {
ViewModelProvider(this)[SharedAssistantViewModel::class.java]
}
viewModel = ViewModelProvider(this, EmailAccountValidationViewModelFactory(sharedViewModel.getAccountCreator()))[EmailAccountValidationViewModel::class.java]
binding.viewModel = viewModel
viewModel.leaveAssistantEvent.observe(
viewLifecycleOwner,
{
it.consume {
coreContext.contactsManager.updateLocalContacts()
val args = Bundle()
args.putBoolean("AllowSkip", true)
args.putString("Username", viewModel.accountCreator.username)
args.putString("Password", viewModel.accountCreator.password)
navigateToAccountLinking(args)
}
}
)
viewModel.onErrorEvent.observe(
viewLifecycleOwner,
{
it.consume { message ->
(requireActivity() as AssistantActivity).showSnackBar(message)
}
}
)
}
}

View file

@ -0,0 +1,105 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.assistant.fragments
import android.app.Dialog
import android.os.Bundle
import android.view.View
import androidx.lifecycle.ViewModelProvider
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.R
import org.linphone.activities.GenericFragment
import org.linphone.activities.assistant.AssistantActivity
import org.linphone.activities.assistant.viewmodels.GenericLoginViewModel
import org.linphone.activities.assistant.viewmodels.GenericLoginViewModelFactory
import org.linphone.activities.assistant.viewmodels.SharedAssistantViewModel
import org.linphone.activities.main.viewmodels.DialogViewModel
import org.linphone.activities.navigateToEchoCancellerCalibration
import org.linphone.databinding.AssistantGenericAccountLoginFragmentBinding
import org.linphone.utils.DialogUtils
class GenericAccountLoginFragment : GenericFragment<AssistantGenericAccountLoginFragmentBinding>() {
private lateinit var sharedViewModel: SharedAssistantViewModel
private lateinit var viewModel: GenericLoginViewModel
override fun getLayoutId(): Int = R.layout.assistant_generic_account_login_fragment
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.lifecycleOwner = viewLifecycleOwner
sharedViewModel = requireActivity().run {
ViewModelProvider(this)[SharedAssistantViewModel::class.java]
}
viewModel = ViewModelProvider(this, GenericLoginViewModelFactory(sharedViewModel.getAccountCreator(true)))[GenericLoginViewModel::class.java]
binding.viewModel = viewModel
viewModel.leaveAssistantEvent.observe(
viewLifecycleOwner,
{
it.consume {
coreContext.contactsManager.updateLocalContacts()
if (coreContext.core.isEchoCancellerCalibrationRequired) {
navigateToEchoCancellerCalibration()
} else {
requireActivity().finish()
}
}
}
)
viewModel.invalidCredentialsEvent.observe(
viewLifecycleOwner,
{
it.consume {
val dialogViewModel = DialogViewModel(getString(R.string.assistant_error_invalid_credentials))
val dialog: Dialog = DialogUtils.getDialog(requireContext(), dialogViewModel)
dialogViewModel.showCancelButton {
viewModel.removeInvalidProxyConfig()
dialog.dismiss()
}
dialogViewModel.showDeleteButton(
{
viewModel.continueEvenIfInvalidCredentials()
dialog.dismiss()
},
getString(R.string.assistant_continue_even_if_credentials_invalid)
)
dialog.show()
}
}
)
viewModel.onErrorEvent.observe(
viewLifecycleOwner,
{
it.consume { message ->
(requireActivity() as AssistantActivity).showSnackBar(message)
}
}
)
}
}

View file

@ -0,0 +1,82 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.assistant.fragments
import android.os.Bundle
import android.view.View
import androidx.lifecycle.ViewModelProvider
import org.linphone.R
import org.linphone.activities.assistant.AssistantActivity
import org.linphone.activities.assistant.viewmodels.PhoneAccountCreationViewModel
import org.linphone.activities.assistant.viewmodels.PhoneAccountCreationViewModelFactory
import org.linphone.activities.assistant.viewmodels.SharedAssistantViewModel
import org.linphone.activities.navigateToPhoneAccountValidation
import org.linphone.databinding.AssistantPhoneAccountCreationFragmentBinding
class PhoneAccountCreationFragment : AbstractPhoneFragment<AssistantPhoneAccountCreationFragmentBinding>() {
private lateinit var sharedViewModel: SharedAssistantViewModel
override lateinit var viewModel: PhoneAccountCreationViewModel
override fun getLayoutId(): Int = R.layout.assistant_phone_account_creation_fragment
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.lifecycleOwner = viewLifecycleOwner
sharedViewModel = requireActivity().run {
ViewModelProvider(this)[SharedAssistantViewModel::class.java]
}
viewModel = ViewModelProvider(this, PhoneAccountCreationViewModelFactory(sharedViewModel.getAccountCreator()))[PhoneAccountCreationViewModel::class.java]
binding.viewModel = viewModel
binding.setInfoClickListener {
showPhoneNumberInfoDialog()
}
binding.setSelectCountryClickListener {
CountryPickerFragment(viewModel).show(childFragmentManager, "CountryPicker")
}
viewModel.goToSmsValidationEvent.observe(
viewLifecycleOwner,
{
it.consume {
val args = Bundle()
args.putBoolean("IsCreation", true)
args.putString("PhoneNumber", viewModel.accountCreator.phoneNumber)
navigateToPhoneAccountValidation(args)
}
}
)
viewModel.onErrorEvent.observe(
viewLifecycleOwner,
{
it.consume { message ->
(requireActivity() as AssistantActivity).showSnackBar(message)
}
}
)
checkPermission()
}
}

View file

@ -0,0 +1,110 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.assistant.fragments
import android.os.Bundle
import android.view.View
import androidx.lifecycle.ViewModelProvider
import org.linphone.LinphoneApplication
import org.linphone.R
import org.linphone.activities.assistant.AssistantActivity
import org.linphone.activities.assistant.viewmodels.*
import org.linphone.activities.navigateToEchoCancellerCalibration
import org.linphone.activities.navigateToPhoneAccountValidation
import org.linphone.core.tools.Log
import org.linphone.databinding.AssistantPhoneAccountLinkingFragmentBinding
class PhoneAccountLinkingFragment : AbstractPhoneFragment<AssistantPhoneAccountLinkingFragmentBinding>() {
private lateinit var sharedViewModel: SharedAssistantViewModel
override lateinit var viewModel: PhoneAccountLinkingViewModel
override fun getLayoutId(): Int = R.layout.assistant_phone_account_linking_fragment
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.lifecycleOwner = viewLifecycleOwner
sharedViewModel = requireActivity().run {
ViewModelProvider(this)[SharedAssistantViewModel::class.java]
}
val accountCreator = sharedViewModel.getAccountCreator()
viewModel = ViewModelProvider(this, PhoneAccountLinkingViewModelFactory(accountCreator))[PhoneAccountLinkingViewModel::class.java]
binding.viewModel = viewModel
val username = arguments?.getString("Username")
Log.i("[Phone Account Linking] username to link is $username")
viewModel.username.value = username
val password = arguments?.getString("Password")
accountCreator.password = password
val ha1 = arguments?.getString("HA1")
accountCreator.ha1 = ha1
val allowSkip = arguments?.getBoolean("AllowSkip", false)
viewModel.allowSkip.value = allowSkip
binding.setInfoClickListener {
showPhoneNumberInfoDialog()
}
binding.setSelectCountryClickListener {
CountryPickerFragment(viewModel).show(childFragmentManager, "CountryPicker")
}
viewModel.goToSmsValidationEvent.observe(
viewLifecycleOwner,
{
it.consume {
val args = Bundle()
args.putBoolean("IsLinking", true)
args.putString("PhoneNumber", viewModel.accountCreator.phoneNumber)
navigateToPhoneAccountValidation(args)
}
}
)
viewModel.leaveAssistantEvent.observe(
viewLifecycleOwner,
{
it.consume {
if (LinphoneApplication.coreContext.core.isEchoCancellerCalibrationRequired) {
navigateToEchoCancellerCalibration()
} else {
requireActivity().finish()
}
}
}
)
viewModel.onErrorEvent.observe(
viewLifecycleOwner,
{
it.consume { message ->
(requireActivity() as AssistantActivity).showSnackBar(message)
}
}
)
checkPermission()
}
}

View file

@ -0,0 +1,105 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.assistant.fragments
import android.content.ClipboardManager
import android.content.Context.CLIPBOARD_SERVICE
import android.os.Bundle
import android.view.View
import androidx.lifecycle.ViewModelProvider
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.R
import org.linphone.activities.GenericFragment
import org.linphone.activities.assistant.AssistantActivity
import org.linphone.activities.assistant.viewmodels.PhoneAccountValidationViewModel
import org.linphone.activities.assistant.viewmodels.PhoneAccountValidationViewModelFactory
import org.linphone.activities.assistant.viewmodels.SharedAssistantViewModel
import org.linphone.activities.navigateToAccountSettings
import org.linphone.activities.navigateToEchoCancellerCalibration
import org.linphone.databinding.AssistantPhoneAccountValidationFragmentBinding
class PhoneAccountValidationFragment : GenericFragment<AssistantPhoneAccountValidationFragmentBinding>() {
private lateinit var sharedViewModel: SharedAssistantViewModel
private lateinit var viewModel: PhoneAccountValidationViewModel
override fun getLayoutId(): Int = R.layout.assistant_phone_account_validation_fragment
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.lifecycleOwner = viewLifecycleOwner
sharedViewModel = requireActivity().run {
ViewModelProvider(this)[SharedAssistantViewModel::class.java]
}
viewModel = ViewModelProvider(this, PhoneAccountValidationViewModelFactory(sharedViewModel.getAccountCreator()))[PhoneAccountValidationViewModel::class.java]
binding.viewModel = viewModel
viewModel.phoneNumber.value = arguments?.getString("PhoneNumber")
viewModel.isLogin.value = arguments?.getBoolean("IsLogin", false)
viewModel.isCreation.value = arguments?.getBoolean("IsCreation", false)
viewModel.isLinking.value = arguments?.getBoolean("IsLinking", false)
viewModel.leaveAssistantEvent.observe(
viewLifecycleOwner,
{
it.consume {
when {
viewModel.isLogin.value == true || viewModel.isCreation.value == true -> {
coreContext.contactsManager.updateLocalContacts()
if (coreContext.core.isEchoCancellerCalibrationRequired) {
navigateToEchoCancellerCalibration()
} else {
requireActivity().finish()
}
}
viewModel.isLinking.value == true -> {
val args = Bundle()
args.putString("Identity", "sip:${viewModel.accountCreator.username}@${viewModel.accountCreator.domain}")
navigateToAccountSettings(args)
}
}
}
}
)
viewModel.onErrorEvent.observe(
viewLifecycleOwner,
{
it.consume { message ->
(requireActivity() as AssistantActivity).showSnackBar(message)
}
}
)
val clipboard = requireContext().getSystemService(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) {
viewModel.code.value = clip
}
}
}
}
}

View file

@ -0,0 +1,102 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.assistant.fragments
import android.content.pm.PackageManager
import android.os.Bundle
import android.view.View
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.fragment.findNavController
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.R
import org.linphone.activities.GenericFragment
import org.linphone.activities.assistant.viewmodels.QrCodeViewModel
import org.linphone.activities.assistant.viewmodels.SharedAssistantViewModel
import org.linphone.core.tools.Log
import org.linphone.databinding.AssistantQrCodeFragmentBinding
import org.linphone.utils.PermissionHelper
class QrCodeFragment : GenericFragment<AssistantQrCodeFragmentBinding>() {
private lateinit var sharedViewModel: SharedAssistantViewModel
private lateinit var viewModel: QrCodeViewModel
override fun getLayoutId(): Int = R.layout.assistant_qr_code_fragment
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.lifecycleOwner = viewLifecycleOwner
sharedViewModel = requireActivity().run {
ViewModelProvider(this)[SharedAssistantViewModel::class.java]
}
viewModel = ViewModelProvider(this)[QrCodeViewModel::class.java]
binding.viewModel = viewModel
viewModel.qrCodeFoundEvent.observe(
viewLifecycleOwner,
{
it.consume { url ->
sharedViewModel.remoteProvisioningUrl.value = url
findNavController().navigateUp()
}
}
)
viewModel.setBackCamera()
if (!PermissionHelper.required(requireContext()).hasCameraPermission()) {
Log.i("[QR Code] Asking for CAMERA permission")
requestPermissions(arrayOf(android.Manifest.permission.CAMERA), 0)
}
}
override fun onResume() {
super.onResume()
coreContext.core.nativePreviewWindowId = binding.qrCodeCaptureTexture
coreContext.core.enableQrcodeVideoPreview(true)
coreContext.core.enableVideoPreview(true)
}
override fun onPause() {
coreContext.core.nativePreviewWindowId = null
coreContext.core.enableQrcodeVideoPreview(false)
coreContext.core.enableVideoPreview(false)
super.onPause()
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
val granted = grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED
if (granted) {
Log.i("[QR Code] CAMERA permission granted")
coreContext.core.reloadVideoDevices()
viewModel.setBackCamera()
} else {
Log.w("[QR Code] CAMERA permission denied")
findNavController().navigateUp()
}
}
}

View file

@ -0,0 +1,82 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.assistant.fragments
import android.os.Bundle
import android.view.View
import androidx.lifecycle.ViewModelProvider
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.R
import org.linphone.activities.GenericFragment
import org.linphone.activities.assistant.AssistantActivity
import org.linphone.activities.assistant.viewmodels.RemoteProvisioningViewModel
import org.linphone.activities.assistant.viewmodels.SharedAssistantViewModel
import org.linphone.activities.navigateToEchoCancellerCalibration
import org.linphone.activities.navigateToQrCode
import org.linphone.databinding.AssistantRemoteProvisioningFragmentBinding
class RemoteProvisioningFragment : GenericFragment<AssistantRemoteProvisioningFragmentBinding>() {
private lateinit var sharedViewModel: SharedAssistantViewModel
private lateinit var viewModel: RemoteProvisioningViewModel
override fun getLayoutId(): Int = R.layout.assistant_remote_provisioning_fragment
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.lifecycleOwner = viewLifecycleOwner
sharedViewModel = requireActivity().run {
ViewModelProvider(this)[SharedAssistantViewModel::class.java]
}
viewModel = ViewModelProvider(this)[RemoteProvisioningViewModel::class.java]
binding.viewModel = viewModel
binding.setQrCodeClickListener {
navigateToQrCode()
}
viewModel.fetchSuccessfulEvent.observe(
viewLifecycleOwner,
{
it.consume { success ->
if (success) {
if (coreContext.core.isEchoCancellerCalibrationRequired) {
navigateToEchoCancellerCalibration()
} else {
requireActivity().finish()
}
} else {
val activity = requireActivity() as AssistantActivity
activity.showSnackBar(R.string.assistant_remote_provisioning_failure)
}
}
}
)
viewModel.urlToFetch.value = sharedViewModel.remoteProvisioningUrl.value ?: coreContext.core.provisioningUri
}
override fun onDestroy() {
super.onDestroy()
sharedViewModel.remoteProvisioningUrl.value = null
}
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2010-2024 Belledonne Communications SARL.
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
@ -17,28 +17,32 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.ui.main.history.fragment
package org.linphone.activities.assistant.fragments
import android.os.Bundle
import android.view.View
import androidx.navigation.fragment.findNavController
import org.linphone.core.tools.Log
import org.linphone.ui.main.chat.fragment.ConversationFragment
import org.linphone.R
import org.linphone.activities.GenericFragment
import org.linphone.databinding.AssistantTopBarFragmentBinding
class ConferenceConversationFragment : ConversationFragment() {
companion object {
private const val TAG = "[Conference Conversation Fragment]"
}
class TopBarFragment : GenericFragment<AssistantTopBarFragmentBinding>() {
override fun getLayoutId(): Int = R.layout.assistant_top_bar_fragment
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
Log.i("$TAG Creating a conference history ConversationFragment")
sendMessageViewModel.isCallConversation.value = true
viewModel.isCallConversation.value = true
binding.lifecycleOwner = viewLifecycleOwner
useMaterialSharedAxisXForwardAnimation = false
binding.setBackClickListener {
findNavController().popBackStack()
goBack()
}
}
override fun goBack() {
if (!findNavController().popBackStack()) {
requireActivity().finish()
}
}
}

View file

@ -0,0 +1,127 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.assistant.fragments
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.text.SpannableString
import android.text.Spanned
import android.text.method.LinkMovementMethod
import android.text.style.ClickableSpan
import android.view.View
import androidx.lifecycle.ViewModelProvider
import java.util.regex.Pattern
import org.linphone.LinphoneApplication.Companion.corePreferences
import org.linphone.R
import org.linphone.activities.*
import org.linphone.activities.assistant.viewmodels.WelcomeViewModel
import org.linphone.activities.navigateToAccountLogin
import org.linphone.activities.navigateToEmailAccountCreation
import org.linphone.activities.navigateToGenericLogin
import org.linphone.activities.navigateToRemoteProvisioning
import org.linphone.databinding.AssistantWelcomeFragmentBinding
class WelcomeFragment : GenericFragment<AssistantWelcomeFragmentBinding>() {
private lateinit var viewModel: WelcomeViewModel
override fun getLayoutId(): Int = R.layout.assistant_welcome_fragment
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.lifecycleOwner = viewLifecycleOwner
viewModel = ViewModelProvider(this)[WelcomeViewModel::class.java]
binding.viewModel = viewModel
binding.setCreateAccountClickListener {
if (resources.getBoolean(R.bool.isTablet)) {
navigateToEmailAccountCreation()
} else {
navigateToPhoneAccountCreation()
}
}
binding.setAccountLoginClickListener {
navigateToAccountLogin()
}
binding.setGenericAccountLoginClickListener {
navigateToGenericLogin()
}
binding.setRemoteProvisioningClickListener {
navigateToRemoteProvisioning()
}
viewModel.termsAndPrivacyAccepted.observe(
viewLifecycleOwner,
{
if (it) corePreferences.readAndAgreeTermsAndPrivacy = true
}
)
setUpTermsAndPrivacyLinks()
}
private fun setUpTermsAndPrivacyLinks() {
val terms = getString(R.string.assistant_general_terms)
val privacy = getString(R.string.assistant_privacy_policy)
val label = getString(
R.string.assistant_read_and_agree_terms,
terms,
privacy
)
val spannable = SpannableString(label)
val termsMatcher = Pattern.compile(terms).matcher(label)
if (termsMatcher.find()) {
val clickableSpan: ClickableSpan = object : ClickableSpan() {
override fun onClick(widget: View) {
val browserIntent = Intent(
Intent.ACTION_VIEW,
Uri.parse(getString(R.string.assistant_general_terms_link))
)
startActivity(browserIntent)
}
}
spannable.setSpan(clickableSpan, termsMatcher.start(0), termsMatcher.end(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
val policyMatcher = Pattern.compile(privacy).matcher(label)
if (policyMatcher.find()) {
val clickableSpan: ClickableSpan = object : ClickableSpan() {
override fun onClick(widget: View) {
val browserIntent = Intent(
Intent.ACTION_VIEW,
Uri.parse(getString(R.string.assistant_privacy_policy_link))
)
startActivity(browserIntent)
}
}
spannable.setSpan(clickableSpan, policyMatcher.start(0), policyMatcher.end(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
binding.termsAndPrivacy.text = spannable
binding.termsAndPrivacy.movementMethod = LinkMovementMethod.getInstance()
}
}

View file

@ -0,0 +1,82 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.assistant.viewmodels
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations
import androidx.lifecycle.ViewModel
import org.linphone.activities.assistant.fragments.CountryPickerFragment
import org.linphone.core.AccountCreator
import org.linphone.core.DialPlan
import org.linphone.core.tools.Log
import org.linphone.utils.PhoneNumberUtils
abstract class AbstractPhoneViewModel(val accountCreator: AccountCreator) :
ViewModel(),
CountryPickerFragment.CountryPickedListener {
val prefix = MutableLiveData<String>()
val phoneNumber = MutableLiveData<String>()
val phoneNumberError = MutableLiveData<String>()
val countryName: LiveData<String> = Transformations.switchMap(prefix) {
getCountryNameFromPrefix(it)
}
init {
prefix.value = "+"
}
override fun onCountryClicked(dialPlan: DialPlan) {
prefix.value = "+${dialPlan.countryCallingCode}"
}
fun isPhoneNumberOk(): Boolean {
return countryName.value.orEmpty().isNotEmpty() && phoneNumber.value.orEmpty().isNotEmpty() && phoneNumberError.value.orEmpty().isEmpty()
}
fun updateFromPhoneNumberAndOrDialPlan(number: String?, dialPlan: DialPlan?) {
if (dialPlan != null) {
Log.i("[Assistant] Found prefix from dial plan: ${dialPlan.countryCallingCode}")
prefix.value = "+${dialPlan.countryCallingCode}"
}
if (number != null) {
Log.i("[Assistant] Found phone number: $number")
phoneNumber.value = number!!
}
}
private fun getCountryNameFromPrefix(prefix: String?): MutableLiveData<String> {
val country = MutableLiveData<String>()
country.value = ""
if (prefix != null && prefix.isNotEmpty()) {
val countryCode = if (prefix.first() == '+') prefix.substring(1) else prefix
val dialPlan = PhoneNumberUtils.getDialPlanFromCountryCallingPrefix(countryCode)
Log.i("[Assistant] Found dial plan $dialPlan from country code: $countryCode")
country.value = dialPlan?.country
}
return country
}
}

View file

@ -0,0 +1,226 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.assistant.viewmodels
import androidx.lifecycle.*
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.R
import org.linphone.core.*
import org.linphone.core.tools.Log
import org.linphone.utils.Event
class AccountLoginViewModelFactory(private val accountCreator: AccountCreator) :
ViewModelProvider.NewInstanceFactory() {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return AccountLoginViewModel(accountCreator) as T
}
}
class AccountLoginViewModel(accountCreator: AccountCreator) : AbstractPhoneViewModel(accountCreator) {
val loginWithUsernamePassword = MutableLiveData<Boolean>()
val username = MutableLiveData<String>()
val usernameError = MutableLiveData<String>()
val password = MutableLiveData<String>()
val passwordError = MutableLiveData<String>()
val loginEnabled: MediatorLiveData<Boolean> = MediatorLiveData()
val waitForServerAnswer = MutableLiveData<Boolean>()
val leaveAssistantEvent: MutableLiveData<Event<Boolean>> by lazy {
MutableLiveData<Event<Boolean>>()
}
val invalidCredentialsEvent: MutableLiveData<Event<Boolean>> by lazy {
MutableLiveData<Event<Boolean>>()
}
val goToSmsValidationEvent: MutableLiveData<Event<Boolean>> by lazy {
MutableLiveData<Event<Boolean>>()
}
val onErrorEvent: MutableLiveData<Event<String>> by lazy {
MutableLiveData<Event<String>>()
}
private val listener = object : AccountCreatorListenerStub() {
override fun onRecoverAccount(
creator: AccountCreator,
status: AccountCreator.Status,
response: String?
) {
Log.i("[Assistant] [Account Login] Recover account status is $status")
waitForServerAnswer.value = false
if (status == AccountCreator.Status.RequestOk) {
goToSmsValidationEvent.value = Event(true)
} else {
onErrorEvent.value = Event("Error: ${status.name}")
}
}
}
private var proxyConfigToCheck: ProxyConfig? = null
private val coreListener = object : CoreListenerStub() {
override fun onRegistrationStateChanged(
core: Core,
cfg: ProxyConfig,
state: RegistrationState,
message: String
) {
if (cfg == proxyConfigToCheck) {
Log.i("[Assistant] [Account Login] Registration state is $state: $message")
if (state == RegistrationState.Ok) {
waitForServerAnswer.value = false
leaveAssistantEvent.value = Event(true)
core.removeListener(this)
} else if (state == RegistrationState.Failed) {
waitForServerAnswer.value = false
invalidCredentialsEvent.value = Event(true)
core.removeListener(this)
}
}
}
}
init {
accountCreator.addListener(listener)
loginWithUsernamePassword.value = coreContext.context.resources.getBoolean(R.bool.isTablet)
loginEnabled.value = false
loginEnabled.addSource(prefix) {
loginEnabled.value = isLoginButtonEnabled()
}
loginEnabled.addSource(phoneNumber) {
loginEnabled.value = isLoginButtonEnabled()
}
loginEnabled.addSource(username) {
loginEnabled.value = isLoginButtonEnabled()
}
loginEnabled.addSource(password) {
loginEnabled.value = isLoginButtonEnabled()
}
loginEnabled.addSource(loginWithUsernamePassword) {
loginEnabled.value = isLoginButtonEnabled()
}
loginEnabled.addSource(phoneNumberError) {
loginEnabled.value = isLoginButtonEnabled()
}
}
override fun onCleared() {
accountCreator.removeListener(listener)
super.onCleared()
}
fun removeInvalidProxyConfig() {
val cfg = proxyConfigToCheck
cfg ?: return
val authInfo = cfg.findAuthInfo()
if (authInfo != null) coreContext.core.removeAuthInfo(authInfo)
coreContext.core.removeProxyConfig(cfg)
proxyConfigToCheck = null
}
fun continueEvenIfInvalidCredentials() {
leaveAssistantEvent.value = Event(true)
}
fun login() {
if (loginWithUsernamePassword.value == true) {
val result = accountCreator.setUsername(username.value)
if (result != AccountCreator.UsernameStatus.Ok) {
Log.e("[Assistant] [Account Login] Error [${result.name}] setting the username: ${username.value}")
usernameError.value = result.name
return
}
Log.i("[Assistant] [Account Login] Username is ${accountCreator.username}")
val result2 = accountCreator.setPassword(password.value)
if (result2 != AccountCreator.PasswordStatus.Ok) {
Log.e("[Assistant] [Account Login] Error [${result2.name}] setting the password")
passwordError.value = result2.name
return
}
waitForServerAnswer.value = true
coreContext.core.addListener(coreListener)
if (!createProxyConfig()) {
waitForServerAnswer.value = false
coreContext.core.removeListener(coreListener)
onErrorEvent.value = Event("Error: Failed to create account object")
}
} else {
val result = AccountCreator.PhoneNumberStatus.fromInt(accountCreator.setPhoneNumber(phoneNumber.value, prefix.value))
if (result != AccountCreator.PhoneNumberStatus.Ok) {
Log.e("[Assistant] [Account Login] Error [$result] setting the phone number: ${phoneNumber.value} with prefix: ${prefix.value}")
phoneNumberError.value = result.name
return
}
Log.i("[Assistant] [Account Login] Phone number is ${accountCreator.phoneNumber}")
val result2 = accountCreator.setUsername(accountCreator.phoneNumber)
if (result2 != AccountCreator.UsernameStatus.Ok) {
Log.e("[Assistant] [Account Login] Error [${result2.name}] setting the username: ${accountCreator.phoneNumber}")
usernameError.value = result2.name
return
}
Log.i("[Assistant] [Account Login] Username is ${accountCreator.username}")
waitForServerAnswer.value = true
val status = accountCreator.recoverAccount()
Log.i("[Assistant] [Account Login] Recover account returned $status")
if (status != AccountCreator.Status.RequestOk) {
waitForServerAnswer.value = false
onErrorEvent.value = Event("Error: ${status.name}")
}
}
}
private fun isLoginButtonEnabled(): Boolean {
return if (loginWithUsernamePassword.value == true) {
username.value.orEmpty().isNotEmpty() && password.value.orEmpty().isNotEmpty()
} else {
isPhoneNumberOk()
}
}
private fun createProxyConfig(): Boolean {
val proxyConfig: ProxyConfig? = accountCreator.createProxyConfig()
proxyConfigToCheck = proxyConfig
if (proxyConfig == null) {
Log.e("[Assistant] [Account Login] Account creator couldn't create proxy config")
onErrorEvent.value = Event("Error: Failed to create account object")
return false
}
proxyConfig.isPushNotificationAllowed = true
Log.i("[Assistant] [Account Login] Proxy config created")
return true
}
}

View file

@ -0,0 +1,65 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.assistant.viewmodels
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.core.Core
import org.linphone.core.CoreListenerStub
import org.linphone.core.EcCalibratorStatus
import org.linphone.core.tools.Log
import org.linphone.utils.Event
class EchoCancellerCalibrationViewModel : ViewModel() {
val echoCalibrationTerminated = MutableLiveData<Event<Boolean>>()
private val listener = object : CoreListenerStub() {
override fun onEcCalibrationResult(core: Core, status: EcCalibratorStatus, delayMs: Int) {
if (status == EcCalibratorStatus.InProgress) return
echoCancellerCalibrationFinished(status, delayMs)
}
}
init {
coreContext.core.addListener(listener)
}
fun startEchoCancellerCalibration() {
coreContext.core.startEchoCancellerCalibration()
}
fun echoCancellerCalibrationFinished(status: EcCalibratorStatus, delay: Int) {
coreContext.core.removeListener(listener)
when (status) {
EcCalibratorStatus.DoneNoEcho -> {
Log.i("[Echo Canceller Calibration] Done, no echo")
}
EcCalibratorStatus.Done -> {
Log.i("[Echo Canceller Calibration] Done, delay is ${delay}ms")
}
EcCalibratorStatus.Failed -> {
Log.w("[Echo Canceller Calibration] Failed")
}
}
echoCalibrationTerminated.value = Event(true)
}
}

View file

@ -0,0 +1,170 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.assistant.viewmodels
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import org.linphone.R
import org.linphone.core.AccountCreator
import org.linphone.core.AccountCreatorListenerStub
import org.linphone.core.tools.Log
import org.linphone.utils.AppUtils
import org.linphone.utils.Event
class EmailAccountCreationViewModelFactory(private val accountCreator: AccountCreator) :
ViewModelProvider.NewInstanceFactory() {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return EmailAccountCreationViewModel(accountCreator) as T
}
}
class EmailAccountCreationViewModel(val accountCreator: AccountCreator) : ViewModel() {
val username = MutableLiveData<String>()
val usernameError = MutableLiveData<String>()
val email = MutableLiveData<String>()
val emailError = MutableLiveData<String>()
val password = MutableLiveData<String>()
val passwordError = MutableLiveData<String>()
val passwordConfirmation = MutableLiveData<String>()
val passwordConfirmationError = MutableLiveData<String>()
val createEnabled: MediatorLiveData<Boolean> = MediatorLiveData()
val waitForServerAnswer = MutableLiveData<Boolean>()
val goToEmailValidationEvent = MutableLiveData<Event<Boolean>>()
val onErrorEvent: MutableLiveData<Event<String>> by lazy {
MutableLiveData<Event<String>>()
}
private val listener = object : AccountCreatorListenerStub() {
override fun onIsAccountExist(
creator: AccountCreator,
status: AccountCreator.Status,
response: String?
) {
Log.i("[Assistant] [Account Creation] onIsAccountExist status is $status")
when (status) {
AccountCreator.Status.AccountExist, AccountCreator.Status.AccountExistWithAlias -> {
waitForServerAnswer.value = false
usernameError.value = AppUtils.getString(R.string.assistant_error_username_already_exists)
}
AccountCreator.Status.AccountNotExist -> {
val createAccountStatus = creator.createAccount()
if (createAccountStatus != AccountCreator.Status.RequestOk) {
waitForServerAnswer.value = false
onErrorEvent.value = Event("Error: ${status.name}")
}
}
else -> {
waitForServerAnswer.value = false
onErrorEvent.value = Event("Error: ${status.name}")
}
}
}
override fun onCreateAccount(
creator: AccountCreator,
status: AccountCreator.Status,
response: String?
) {
Log.i("[Account Creation] onCreateAccount status is $status")
waitForServerAnswer.value = false
when (status) {
AccountCreator.Status.AccountCreated -> {
goToEmailValidationEvent.value = Event(true)
}
else -> {
onErrorEvent.value = Event("Error: ${status.name}")
}
}
}
}
init {
accountCreator.addListener(listener)
createEnabled.value = false
createEnabled.addSource(username) {
createEnabled.value = isCreateButtonEnabled()
}
createEnabled.addSource(usernameError) {
createEnabled.value = isCreateButtonEnabled()
}
createEnabled.addSource(email) {
createEnabled.value = isCreateButtonEnabled()
}
createEnabled.addSource(emailError) {
createEnabled.value = isCreateButtonEnabled()
}
createEnabled.addSource(password) {
createEnabled.value = isCreateButtonEnabled()
}
createEnabled.addSource(passwordError) {
createEnabled.value = isCreateButtonEnabled()
}
createEnabled.addSource(passwordConfirmation) {
createEnabled.value = isCreateButtonEnabled()
}
createEnabled.addSource(passwordConfirmationError) {
createEnabled.value = isCreateButtonEnabled()
}
}
override fun onCleared() {
accountCreator.removeListener(listener)
super.onCleared()
}
fun create() {
accountCreator.username = username.value
accountCreator.password = password.value
accountCreator.email = email.value
waitForServerAnswer.value = true
val status = accountCreator.isAccountExist
Log.i("[Assistant] [Account Creation] Account exists returned $status")
if (status != AccountCreator.Status.RequestOk) {
waitForServerAnswer.value = false
onErrorEvent.value = Event("Error: ${status.name}")
}
}
private fun isCreateButtonEnabled(): Boolean {
return username.value.orEmpty().isNotEmpty() &&
email.value.orEmpty().isNotEmpty() &&
password.value.orEmpty().isNotEmpty() &&
passwordConfirmation.value.orEmpty().isNotEmpty() &&
password.value == passwordConfirmation.value &&
usernameError.value.orEmpty().isEmpty() &&
emailError.value.orEmpty().isEmpty() &&
passwordError.value.orEmpty().isEmpty() &&
passwordConfirmationError.value.orEmpty().isEmpty()
}
}

View file

@ -0,0 +1,112 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.assistant.viewmodels
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import org.linphone.core.AccountCreator
import org.linphone.core.AccountCreatorListenerStub
import org.linphone.core.ProxyConfig
import org.linphone.core.tools.Log
import org.linphone.utils.Event
class EmailAccountValidationViewModelFactory(private val accountCreator: AccountCreator) :
ViewModelProvider.NewInstanceFactory() {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return EmailAccountValidationViewModel(accountCreator) as T
}
}
class EmailAccountValidationViewModel(val accountCreator: AccountCreator) : ViewModel() {
val email = MutableLiveData<String>()
val waitForServerAnswer = MutableLiveData<Boolean>()
val leaveAssistantEvent = MutableLiveData<Event<Boolean>>()
val onErrorEvent: MutableLiveData<Event<String>> by lazy {
MutableLiveData<Event<String>>()
}
private val listener = object : AccountCreatorListenerStub() {
override fun onIsAccountActivated(
creator: AccountCreator,
status: AccountCreator.Status,
response: String?
) {
Log.i("[Account Validation] onIsAccountActivated status is $status")
waitForServerAnswer.value = false
when (status) {
AccountCreator.Status.AccountActivated -> {
if (createProxyConfig()) {
leaveAssistantEvent.value = Event(true)
} else {
onErrorEvent.value = Event("Error: ${status.name}")
}
}
AccountCreator.Status.AccountNotActivated -> {
onErrorEvent.value = Event("Error: ${status.name}")
}
else -> {
onErrorEvent.value = Event("Error: ${status.name}")
}
}
}
}
init {
accountCreator.addListener(listener)
email.value = accountCreator.email
}
override fun onCleared() {
accountCreator.removeListener(listener)
super.onCleared()
}
fun finish() {
waitForServerAnswer.value = true
val status = accountCreator.isAccountActivated
Log.i("[Assistant] [Account Validation] Account exists returned $status")
if (status != AccountCreator.Status.RequestOk) {
waitForServerAnswer.value = false
onErrorEvent.value = Event("Error: ${status.name}")
}
}
private fun createProxyConfig(): Boolean {
val proxyConfig: ProxyConfig? = accountCreator.createProxyConfig()
if (proxyConfig == null) {
Log.e("[Assistant] [Account Validation] Account creator couldn't create proxy config")
onErrorEvent.value = Event("Error: Failed to create account object")
return false
}
proxyConfig.isPushNotificationAllowed = true
Log.i("[Assistant] [Account Validation] Proxy config created")
return true
}
}

View file

@ -0,0 +1,155 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.assistant.viewmodels
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.LinphoneApplication.Companion.corePreferences
import org.linphone.core.*
import org.linphone.core.tools.Log
import org.linphone.utils.Event
class GenericLoginViewModelFactory(private val accountCreator: AccountCreator) :
ViewModelProvider.NewInstanceFactory() {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return GenericLoginViewModel(accountCreator) as T
}
}
class GenericLoginViewModel(private val accountCreator: AccountCreator) : ViewModel() {
val username = MutableLiveData<String>()
val password = MutableLiveData<String>()
val domain = MutableLiveData<String>()
val displayName = MutableLiveData<String>()
val transport = MutableLiveData<TransportType>()
val loginEnabled: MediatorLiveData<Boolean> = MediatorLiveData()
val waitForServerAnswer = MutableLiveData<Boolean>()
val leaveAssistantEvent = MutableLiveData<Event<Boolean>>()
val invalidCredentialsEvent: MutableLiveData<Event<Boolean>> by lazy {
MutableLiveData<Event<Boolean>>()
}
val onErrorEvent: MutableLiveData<Event<String>> by lazy {
MutableLiveData<Event<String>>()
}
private var proxyConfigToCheck: ProxyConfig? = null
private val coreListener = object : CoreListenerStub() {
override fun onRegistrationStateChanged(
core: Core,
cfg: ProxyConfig,
state: RegistrationState,
message: String
) {
if (cfg == proxyConfigToCheck) {
Log.i("[Assistant] [Generic Login] Registration state is $state: $message")
if (state == RegistrationState.Ok) {
waitForServerAnswer.value = false
leaveAssistantEvent.value = Event(true)
core.removeListener(this)
} else if (state == RegistrationState.Failed) {
waitForServerAnswer.value = false
invalidCredentialsEvent.value = Event(true)
core.removeListener(this)
}
}
}
}
init {
transport.value = TransportType.Tls
loginEnabled.value = false
loginEnabled.addSource(username) {
loginEnabled.value = isLoginButtonEnabled()
}
loginEnabled.addSource(password) {
loginEnabled.value = isLoginButtonEnabled()
}
loginEnabled.addSource(domain) {
loginEnabled.value = isLoginButtonEnabled()
}
}
fun setTransport(transportType: TransportType) {
transport.value = transportType
}
fun removeInvalidProxyConfig() {
val cfg = proxyConfigToCheck
cfg ?: return
val authInfo = cfg.findAuthInfo()
if (authInfo != null) coreContext.core.removeAuthInfo(authInfo)
coreContext.core.removeProxyConfig(cfg)
proxyConfigToCheck = null
}
fun continueEvenIfInvalidCredentials() {
leaveAssistantEvent.value = Event(true)
}
fun createProxyConfig() {
waitForServerAnswer.value = true
coreContext.core.addListener(coreListener)
accountCreator.username = username.value
accountCreator.password = password.value
accountCreator.domain = domain.value
accountCreator.displayName = displayName.value
accountCreator.transport = transport.value
val proxyConfig: ProxyConfig? = accountCreator.createProxyConfig()
proxyConfigToCheck = proxyConfig
if (proxyConfig == null) {
Log.e("[Assistant] [Generic Login] Account creator couldn't create proxy config")
coreContext.core.removeListener(coreListener)
onErrorEvent.value = Event("Error: Failed to create account object")
return
}
Log.i("[Assistant] [Generic Login] Proxy config created")
// The following is required to keep the app alive
// and be able to receive calls while in background
if (domain.value.orEmpty() != corePreferences.defaultDomain) {
Log.i("[Assistant] [Generic Login] Background mode with foreground service automatically enabled")
corePreferences.keepServiceAlive = true
coreContext.notificationsManager.startForeground()
}
}
private fun isLoginButtonEnabled(): Boolean {
return username.value.orEmpty().isNotEmpty() && domain.value.orEmpty().isNotEmpty() && password.value.orEmpty().isNotEmpty()
}
}

View file

@ -0,0 +1,170 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.assistant.viewmodels
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import org.linphone.LinphoneApplication.Companion.corePreferences
import org.linphone.R
import org.linphone.core.AccountCreator
import org.linphone.core.AccountCreatorListenerStub
import org.linphone.core.tools.Log
import org.linphone.utils.AppUtils
import org.linphone.utils.Event
class PhoneAccountCreationViewModelFactory(private val accountCreator: AccountCreator) :
ViewModelProvider.NewInstanceFactory() {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return PhoneAccountCreationViewModel(accountCreator) as T
}
}
class PhoneAccountCreationViewModel(accountCreator: AccountCreator) : AbstractPhoneViewModel(accountCreator) {
val username = MutableLiveData<String>()
val useUsername = MutableLiveData<Boolean>()
val usernameError = MutableLiveData<String>()
val createEnabled: MediatorLiveData<Boolean> = MediatorLiveData()
val waitForServerAnswer = MutableLiveData<Boolean>()
val goToSmsValidationEvent: MutableLiveData<Event<Boolean>> by lazy {
MutableLiveData<Event<Boolean>>()
}
val onErrorEvent: MutableLiveData<Event<String>> by lazy {
MutableLiveData<Event<String>>()
}
private val listener = object : AccountCreatorListenerStub() {
override fun onIsAccountExist(
creator: AccountCreator,
status: AccountCreator.Status,
response: String?
) {
Log.i("[Phone Account Creation] onIsAccountExist status is $status")
when (status) {
AccountCreator.Status.AccountExist, AccountCreator.Status.AccountExistWithAlias -> {
waitForServerAnswer.value = false
if (useUsername.value == true) {
usernameError.value = AppUtils.getString(R.string.assistant_error_username_already_exists)
} else {
phoneNumberError.value = AppUtils.getString(R.string.assistant_error_phone_number_already_exists)
}
}
AccountCreator.Status.AccountNotExist -> {
val createAccountStatus = creator.createAccount()
Log.i("[Phone Account Creation] createAccount returned $createAccountStatus")
if (createAccountStatus != AccountCreator.Status.RequestOk) {
waitForServerAnswer.value = false
onErrorEvent.value = Event("Error: ${status.name}")
}
}
else -> {
waitForServerAnswer.value = false
onErrorEvent.value = Event("Error: ${status.name}")
}
}
}
override fun onCreateAccount(
creator: AccountCreator,
status: AccountCreator.Status,
response: String?
) {
Log.i("[Phone Account Creation] onCreateAccount status is $status")
waitForServerAnswer.value = false
when (status) {
AccountCreator.Status.AccountCreated -> {
goToSmsValidationEvent.value = Event(true)
}
AccountCreator.Status.AccountExistWithAlias -> {
phoneNumberError.value = AppUtils.getString(R.string.assistant_error_phone_number_already_exists)
}
else -> {
onErrorEvent.value = Event("Error: ${status.name}")
}
}
}
}
init {
useUsername.value = false
accountCreator.addListener(listener)
createEnabled.value = false
createEnabled.addSource(prefix) {
createEnabled.value = isCreateButtonEnabled()
}
createEnabled.addSource(phoneNumber) {
createEnabled.value = isCreateButtonEnabled()
}
createEnabled.addSource(useUsername) {
createEnabled.value = isCreateButtonEnabled()
}
createEnabled.addSource(username) {
createEnabled.value = isCreateButtonEnabled()
}
createEnabled.addSource(usernameError) {
createEnabled.value = isCreateButtonEnabled()
}
createEnabled.addSource(phoneNumberError) {
createEnabled.value = isCreateButtonEnabled()
}
}
override fun onCleared() {
accountCreator.removeListener(listener)
super.onCleared()
}
fun create() {
accountCreator.setPhoneNumber(phoneNumber.value, prefix.value)
if (useUsername.value == true) {
accountCreator.username = username.value
} else {
accountCreator.username = accountCreator.phoneNumber
}
waitForServerAnswer.value = true
val status = accountCreator.isAccountExist
Log.i("[Phone Account Creation] isAccountExist returned $status")
if (status != AccountCreator.Status.RequestOk) {
waitForServerAnswer.value = false
onErrorEvent.value = Event("Error: ${status.name}")
}
}
private fun isCreateButtonEnabled(): Boolean {
val usernameRegexp = corePreferences.config.getString("assistant", "username_regex", "^[a-z0-9+_.\\-]*\$")
return isPhoneNumberOk() && usernameRegexp != null &&
(
useUsername.value == false ||
username.value.orEmpty().matches(Regex(usernameRegexp)) &&
username.value.orEmpty().isNotEmpty() &&
usernameError.value.orEmpty().isEmpty()
)
}
}

View file

@ -0,0 +1,142 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.assistant.viewmodels
import androidx.lifecycle.*
import org.linphone.core.AccountCreator
import org.linphone.core.AccountCreatorListenerStub
import org.linphone.core.tools.Log
import org.linphone.utils.Event
class PhoneAccountLinkingViewModelFactory(private val accountCreator: AccountCreator) :
ViewModelProvider.NewInstanceFactory() {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return PhoneAccountLinkingViewModel(accountCreator) as T
}
}
class PhoneAccountLinkingViewModel(accountCreator: AccountCreator) : AbstractPhoneViewModel(accountCreator) {
val username = MutableLiveData<String>()
val allowSkip = MutableLiveData<Boolean>()
val linkEnabled: MediatorLiveData<Boolean> = MediatorLiveData()
val waitForServerAnswer = MutableLiveData<Boolean>()
val leaveAssistantEvent = MutableLiveData<Event<Boolean>>()
val goToSmsValidationEvent = MutableLiveData<Event<Boolean>>()
val onErrorEvent: MutableLiveData<Event<String>> by lazy {
MutableLiveData<Event<String>>()
}
private val listener = object : AccountCreatorListenerStub() {
override fun onIsAliasUsed(
creator: AccountCreator,
status: AccountCreator.Status,
response: String?
) {
Log.i("[Phone Account Linking] onIsAliasUsed status is $status")
when (status) {
AccountCreator.Status.AliasNotExist -> {
if (creator.linkAccount() != AccountCreator.Status.RequestOk) {
Log.e("[Phone Account Linking] linkAccount status is $status")
waitForServerAnswer.value = false
onErrorEvent.value = Event("Error: ${status.name}")
}
}
AccountCreator.Status.AliasExist, AccountCreator.Status.AliasIsAccount -> {
waitForServerAnswer.value = false
onErrorEvent.value = Event("Error: ${status.name}")
}
else -> {
waitForServerAnswer.value = false
onErrorEvent.value = Event("Error: ${status.name}")
}
}
}
override fun onLinkAccount(
creator: AccountCreator,
status: AccountCreator.Status,
response: String?
) {
Log.i("[Phone Account Linking] onLinkAccount status is $status")
waitForServerAnswer.value = false
when (status) {
AccountCreator.Status.RequestOk -> {
goToSmsValidationEvent.value = Event(true)
}
else -> {
onErrorEvent.value = Event("Error: ${status.name}")
}
}
}
}
init {
accountCreator.addListener(listener)
linkEnabled.value = false
linkEnabled.addSource(prefix) {
linkEnabled.value = isLinkButtonEnabled()
}
linkEnabled.addSource(phoneNumber) {
linkEnabled.value = isLinkButtonEnabled()
}
linkEnabled.addSource(phoneNumberError) {
linkEnabled.value = isLinkButtonEnabled()
}
}
override fun onCleared() {
accountCreator.removeListener(listener)
super.onCleared()
}
fun link() {
accountCreator.setPhoneNumber(phoneNumber.value, prefix.value)
accountCreator.username = username.value
Log.i("[Assistant] [Phone Account Linking] Phone number is ${accountCreator.phoneNumber}")
waitForServerAnswer.value = true
val status: AccountCreator.Status = accountCreator.isAliasUsed
Log.i("[Phone Account Linking] isAliasUsed returned $status")
if (status != AccountCreator.Status.RequestOk) {
waitForServerAnswer.value = false
onErrorEvent.value = Event("Error: ${status.name}")
}
}
fun skip() {
leaveAssistantEvent.value = Event(true)
}
private fun isLinkButtonEnabled(): Boolean {
return isPhoneNumberOk()
}
}

View file

@ -0,0 +1,156 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.assistant.viewmodels
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import org.linphone.core.AccountCreator
import org.linphone.core.AccountCreatorListenerStub
import org.linphone.core.ProxyConfig
import org.linphone.core.tools.Log
import org.linphone.utils.Event
class PhoneAccountValidationViewModelFactory(private val accountCreator: AccountCreator) :
ViewModelProvider.NewInstanceFactory() {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return PhoneAccountValidationViewModel(accountCreator) as T
}
}
class PhoneAccountValidationViewModel(val accountCreator: AccountCreator) : ViewModel() {
val phoneNumber = MutableLiveData<String>()
val code = MutableLiveData<String>()
val isLogin = MutableLiveData<Boolean>()
val isCreation = MutableLiveData<Boolean>()
val isLinking = MutableLiveData<Boolean>()
val waitForServerAnswer = MutableLiveData<Boolean>()
val leaveAssistantEvent = MutableLiveData<Event<Boolean>>()
val onErrorEvent: MutableLiveData<Event<String>> by lazy {
MutableLiveData<Event<String>>()
}
val listener = object : AccountCreatorListenerStub() {
override fun onLoginLinphoneAccount(
creator: AccountCreator,
status: AccountCreator.Status,
response: String?
) {
Log.i("[Assistant] [Phone Account Validation] onLoginLinphoneAccount status is $status")
waitForServerAnswer.value = false
if (status == AccountCreator.Status.RequestOk) {
if (createProxyConfig()) {
leaveAssistantEvent.value = Event(true)
} else {
onErrorEvent.value = Event("Error: Failed to create account object")
}
} else {
onErrorEvent.value = Event("Error: ${status.name}")
}
}
override fun onActivateAlias(
creator: AccountCreator,
status: AccountCreator.Status,
response: String?
) {
Log.i("[Assistant] [Phone Account Validation] onActivateAlias status is $status")
waitForServerAnswer.value = false
when (status) {
AccountCreator.Status.AccountActivated -> {
leaveAssistantEvent.value = Event(true)
}
else -> {
onErrorEvent.value = Event("Error: ${status.name}")
}
}
}
override fun onActivateAccount(
creator: AccountCreator,
status: AccountCreator.Status,
response: String?
) {
Log.i("[Assistant] [Phone Account Validation] onActivateAccount status is $status")
waitForServerAnswer.value = false
if (status == AccountCreator.Status.AccountActivated) {
if (createProxyConfig()) {
leaveAssistantEvent.value = Event(true)
} else {
onErrorEvent.value = Event("Error: Failed to create account object")
}
} else {
onErrorEvent.value = Event("Error: ${status.name}")
}
}
}
init {
accountCreator.addListener(listener)
}
override fun onCleared() {
accountCreator.removeListener(listener)
super.onCleared()
}
fun finish() {
accountCreator.activationCode = code.value.orEmpty()
Log.i("[Assistant] [Phone Account Validation] Phone number is ${accountCreator.phoneNumber} and activation code is ${accountCreator.activationCode}")
waitForServerAnswer.value = true
val status = when {
isLogin.value == true -> accountCreator.loginLinphoneAccount()
isCreation.value == true -> accountCreator.activateAccount()
isLinking.value == true -> accountCreator.activateAlias()
else -> AccountCreator.Status.UnexpectedError
}
Log.i("[Assistant] [Phone Account Validation] Code validation result is $status")
if (status != AccountCreator.Status.RequestOk) {
waitForServerAnswer.value = false
onErrorEvent.value = Event("Error: ${status.name}")
}
}
private fun createProxyConfig(): Boolean {
val proxyConfig: ProxyConfig? = accountCreator.createProxyConfig()
if (proxyConfig == null) {
Log.e("[Assistant] [Phone Account Validation] Account creator couldn't create proxy config")
return false
}
proxyConfig.isPushNotificationAllowed = true
Log.i("[Assistant] [Phone Account Validation] Proxy config created")
return true
}
}

View file

@ -0,0 +1,73 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.assistant.viewmodels
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.core.Core
import org.linphone.core.CoreListenerStub
import org.linphone.core.tools.Log
import org.linphone.utils.Event
class QrCodeViewModel : ViewModel() {
val qrCodeFoundEvent = MutableLiveData<Event<String>>()
val showSwitchCamera = MutableLiveData<Boolean>()
private val listener = object : CoreListenerStub() {
override fun onQrcodeFound(core: Core, result: String?) {
Log.i("[QR Code] Found [$result]")
if (result != null) qrCodeFoundEvent.postValue(Event(result))
}
}
init {
coreContext.core.addListener(listener)
showSwitchCamera.value = coreContext.showSwitchCameraButton()
}
override fun onCleared() {
coreContext.core.removeListener(listener)
super.onCleared()
}
fun setBackCamera() {
showSwitchCamera.value = coreContext.showSwitchCameraButton()
for (camera in coreContext.core.videoDevicesList) {
if (camera.contains("Back")) {
Log.i("[QR Code] Found back facing camera: $camera")
coreContext.core.videoDevice = camera
return
}
}
val first = coreContext.core.videoDevicesList.firstOrNull()
if (first != null) {
Log.i("[QR Code] Using first camera found: $first")
coreContext.core.videoDevice = first
}
}
fun switchCamera() {
coreContext.switchCamera()
}
}

View file

@ -0,0 +1,84 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.assistant.viewmodels
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.core.ConfiguringState
import org.linphone.core.Core
import org.linphone.core.CoreListenerStub
import org.linphone.core.tools.Log
import org.linphone.utils.Event
class RemoteProvisioningViewModel : ViewModel() {
val urlToFetch = MutableLiveData<String>()
val urlError = MutableLiveData<String>()
val fetchEnabled: MediatorLiveData<Boolean> = MediatorLiveData()
val fetchInProgress = MutableLiveData<Boolean>()
val fetchSuccessfulEvent = MutableLiveData<Event<Boolean>>()
private val listener = object : CoreListenerStub() {
override fun onConfiguringStatus(core: Core, status: ConfiguringState, message: String?) {
fetchInProgress.value = false
when (status) {
ConfiguringState.Successful -> {
fetchSuccessfulEvent.value = Event(true)
}
ConfiguringState.Failed -> {
fetchSuccessfulEvent.value = Event(false)
}
}
}
}
init {
fetchInProgress.value = false
coreContext.core.addListener(listener)
fetchEnabled.value = false
fetchEnabled.addSource(urlToFetch) {
fetchEnabled.value = isFetchEnabled()
}
fetchEnabled.addSource(urlError) {
fetchEnabled.value = isFetchEnabled()
}
}
override fun onCleared() {
coreContext.core.removeListener(listener)
super.onCleared()
}
fun fetchAndApply() {
val url = urlToFetch.value.orEmpty()
coreContext.core.provisioningUri = url
Log.w("[Remote Provisioning] Url set to [$url], restarting Core")
fetchInProgress.value = true
coreContext.core.stop()
coreContext.core.start()
}
private fun isFetchEnabled(): Boolean {
return urlToFetch.value.orEmpty().isNotEmpty() && urlError.value.orEmpty().isEmpty()
}
}

View file

@ -0,0 +1,59 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.assistant.viewmodels
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import java.util.*
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.LinphoneApplication.Companion.corePreferences
import org.linphone.core.*
import org.linphone.core.tools.Log
class SharedAssistantViewModel : ViewModel() {
val remoteProvisioningUrl = MutableLiveData<String>()
private var accountCreator: AccountCreator
private var useGenericSipAccount: Boolean = false
init {
Log.i("[Assistant] Loading linphone default values")
coreContext.core.loadConfigFromXml(corePreferences.linphoneDefaultValuesPath)
accountCreator = coreContext.core.createAccountCreator(corePreferences.xmlRpcServerUrl)
accountCreator.language = Locale.getDefault().language
}
fun getAccountCreator(genericAccountCreator: Boolean = false): AccountCreator {
if (genericAccountCreator != useGenericSipAccount) {
accountCreator.reset()
accountCreator.language = Locale.getDefault().language
if (genericAccountCreator) {
Log.i("[Assistant] Loading default values")
coreContext.core.loadConfigFromXml(corePreferences.defaultValuesPath)
} else {
Log.i("[Assistant] Loading linphone default values")
coreContext.core.loadConfigFromXml(corePreferences.linphoneDefaultValuesPath)
}
useGenericSipAccount = genericAccountCreator
}
return accountCreator
}
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2010-2023 Belledonne Communications SARL.
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
@ -17,32 +17,21 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.ui.main.contacts.model
package org.linphone.activities.assistant.viewmodels
import androidx.annotation.UiThread
import androidx.lifecycle.MutableLiveData
import org.linphone.utils.Event
import androidx.lifecycle.ViewModel
import org.linphone.LinphoneApplication.Companion.corePreferences
class NumberOrAddressPickerDialogModel
@UiThread
constructor(
list: List<ContactNumberOrAddressModel>
) {
val sipAddressesAndPhoneNumbers = MutableLiveData<List<ContactNumberOrAddressModel>>()
class WelcomeViewModel : ViewModel() {
val showCreateAccount: Boolean = corePreferences.showCreateAccount
val showLinphoneLogin: Boolean = corePreferences.showLinphoneLogin
val showGenericLogin: Boolean = corePreferences.showGenericLogin
val showRemoteProvisioning: Boolean = corePreferences.showRemoteProvisioning
val dismissEvent = MutableLiveData<Event<Boolean>>()
val termsAndPrivacyAccepted = MutableLiveData<Boolean>()
init {
for (model in list) {
model.setActionDoneCallback {
dismiss()
}
}
sipAddressesAndPhoneNumbers.value = list
}
@UiThread
fun dismiss() {
dismissEvent.value = Event(true)
termsAndPrivacyAccepted.value = corePreferences.readAndAgreeTermsAndPrivacy
}
}

View file

@ -0,0 +1,212 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.call
import android.content.Intent
import android.content.res.Configuration
import android.content.res.Resources
import android.os.Bundle
import android.view.Gravity
import android.view.View
import androidx.constraintlayout.widget.ConstraintSet
import androidx.databinding.DataBindingUtil
import androidx.lifecycle.ViewModelProvider
import androidx.window.layout.FoldingFeature
import kotlinx.coroutines.*
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.LinphoneApplication.Companion.corePreferences
import org.linphone.R
import org.linphone.activities.call.viewmodels.*
import org.linphone.activities.main.MainActivity
import org.linphone.compatibility.Compatibility
import org.linphone.core.tools.Log
import org.linphone.databinding.CallActivityBinding
class CallActivity : ProximitySensorActivity() {
private lateinit var binding: CallActivityBinding
private lateinit var viewModel: ControlsFadingViewModel
private lateinit var sharedViewModel: SharedCallViewModel
private var foldingFeature: FoldingFeature? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Compatibility.setShowWhenLocked(this, true)
Compatibility.setTurnScreenOn(this, true)
binding = DataBindingUtil.setContentView(this, R.layout.call_activity)
binding.lifecycleOwner = this
viewModel = ViewModelProvider(this)[ControlsFadingViewModel::class.java]
binding.controlsFadingViewModel = viewModel
sharedViewModel = ViewModelProvider(this)[SharedCallViewModel::class.java]
sharedViewModel.toggleDrawerEvent.observe(
this,
{
it.consume {
if (binding.statsMenu.isDrawerOpen(Gravity.LEFT)) {
binding.statsMenu.closeDrawer(binding.sideMenuContent, true)
} else {
binding.statsMenu.openDrawer(binding.sideMenuContent, true)
}
}
}
)
sharedViewModel.resetHiddenInterfaceTimerInVideoCallEvent.observe(
this,
{
it.consume {
viewModel.showMomentarily()
}
}
)
viewModel.proximitySensorEnabled.observe(
this,
{
enableProximitySensor(it)
}
)
viewModel.videoEnabled.observe(
this,
{
updateConstraintSetDependingOnFoldingState()
}
)
}
override fun onLayoutChanges(foldingFeature: FoldingFeature?) {
this.foldingFeature = foldingFeature
updateConstraintSetDependingOnFoldingState()
}
override fun onResume() {
super.onResume()
if (coreContext.core.callsNb == 0) {
Log.w("[Call Activity] Resuming but no call found...")
if (isTaskRoot) {
// When resuming app from recent tasks make sure MainActivity will be launched if there is no call
val intent = Intent()
intent.setClass(this, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
startActivity(intent)
} else {
finish()
}
} else {
coreContext.removeCallOverlay()
}
if (corePreferences.fullScreenCallUI) {
hideSystemUI()
window.decorView.setOnSystemUiVisibilityChangeListener { visibility ->
if (visibility and View.SYSTEM_UI_FLAG_FULLSCREEN == 0) {
GlobalScope.launch {
delay(2000)
withContext(Dispatchers.Main) {
hideSystemUI()
}
}
}
}
}
}
override fun onPause() {
val core = coreContext.core
if (core.callsNb > 0) {
coreContext.createCallOverlay()
}
super.onPause()
}
override fun onDestroy() {
coreContext.core.nativeVideoWindowId = null
coreContext.core.nativePreviewWindowId = null
super.onDestroy()
}
override fun onUserLeaveHint() {
super.onUserLeaveHint()
if (coreContext.isVideoCallOrConferenceActive()) {
Compatibility.enterPipMode(this)
}
}
override fun onPictureInPictureModeChanged(
isInPictureInPictureMode: Boolean,
newConfig: Configuration
) {
if (isInPictureInPictureMode) {
viewModel.areControlsHidden.value = true
}
if (corePreferences.hideCameraPreviewInPipMode) {
viewModel.isVideoPreviewHidden.value = isInPictureInPictureMode
} else {
viewModel.isVideoPreviewResizedForPip.value = isInPictureInPictureMode
}
}
override fun getTheme(): Resources.Theme {
val theme = super.getTheme()
if (corePreferences.fullScreenCallUI) {
theme.applyStyle(R.style.FullScreenTheme, true)
}
return theme
}
private fun hideSystemUI() {
window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_IMMERSIVE or
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
}
private fun updateConstraintSetDependingOnFoldingState() {
val feature = foldingFeature ?: return
val constraintLayout = binding.constraintLayout
val set = ConstraintSet()
set.clone(constraintLayout)
if (feature.state == FoldingFeature.State.HALF_OPENED && viewModel.videoEnabled.value == true) {
set.setGuidelinePercent(R.id.hinge_top, 0.5f)
set.setGuidelinePercent(R.id.hinge_bottom, 0.5f)
viewModel.disable(true)
} else {
set.setGuidelinePercent(R.id.hinge_top, 0f)
set.setGuidelinePercent(R.id.hinge_bottom, 1f)
viewModel.disable(false)
}
set.applyTo(constraintLayout)
}
}

View file

@ -0,0 +1,185 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.call
import android.Manifest
import android.annotation.TargetApi
import android.app.KeyguardManager
import android.content.Context
import android.content.Intent
import android.content.pm.ActivityInfo
import android.content.pm.PackageManager
import android.os.Bundle
import androidx.databinding.DataBindingUtil
import androidx.lifecycle.ViewModelProvider
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.R
import org.linphone.activities.GenericActivity
import org.linphone.activities.call.viewmodels.IncomingCallViewModel
import org.linphone.activities.call.viewmodels.IncomingCallViewModelFactory
import org.linphone.activities.main.MainActivity
import org.linphone.compatibility.Compatibility
import org.linphone.core.Call
import org.linphone.core.tools.Log
import org.linphone.databinding.CallIncomingActivityBinding
import org.linphone.mediastream.Version
import org.linphone.utils.PermissionHelper
class IncomingCallActivity : GenericActivity() {
private lateinit var binding: CallIncomingActivityBinding
private lateinit var viewModel: IncomingCallViewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Compatibility.setShowWhenLocked(this, true)
Compatibility.setTurnScreenOn(this, true)
// Leaks on API 27+: https://stackoverflow.com/questions/60477120/keyguardmanager-memory-leak
Compatibility.requestDismissKeyguard(this)
binding = DataBindingUtil.setContentView(this, R.layout.call_incoming_activity)
binding.lifecycleOwner = this
val incomingCall: Call? = findIncomingCall()
if (incomingCall == null) {
Log.e("[Incoming Call Activity] Couldn't find call in state Incoming")
if (isTaskRoot) {
// When resuming app from recent tasks make sure MainActivity will be launched if there is no call
val intent = Intent()
intent.setClass(this, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
startActivity(intent)
} else {
finish()
}
return
}
viewModel = ViewModelProvider(
this,
IncomingCallViewModelFactory(incomingCall)
)[IncomingCallViewModel::class.java]
binding.viewModel = viewModel
viewModel.callEndedEvent.observe(
this,
{
it.consume {
Log.i("[Incoming Call Activity] Call ended, finish activity")
finish()
}
}
)
viewModel.earlyMediaVideoEnabled.observe(
this,
{
if (it) {
Log.i("[Incoming Call Activity] Early media video being received, set native window id")
coreContext.core.nativeVideoWindowId = binding.remoteVideoSurface
}
}
)
val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
val keyguardLocked = keyguardManager.isKeyguardLocked
viewModel.screenLocked.value = keyguardLocked
if (keyguardLocked) {
// Forbid screen rotation to prevent keyguard to show up above incoming call view
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LOCKED
}
binding.buttons.setViewModel(viewModel)
if (Version.sdkAboveOrEqual(Version.API23_MARSHMALLOW_60)) {
checkPermissions()
}
}
override fun onResume() {
super.onResume()
val incomingCall: Call? = findIncomingCall()
if (incomingCall == null) {
Log.e("[Incoming Call Activity] Couldn't find call in state Incoming")
if (isTaskRoot) {
// When resuming app from recent tasks make sure MainActivity will be launched if there is no call
val intent = Intent()
intent.setClass(this, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
startActivity(intent)
} else {
finish()
}
}
}
@TargetApi(Version.API23_MARSHMALLOW_60)
private fun checkPermissions() {
val permissionsRequiredList = arrayListOf<String>()
if (!PermissionHelper.get().hasRecordAudioPermission()) {
Log.i("[Incoming Call Activity] Asking for RECORD_AUDIO permission")
permissionsRequiredList.add(Manifest.permission.RECORD_AUDIO)
}
if (viewModel.call.currentParams.videoEnabled() && !PermissionHelper.get().hasCameraPermission()) {
Log.i("[Incoming Call Activity] Asking for CAMERA permission")
permissionsRequiredList.add(Manifest.permission.CAMERA)
}
if (permissionsRequiredList.isNotEmpty()) {
val permissionsRequired = arrayOfNulls<String>(permissionsRequiredList.size)
permissionsRequiredList.toArray(permissionsRequired)
requestPermissions(permissionsRequired, 0)
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
if (requestCode == 0) {
for (i in permissions.indices) {
when (permissions[i]) {
Manifest.permission.RECORD_AUDIO -> if (grantResults[i] == PackageManager.PERMISSION_GRANTED) {
Log.i("[Incoming Call Activity] RECORD_AUDIO permission has been granted")
}
Manifest.permission.CAMERA -> if (grantResults[i] == PackageManager.PERMISSION_GRANTED) {
Log.i("[Incoming Call Activity] CAMERA permission has been granted")
coreContext.core.reloadVideoDevices()
}
}
}
}
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
}
private fun findIncomingCall(): Call? {
for (call in coreContext.core.calls) {
if (call.state == Call.State.IncomingReceived ||
call.state == Call.State.IncomingEarlyMedia
) {
return call
}
}
return null
}
}

View file

@ -0,0 +1,232 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.call
import android.Manifest
import android.animation.ValueAnimator
import android.annotation.TargetApi
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import androidx.databinding.DataBindingUtil
import androidx.lifecycle.ViewModelProvider
import com.google.android.flexbox.FlexboxLayout
import org.linphone.LinphoneApplication
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.R
import org.linphone.activities.call.viewmodels.CallViewModel
import org.linphone.activities.call.viewmodels.CallViewModelFactory
import org.linphone.activities.call.viewmodels.ControlsViewModel
import org.linphone.activities.main.MainActivity
import org.linphone.core.Call
import org.linphone.core.tools.Log
import org.linphone.databinding.CallOutgoingActivityBinding
import org.linphone.mediastream.Version
import org.linphone.utils.PermissionHelper
class OutgoingCallActivity : ProximitySensorActivity() {
private lateinit var binding: CallOutgoingActivityBinding
private lateinit var viewModel: CallViewModel
private lateinit var controlsViewModel: ControlsViewModel
// We have to use lateinit here because we need to compute the screen width first
private lateinit var numpadAnimator: ValueAnimator
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = DataBindingUtil.setContentView(this, R.layout.call_outgoing_activity)
binding.lifecycleOwner = this
val outgoingCall: Call? = findOutgoingCall()
if (outgoingCall == null) {
Log.e("[Outgoing Call Activity] Couldn't find call in state Outgoing")
if (isTaskRoot) {
// When resuming app from recent tasks make sure MainActivity will be launched if there is no call
val intent = Intent()
intent.setClass(this, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
startActivity(intent)
} else {
finish()
}
return
}
viewModel = ViewModelProvider(
this,
CallViewModelFactory(outgoingCall)
)[CallViewModel::class.java]
binding.viewModel = viewModel
controlsViewModel = ViewModelProvider(this)[ControlsViewModel::class.java]
binding.controlsViewModel = controlsViewModel
viewModel.callEndedEvent.observe(
this,
{
it.consume {
Log.i("[Outgoing Call Activity] Call ended, finish activity")
finish()
}
}
)
viewModel.callConnectedEvent.observe(
this,
{
it.consume {
Log.i("[Outgoing Call Activity] Call connected, finish activity")
finish()
}
}
)
controlsViewModel.isSpeakerSelected.observe(
this,
{
enableProximitySensor(!it)
}
)
controlsViewModel.askPermissionEvent.observe(
this,
{
it.consume { permission ->
requestPermissions(arrayOf(permission), 0)
}
}
)
controlsViewModel.toggleNumpadEvent.observe(
this,
{
it.consume { open ->
if (this::numpadAnimator.isInitialized) {
if (open) {
numpadAnimator.start()
} else {
numpadAnimator.reverse()
}
}
}
}
)
if (Version.sdkAboveOrEqual(Version.API23_MARSHMALLOW_60)) {
checkPermissions()
}
}
override fun onStart() {
super.onStart()
initNumpadLayout()
}
override fun onStop() {
numpadAnimator.end()
super.onStop()
}
override fun onResume() {
super.onResume()
val outgoingCall: Call? = findOutgoingCall()
if (outgoingCall == null) {
Log.e("[Outgoing Call Activity] Couldn't find call in state Outgoing")
if (isTaskRoot) {
// When resuming app from recent tasks make sure MainActivity will be launched if there is no call
val intent = Intent()
intent.setClass(this, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
startActivity(intent)
} else {
finish()
}
}
}
@TargetApi(Version.API23_MARSHMALLOW_60)
private fun checkPermissions() {
val permissionsRequiredList = arrayListOf<String>()
if (!PermissionHelper.get().hasRecordAudioPermission()) {
Log.i("[Outgoing Call Activity] Asking for RECORD_AUDIO permission")
permissionsRequiredList.add(Manifest.permission.RECORD_AUDIO)
}
if (viewModel.call.currentParams.videoEnabled() && !PermissionHelper.get().hasCameraPermission()) {
Log.i("[Outgoing Call Activity] Asking for CAMERA permission")
permissionsRequiredList.add(Manifest.permission.CAMERA)
}
if (permissionsRequiredList.isNotEmpty()) {
val permissionsRequired = arrayOfNulls<String>(permissionsRequiredList.size)
permissionsRequiredList.toArray(permissionsRequired)
requestPermissions(permissionsRequired, 0)
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
if (requestCode == 0) {
for (i in permissions.indices) {
when (permissions[i]) {
Manifest.permission.RECORD_AUDIO -> if (grantResults[i] == PackageManager.PERMISSION_GRANTED) {
Log.i("[Outgoing Call Activity] RECORD_AUDIO permission has been granted")
controlsViewModel.updateMuteMicState()
}
Manifest.permission.CAMERA -> if (grantResults[i] == PackageManager.PERMISSION_GRANTED) {
Log.i("[Outgoing Call Activity] CAMERA permission has been granted")
coreContext.core.reloadVideoDevices()
}
}
}
}
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
}
private fun findOutgoingCall(): Call? {
for (call in coreContext.core.calls) {
if (call.state == Call.State.OutgoingInit ||
call.state == Call.State.OutgoingProgress ||
call.state == Call.State.OutgoingRinging
) {
return call
}
}
return null
}
private fun initNumpadLayout() {
val screenWidth = coreContext.screenWidth
numpadAnimator = ValueAnimator.ofFloat(screenWidth, 0f).apply {
addUpdateListener {
val value = it.animatedValue as Float
findViewById<FlexboxLayout>(R.id.numpad)?.translationX = -value
duration = if (LinphoneApplication.corePreferences.enableAnimations) 500 else 0
}
}
// Hide the numpad here as we can't set the translationX property on include tag in layout
if (this::controlsViewModel.isInitialized && controlsViewModel.numpadVisibility.value == false) {
findViewById<FlexboxLayout>(R.id.numpad)?.translationX = -screenWidth
}
}
}

View file

@ -0,0 +1,90 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.call
import android.content.Context
import android.os.Bundle
import android.os.PowerManager
import android.os.PowerManager.RELEASE_FLAG_WAIT_FOR_NO_PROXIMITY
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.activities.GenericActivity
import org.linphone.core.tools.Log
abstract class ProximitySensorActivity : GenericActivity() {
private lateinit var proximityWakeLock: PowerManager.WakeLock
private var proximitySensorEnabled = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
if (!powerManager.isWakeLockLevelSupported(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK)) {
Log.w("[Proximity Sensor Activity] PROXIMITY_SCREEN_OFF_WAKE_LOCK isn't supported on this device!")
}
proximityWakeLock = powerManager.newWakeLock(
PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK,
"$packageName;proximity_sensor"
)
}
override fun onResume() {
super.onResume()
if (coreContext.core.callsNb > 0) {
val videoEnabled = coreContext.isVideoCallOrConferenceActive()
enableProximitySensor(!videoEnabled)
}
}
override fun onPause() {
enableProximitySensor(false)
super.onPause()
}
override fun onDestroy() {
enableProximitySensor(false)
super.onDestroy()
}
protected fun enableProximitySensor(enable: Boolean) {
if (enable) {
if (!proximitySensorEnabled) {
Log.i("[Proximity Sensor Activity] Enabling proximity sensor turning off screen")
if (!proximityWakeLock.isHeld) {
Log.i("[Proximity Sensor Activity] Acquiring PROXIMITY_SCREEN_OFF_WAKE_LOCK")
proximityWakeLock.acquire()
}
proximitySensorEnabled = true
}
} else {
if (proximitySensorEnabled) {
Log.i("[Proximity Sensor Activity] Disabling proximity sensor turning off screen")
if (proximityWakeLock.isHeld) {
Log.i("[Proximity Sensor Activity] Releasing PROXIMITY_SCREEN_OFF_WAKE_LOCK")
proximityWakeLock.release(RELEASE_FLAG_WAIT_FOR_NO_PROXIMITY)
}
proximitySensorEnabled = false
}
}
}
}

View file

@ -0,0 +1,142 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.call
import android.content.Context
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.ScaleGestureDetector
import android.view.View
import kotlin.math.max
import kotlin.math.min
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.core.Call
class VideoZoomHelper(context: Context, private var videoDisplayView: View) : GestureDetector.SimpleOnGestureListener() {
private var scaleDetector: ScaleGestureDetector
private var zoomFactor = 1f
private var zoomCenterX = 0f
private var zoomCenterY = 0f
init {
val gestureDetector = GestureDetector(context, this)
scaleDetector = ScaleGestureDetector(
context,
object :
ScaleGestureDetector.SimpleOnScaleGestureListener() {
override fun onScale(detector: ScaleGestureDetector): Boolean {
zoomFactor *= detector.scaleFactor
// Don't let the object get too small or too large.
// Zoom to make the video fill the screen vertically
val portraitZoomFactor = videoDisplayView.height.toFloat() / (3 * videoDisplayView.width / 4)
// Zoom to make the video fill the screen horizontally
val landscapeZoomFactor = videoDisplayView.width.toFloat() / (3 * videoDisplayView.height / 4)
zoomFactor = max(0.1f, min(zoomFactor, max(portraitZoomFactor, landscapeZoomFactor)))
val currentCall: Call? = coreContext.core.currentCall
if (currentCall != null) {
currentCall.zoom(zoomFactor, zoomCenterX, zoomCenterY)
return true
}
return false
}
}
)
videoDisplayView.setOnTouchListener { _, event ->
val currentZoomFactor = zoomFactor
scaleDetector.onTouchEvent(event)
if (currentZoomFactor != zoomFactor) {
// We did scale, prevent touch event from going further
return@setOnTouchListener true
}
// If true, gesture detected, prevent touch event from going further
// Otherwise it seems we didn't use event,
// allow it to be dispatched somewhere else
gestureDetector.onTouchEvent(event)
}
}
override fun onScroll(
e1: MotionEvent,
e2: MotionEvent,
distanceX: Float,
distanceY: Float
): Boolean {
val currentCall: Call? = coreContext.core.currentCall
if (currentCall != null) {
if (zoomFactor > 1) {
// Video is zoomed, slide is used to change center of zoom
if (distanceX > 0 && zoomCenterX < 1) {
zoomCenterX += 0.01f
} else if (distanceX < 0 && zoomCenterX > 0) {
zoomCenterX -= 0.01f
}
if (distanceY < 0 && zoomCenterY < 1) {
zoomCenterY += 0.01f
} else if (distanceY > 0 && zoomCenterY > 0) {
zoomCenterY -= 0.01f
}
if (zoomCenterX > 1) zoomCenterX = 1f
if (zoomCenterX < 0) zoomCenterX = 0f
if (zoomCenterY > 1) zoomCenterY = 1f
if (zoomCenterY < 0) zoomCenterY = 0f
currentCall.zoom(zoomFactor, zoomCenterX, zoomCenterY)
return true
}
}
return false
}
override fun onDoubleTap(e: MotionEvent?): Boolean {
val currentCall: Call? = coreContext.core.currentCall
if (currentCall != null) {
if (zoomFactor == 1f) {
// Zoom to make the video fill the screen vertically
val portraitZoomFactor = videoDisplayView.height.toFloat() / (3 * videoDisplayView.width / 4)
// Zoom to make the video fill the screen horizontally
val landscapeZoomFactor = videoDisplayView.width.toFloat() / (3 * videoDisplayView.height / 4)
zoomFactor = max(portraitZoomFactor, landscapeZoomFactor)
} else {
resetZoom()
}
currentCall.zoom(zoomFactor, zoomCenterX, zoomCenterY)
return true
}
return false
}
private fun resetZoom() {
zoomFactor = 1f
zoomCenterY = 0.5f
zoomCenterX = zoomCenterY
}
}

View file

@ -0,0 +1,115 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.call.data
import androidx.lifecycle.MutableLiveData
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.contact.GenericContactData
import org.linphone.core.*
class CallStatisticsData(val call: Call) : GenericContactData(call.remoteAddress) {
val audioStats = MutableLiveData<ArrayList<StatItemData>>()
val videoStats = MutableLiveData<ArrayList<StatItemData>>()
val isVideoEnabled = MutableLiveData<Boolean>()
val isExpanded = MutableLiveData<Boolean>()
private val listener = object : CoreListenerStub() {
override fun onCallStatsUpdated(core: Core, call: Call, stats: CallStats) {
if (call == this@CallStatisticsData.call) {
isVideoEnabled.value = call.currentParams.videoEnabled()
updateCallStats(stats)
}
}
}
init {
coreContext.core.addListener(listener)
audioStats.value = arrayListOf()
videoStats.value = arrayListOf()
initCallStats()
val videoEnabled = call.currentParams.videoEnabled()
isVideoEnabled.value = videoEnabled
isExpanded.value = coreContext.core.currentCall == call
}
override fun destroy() {
coreContext.core.removeListener(listener)
super.destroy()
}
fun toggleExpanded() {
isExpanded.value = isExpanded.value != true
}
private fun initCallStats() {
val audioList = arrayListOf<StatItemData>()
audioList.add(StatItemData(StatType.CAPTURE))
audioList.add(StatItemData(StatType.PLAYBACK))
audioList.add(StatItemData(StatType.PAYLOAD))
audioList.add(StatItemData(StatType.ENCODER))
audioList.add(StatItemData(StatType.DECODER))
audioList.add(StatItemData(StatType.DOWNLOAD_BW))
audioList.add(StatItemData(StatType.UPLOAD_BW))
audioList.add(StatItemData(StatType.ICE))
audioList.add(StatItemData(StatType.IP_FAM))
audioList.add(StatItemData(StatType.SENDER_LOSS))
audioList.add(StatItemData(StatType.RECEIVER_LOSS))
audioList.add(StatItemData(StatType.JITTER))
audioStats.value = audioList
val videoList = arrayListOf<StatItemData>()
videoList.add(StatItemData(StatType.CAPTURE))
videoList.add(StatItemData(StatType.PLAYBACK))
videoList.add(StatItemData(StatType.PAYLOAD))
videoList.add(StatItemData(StatType.ENCODER))
videoList.add(StatItemData(StatType.DECODER))
videoList.add(StatItemData(StatType.DOWNLOAD_BW))
videoList.add(StatItemData(StatType.UPLOAD_BW))
videoList.add(StatItemData(StatType.ESTIMATED_AVAILABLE_DOWNLOAD_BW))
videoList.add(StatItemData(StatType.ICE))
videoList.add(StatItemData(StatType.IP_FAM))
videoList.add(StatItemData(StatType.SENDER_LOSS))
videoList.add(StatItemData(StatType.RECEIVER_LOSS))
videoList.add(StatItemData(StatType.SENT_RESOLUTION))
videoList.add(StatItemData(StatType.RECEIVED_RESOLUTION))
videoList.add(StatItemData(StatType.SENT_FPS))
videoList.add(StatItemData(StatType.RECEIVED_FPS))
videoStats.value = videoList
}
private fun updateCallStats(stats: CallStats) {
if (stats.type == StreamType.Audio) {
for (stat in audioStats.value.orEmpty()) {
stat.update(call, stats)
}
} else if (stats.type == StreamType.Video) {
for (stat in videoStats.value.orEmpty()) {
stat.update(call, stats)
}
}
}
}

View file

@ -0,0 +1,47 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.call.data
import androidx.lifecycle.MutableLiveData
import org.linphone.contact.GenericContactData
import org.linphone.core.Conference
import org.linphone.core.Participant
import org.linphone.core.tools.Log
class ConferenceParticipantData(
private val conference: Conference,
val participant: Participant
) :
GenericContactData(participant.address) {
private val isAdmin = MutableLiveData<Boolean>()
val isMeAdmin = MutableLiveData<Boolean>()
init {
isAdmin.value = participant.isAdmin
isMeAdmin.value = conference.me.isAdmin
Log.i("[Conference Participant VM] Participant ${participant.address.asStringUriOnly()} is ${if (participant.isAdmin) "admin" else "not admin"}")
Log.i("[Conference Participant VM] Me is ${if (conference.me.isAdmin) "admin" else "not admin"} and is ${if (conference.me.isFocus) "focus" else "not focus"}")
}
fun removeFromConference() {
Log.i("[Conference Participant VM] Removing participant ${participant.address.asStringUriOnly()} from conference $conference")
conference.removeParticipant(participant)
}
}

View file

@ -0,0 +1,76 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.call.data
import androidx.lifecycle.MutableLiveData
import java.text.DecimalFormat
import org.linphone.R
import org.linphone.core.AddressFamily
import org.linphone.core.Call
import org.linphone.core.CallStats
import org.linphone.core.StreamType
enum class StatType(val nameResource: Int) {
CAPTURE(R.string.call_stats_capture_filter),
PLAYBACK(R.string.call_stats_player_filter),
PAYLOAD(R.string.call_stats_codec),
ENCODER(R.string.call_stats_encoder_name),
DECODER(R.string.call_stats_decoder_name),
DOWNLOAD_BW(R.string.call_stats_download),
UPLOAD_BW(R.string.call_stats_upload),
ICE(R.string.call_stats_ice),
IP_FAM(R.string.call_stats_ip),
SENDER_LOSS(R.string.call_stats_sender_loss_rate),
RECEIVER_LOSS(R.string.call_stats_receiver_loss_rate),
JITTER(R.string.call_stats_jitter_buffer),
SENT_RESOLUTION(R.string.call_stats_video_resolution_sent),
RECEIVED_RESOLUTION(R.string.call_stats_video_resolution_received),
SENT_FPS(R.string.call_stats_video_fps_sent),
RECEIVED_FPS(R.string.call_stats_video_fps_received),
ESTIMATED_AVAILABLE_DOWNLOAD_BW(R.string.call_stats_estimated_download)
}
class StatItemData(val type: StatType) {
val value = MutableLiveData<String>()
fun update(call: Call, stats: CallStats) {
val payloadType = if (stats.type == StreamType.Audio) call.currentParams.usedAudioPayloadType else call.currentParams.usedVideoPayloadType
payloadType ?: return
value.value = when (type) {
StatType.CAPTURE -> if (stats.type == StreamType.Audio) call.core.captureDevice else call.core.videoDevice
StatType.PLAYBACK -> if (stats.type == StreamType.Audio) call.core.playbackDevice else call.core.videoDisplayFilter
StatType.PAYLOAD -> "${payloadType.mimeType}/${payloadType.clockRate / 1000} kHz"
StatType.ENCODER -> call.core.mediastreamerFactory.getDecoderText(payloadType.mimeType)
StatType.DECODER -> call.core.mediastreamerFactory.getEncoderText(payloadType.mimeType)
StatType.DOWNLOAD_BW -> "${stats.downloadBandwidth} kbits/s"
StatType.UPLOAD_BW -> "${stats.uploadBandwidth} kbits/s"
StatType.ICE -> stats.iceState.toString()
StatType.IP_FAM -> if (stats.ipFamilyOfRemote == AddressFamily.Inet6) "IPv6" else "IPv4"
StatType.SENDER_LOSS -> DecimalFormat("##.##%").format(stats.senderLossRate)
StatType.RECEIVER_LOSS -> DecimalFormat("##.##%").format(stats.receiverLossRate)
StatType.JITTER -> DecimalFormat("##.## ms").format(stats.jitterBufferSizeMs)
StatType.SENT_RESOLUTION -> call.currentParams.sentVideoDefinition?.name
StatType.RECEIVED_RESOLUTION -> call.currentParams.receivedVideoDefinition?.name
StatType.SENT_FPS -> "${call.currentParams.sentFramerate}"
StatType.RECEIVED_FPS -> "${call.currentParams.receivedFramerate}"
StatType.ESTIMATED_AVAILABLE_DOWNLOAD_BW -> "${stats.estimatedDownloadBandwidth} kbit/s"
}
}
}

View file

@ -0,0 +1,318 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.call.fragments
import android.Manifest
import android.animation.ValueAnimator
import android.annotation.TargetApi
import android.app.Dialog
import android.content.Intent
import android.content.pm.PackageManager.PERMISSION_GRANTED
import android.os.Bundle
import android.os.SystemClock
import android.view.View
import androidx.lifecycle.ViewModelProvider
import com.google.android.flexbox.FlexboxLayout
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.LinphoneApplication.Companion.corePreferences
import org.linphone.R
import org.linphone.activities.GenericFragment
import org.linphone.activities.call.viewmodels.CallsViewModel
import org.linphone.activities.call.viewmodels.ConferenceViewModel
import org.linphone.activities.call.viewmodels.ControlsViewModel
import org.linphone.activities.call.viewmodels.SharedCallViewModel
import org.linphone.activities.main.MainActivity
import org.linphone.activities.main.viewmodels.DialogViewModel
import org.linphone.core.Call
import org.linphone.core.tools.Log
import org.linphone.databinding.CallControlsFragmentBinding
import org.linphone.mediastream.Version
import org.linphone.utils.AppUtils
import org.linphone.utils.DialogUtils
import org.linphone.utils.Event
import org.linphone.utils.PermissionHelper
class ControlsFragment : GenericFragment<CallControlsFragmentBinding>() {
private lateinit var callsViewModel: CallsViewModel
private lateinit var controlsViewModel: ControlsViewModel
private lateinit var conferenceViewModel: ConferenceViewModel
private lateinit var sharedViewModel: SharedCallViewModel
private var dialog: Dialog? = null
override fun getLayoutId(): Int = R.layout.call_controls_fragment
// We have to use lateinit here because we need to compute the screen width first
private lateinit var numpadAnimator: ValueAnimator
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.lifecycleOwner = viewLifecycleOwner
useMaterialSharedAxisXForwardAnimation = false
sharedViewModel = requireActivity().run {
ViewModelProvider(this)[SharedCallViewModel::class.java]
}
callsViewModel = requireActivity().run {
ViewModelProvider(this)[CallsViewModel::class.java]
}
binding.viewModel = callsViewModel
controlsViewModel = requireActivity().run {
ViewModelProvider(this)[ControlsViewModel::class.java]
}
binding.controlsViewModel = controlsViewModel
conferenceViewModel = requireActivity().run {
ViewModelProvider(this)[ConferenceViewModel::class.java]
}
binding.conferenceViewModel = conferenceViewModel
callsViewModel.currentCallViewModel.observe(
viewLifecycleOwner,
{
if (it != null) {
binding.activeCallTimer.base =
SystemClock.elapsedRealtime() - (1000 * it.call.duration) // Linphone timestamps are in seconds
binding.activeCallTimer.start()
}
}
)
callsViewModel.noMoreCallEvent.observe(
viewLifecycleOwner,
{
it.consume {
requireActivity().finish()
}
}
)
callsViewModel.askWriteExternalStoragePermissionEvent.observe(
viewLifecycleOwner,
{
it.consume {
if (!PermissionHelper.get().hasWriteExternalStoragePermission()) {
Log.i("[Controls Fragment] Asking for WRITE_EXTERNAL_STORAGE permission")
requestPermissions(arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), 1)
}
}
}
)
callsViewModel.callUpdateEvent.observe(
viewLifecycleOwner,
{
it.consume { call ->
if (call.state == Call.State.StreamsRunning) {
dialog?.dismiss()
} else if (call.state == Call.State.UpdatedByRemote) {
if (coreContext.core.videoCaptureEnabled() || coreContext.core.videoDisplayEnabled()) {
if (call.currentParams.videoEnabled() != call.remoteParams?.videoEnabled()) {
showCallVideoUpdateDialog(call)
}
} else {
Log.w("[Controls Fragment] Video display & capture are disabled, don't show video dialog")
}
}
}
}
)
controlsViewModel.chatClickedEvent.observe(
viewLifecycleOwner,
{
it.consume {
val intent = Intent()
intent.setClass(requireContext(), MainActivity::class.java)
intent.putExtra("Chat", true)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(intent)
}
}
)
controlsViewModel.addCallClickedEvent.observe(
viewLifecycleOwner,
{
it.consume {
val intent = Intent()
intent.setClass(requireContext(), MainActivity::class.java)
intent.putExtra("Dialer", true)
intent.putExtra("Transfer", false)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(intent)
}
}
)
controlsViewModel.transferCallClickedEvent.observe(
viewLifecycleOwner,
{
it.consume {
val intent = Intent()
intent.setClass(requireContext(), MainActivity::class.java)
intent.putExtra("Dialer", true)
intent.putExtra("Transfer", true)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(intent)
}
}
)
controlsViewModel.askPermissionEvent.observe(
viewLifecycleOwner,
{
it.consume { permission ->
Log.i("[Controls Fragment] Asking for $permission permission")
requestPermissions(arrayOf(permission), 0)
}
}
)
controlsViewModel.toggleNumpadEvent.observe(
viewLifecycleOwner,
{
it.consume { open ->
if (this::numpadAnimator.isInitialized) {
if (open) {
numpadAnimator.start()
} else {
numpadAnimator.reverse()
}
}
}
}
)
controlsViewModel.somethingClickedEvent.observe(
viewLifecycleOwner,
{
it.consume {
sharedViewModel.resetHiddenInterfaceTimerInVideoCallEvent.value = Event(true)
}
}
)
if (Version.sdkAboveOrEqual(Version.API23_MARSHMALLOW_60)) {
checkPermissions()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
onBackPressedCallback.isEnabled = false
}
override fun onStart() {
super.onStart()
initNumpadLayout()
}
override fun onStop() {
numpadAnimator.end()
super.onStop()
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
if (requestCode == 0) {
for (i in permissions.indices) {
when (permissions[i]) {
Manifest.permission.RECORD_AUDIO -> if (grantResults[i] == PERMISSION_GRANTED) {
Log.i("[Controls Fragment] RECORD_AUDIO permission has been granted")
controlsViewModel.updateMuteMicState()
}
Manifest.permission.CAMERA -> if (grantResults[i] == PERMISSION_GRANTED) {
Log.i("[Controls Fragment] CAMERA permission has been granted")
coreContext.core.reloadVideoDevices()
}
}
}
} else if (requestCode == 1 && grantResults.isNotEmpty() && grantResults[0] == PERMISSION_GRANTED) {
callsViewModel.takeScreenshot()
}
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
}
@TargetApi(Version.API23_MARSHMALLOW_60)
private fun checkPermissions() {
val permissionsRequiredList = arrayListOf<String>()
if (!PermissionHelper.get().hasRecordAudioPermission()) {
Log.i("[Controls Fragment] Asking for RECORD_AUDIO permission")
permissionsRequiredList.add(Manifest.permission.RECORD_AUDIO)
}
if (coreContext.isVideoCallOrConferenceActive() && !PermissionHelper.get().hasCameraPermission()) {
Log.i("[Controls Fragment] Asking for CAMERA permission")
permissionsRequiredList.add(Manifest.permission.CAMERA)
}
if (permissionsRequiredList.isNotEmpty()) {
val permissionsRequired = arrayOfNulls<String>(permissionsRequiredList.size)
permissionsRequiredList.toArray(permissionsRequired)
requestPermissions(permissionsRequired, 0)
}
}
private fun showCallVideoUpdateDialog(call: Call) {
val viewModel = DialogViewModel(AppUtils.getString(R.string.call_video_update_requested_dialog))
dialog = DialogUtils.getDialog(requireContext(), viewModel)
viewModel.showCancelButton(
{
callsViewModel.answerCallVideoUpdateRequest(call, false)
dialog?.dismiss()
},
getString(R.string.dialog_decline)
)
viewModel.showOkButton(
{
callsViewModel.answerCallVideoUpdateRequest(call, true)
dialog?.dismiss()
},
getString(R.string.dialog_accept)
)
dialog?.show()
}
private fun initNumpadLayout() {
val screenWidth = coreContext.screenWidth
numpadAnimator = ValueAnimator.ofFloat(screenWidth, 0f).apply {
addUpdateListener {
val value = it.animatedValue as Float
view?.findViewById<FlexboxLayout>(R.id.numpad)?.translationX = -value
duration = if (corePreferences.enableAnimations) 500 else 0
}
}
// Hide the numpad here as we can't set the translationX property on include tag in layout
if (controlsViewModel.numpadVisibility.value == false) {
view?.findViewById<FlexboxLayout>(R.id.numpad)?.translationX = -screenWidth
}
}
}

View file

@ -0,0 +1,49 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.call.fragments
import android.os.Bundle
import android.view.View
import androidx.lifecycle.ViewModelProvider
import org.linphone.R
import org.linphone.activities.GenericFragment
import org.linphone.activities.call.viewmodels.StatisticsListViewModel
import org.linphone.databinding.CallStatisticsFragmentBinding
class StatisticsFragment : GenericFragment<CallStatisticsFragmentBinding>() {
private lateinit var viewModel: StatisticsListViewModel
override fun getLayoutId(): Int = R.layout.call_statistics_fragment
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.lifecycleOwner = viewLifecycleOwner
useMaterialSharedAxisXForwardAnimation = false
viewModel = ViewModelProvider(this)[StatisticsListViewModel::class.java]
binding.viewModel = viewModel
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
onBackPressedCallback.isEnabled = false
}
}

View file

@ -0,0 +1,147 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.call.fragments
import android.app.Dialog
import android.os.Bundle
import android.view.View
import androidx.lifecycle.ViewModelProvider
import java.util.*
import org.linphone.R
import org.linphone.activities.GenericFragment
import org.linphone.activities.call.viewmodels.SharedCallViewModel
import org.linphone.activities.call.viewmodels.StatusViewModel
import org.linphone.activities.main.viewmodels.DialogViewModel
import org.linphone.core.Call
import org.linphone.core.tools.Log
import org.linphone.databinding.CallStatusFragmentBinding
import org.linphone.utils.DialogUtils
import org.linphone.utils.Event
class StatusFragment : GenericFragment<CallStatusFragmentBinding>() {
private lateinit var viewModel: StatusViewModel
private lateinit var sharedViewModel: SharedCallViewModel
private var zrtpDialog: Dialog? = null
override fun getLayoutId(): Int = R.layout.call_status_fragment
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.lifecycleOwner = viewLifecycleOwner
useMaterialSharedAxisXForwardAnimation = false
viewModel = ViewModelProvider(this)[StatusViewModel::class.java]
binding.viewModel = viewModel
sharedViewModel = requireActivity().run {
ViewModelProvider(this)[SharedCallViewModel::class.java]
}
binding.setStatsClickListener {
sharedViewModel.toggleDrawerEvent.value = Event(true)
}
binding.setRefreshClickListener {
viewModel.refreshRegister()
}
viewModel.showZrtpDialogEvent.observe(
viewLifecycleOwner,
{
it.consume { call ->
if (call.state == Call.State.Connected || call.state == Call.State.StreamsRunning) {
showZrtpDialog(call)
}
}
}
)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
onBackPressedCallback.isEnabled = false
}
override fun onDestroy() {
if (zrtpDialog != null) {
zrtpDialog?.dismiss()
}
super.onDestroy()
}
private fun showZrtpDialog(call: Call) {
if (zrtpDialog != null && zrtpDialog?.isShowing == true) {
Log.e("[Status Fragment] ZRTP dialog already visible")
return
}
val token = call.authenticationToken
if (token == null || token.length < 4) {
Log.e("[Status Fragment] ZRTP token is invalid: $token")
return
}
val toRead: String
val toListen: String
when (call.dir) {
Call.Dir.Incoming -> {
toRead = token.substring(0, 2)
toListen = token.substring(2)
}
else -> {
toRead = token.substring(2)
toListen = token.substring(0, 2)
}
}
val viewModel = DialogViewModel(getString(R.string.zrtp_dialog_message), getString(R.string.zrtp_dialog_title))
viewModel.showZrtp = true
viewModel.zrtpReadSas = toRead.toUpperCase(Locale.getDefault())
viewModel.zrtpListenSas = toListen.toUpperCase(Locale.getDefault())
viewModel.showIcon = true
viewModel.iconResource = R.drawable.security_2_indicator
val dialog: Dialog = DialogUtils.getDialog(requireContext(), viewModel)
viewModel.showDeleteButton(
{
call.authenticationTokenVerified = false
this@StatusFragment.viewModel.updateEncryptionInfo(call)
dialog.dismiss()
zrtpDialog = null
},
getString(R.string.zrtp_dialog_deny_button_label)
)
viewModel.showOkButton(
{
call.authenticationTokenVerified = true
this@StatusFragment.viewModel.updateEncryptionInfo(call)
dialog.dismiss()
zrtpDialog = null
},
getString(R.string.zrtp_dialog_ok_button_label)
)
zrtpDialog = dialog
dialog.show()
}
}

View file

@ -0,0 +1,91 @@
/*
* Copyright (c) 2010-2021 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.call.fragments
import android.os.Bundle
import android.view.MotionEvent
import android.view.View
import androidx.lifecycle.ViewModelProvider
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.R
import org.linphone.activities.GenericFragment
import org.linphone.activities.call.VideoZoomHelper
import org.linphone.activities.call.viewmodels.CallsViewModel
import org.linphone.activities.call.viewmodels.ConferenceViewModel
import org.linphone.activities.call.viewmodels.ControlsFadingViewModel
import org.linphone.databinding.CallVideoFragmentBinding
class VideoRenderingFragment : GenericFragment<CallVideoFragmentBinding>() {
private lateinit var controlsFadingViewModel: ControlsFadingViewModel
private lateinit var callsViewModel: CallsViewModel
private lateinit var conferenceViewModel: ConferenceViewModel
private var previewX: Float = 0f
private var previewY: Float = 0f
private lateinit var videoZoomHelper: VideoZoomHelper
override fun getLayoutId(): Int = R.layout.call_video_fragment
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.lifecycleOwner = this
controlsFadingViewModel = requireActivity().run {
ViewModelProvider(this)[ControlsFadingViewModel::class.java]
}
binding.controlsFadingViewModel = controlsFadingViewModel
callsViewModel = requireActivity().run {
ViewModelProvider(this)[CallsViewModel::class.java]
}
conferenceViewModel = requireActivity().run {
ViewModelProvider(this)[ConferenceViewModel::class.java]
}
binding.conferenceViewModel = conferenceViewModel
coreContext.core.nativeVideoWindowId = binding.remoteVideoSurface
coreContext.core.nativePreviewWindowId = binding.localPreviewVideoSurface
binding.setPreviewTouchListener { v, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> {
previewX = v.x - event.rawX
previewY = v.y - event.rawY
}
MotionEvent.ACTION_MOVE -> {
v.animate()
.x(event.rawX + previewX)
.y(event.rawY + previewY)
.setDuration(0)
.start()
}
else -> {
v.performClick()
false
}
}
true
}
videoZoomHelper = VideoZoomHelper(requireContext(), binding.remoteVideoSurface)
}
}

View file

@ -0,0 +1,165 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.call.viewmodels
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import java.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.compatibility.Compatibility
import org.linphone.contact.GenericContactViewModel
import org.linphone.core.Call
import org.linphone.core.CallListenerStub
import org.linphone.core.Factory
import org.linphone.core.tools.Log
import org.linphone.utils.Event
import org.linphone.utils.FileUtils
import org.linphone.utils.LinphoneUtils
class CallViewModelFactory(private val call: Call) :
ViewModelProvider.NewInstanceFactory() {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return CallViewModel(call) as T
}
}
open class CallViewModel(val call: Call) : GenericContactViewModel(call.remoteAddress) {
val address: String by lazy {
LinphoneUtils.getDisplayableAddress(call.remoteAddress)
}
val isPaused = MutableLiveData<Boolean>()
val isOutgoingEarlyMedia = MutableLiveData<Boolean>()
val callEndedEvent: MutableLiveData<Event<Boolean>> by lazy {
MutableLiveData<Event<Boolean>>()
}
val callConnectedEvent: MutableLiveData<Event<Boolean>> by lazy {
MutableLiveData<Event<Boolean>>()
}
private var timer: Timer? = null
private val listener = object : CallListenerStub() {
override fun onStateChanged(call: Call, state: Call.State, message: String) {
if (call != this@CallViewModel.call) return
isPaused.value = state == Call.State.Paused
isOutgoingEarlyMedia.value = state == Call.State.OutgoingEarlyMedia
if (state == Call.State.End || state == Call.State.Released || state == Call.State.Error) {
timer?.cancel()
callEndedEvent.value = Event(true)
if (state == Call.State.Error) {
Log.e("[Call View Model] Error state reason is ${call.reason}")
}
} else if (call.state == Call.State.Connected) {
callConnectedEvent.value = Event(true)
} else if (call.state == Call.State.StreamsRunning) {
// Stop call update timer once user has accepted or declined call update
timer?.cancel()
} else if (call.state == Call.State.UpdatedByRemote) {
// User has 30 secs to accept or decline call update
// Dialog to accept or decline is handled by CallsViewModel & ControlsFragment
startTimer(call)
}
}
override fun onSnapshotTaken(call: Call, filePath: String) {
Log.i("[Call View Model] Snapshot taken, saved at $filePath")
val content = Factory.instance().createContent()
content.filePath = filePath
content.type = "image"
content.subtype = "jpeg"
content.name = filePath.substring(filePath.indexOf("/") + 1)
viewModelScope.launch {
if (Compatibility.addImageToMediaStore(coreContext.context, content)) {
Log.i("[Call View Model] Adding snapshot ${content.name} to Media Store terminated")
} else {
Log.e("[Call View Model] Something went wrong while copying file to Media Store...")
}
}
}
}
init {
call.addListener(listener)
isPaused.value = call.state == Call.State.Paused
}
override fun onCleared() {
destroy()
super.onCleared()
}
fun destroy() {
call.removeListener(listener)
}
fun terminateCall() {
coreContext.terminateCall(call)
}
fun pause() {
call.pause()
}
fun resume() {
call.resume()
}
fun takeScreenshot() {
if (call.currentParams.videoEnabled()) {
val fileName = System.currentTimeMillis().toString() + ".jpeg"
call.takeVideoSnapshot(FileUtils.getFileStoragePath(fileName).absolutePath)
}
}
private fun startTimer(call: Call) {
timer?.cancel()
timer = Timer("Call update timeout")
timer?.schedule(
object : TimerTask() {
override fun run() {
// Decline call update
viewModelScope.launch {
withContext(Dispatchers.Main) {
coreContext.answerCallVideoUpdateRequest(call, false)
}
}
}
},
30000
)
}
}

View file

@ -0,0 +1,165 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.call.viewmodels
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.core.*
import org.linphone.core.tools.Log
import org.linphone.utils.Event
import org.linphone.utils.PermissionHelper
class CallsViewModel : ViewModel() {
val currentCallViewModel = MutableLiveData<CallViewModel>()
val noActiveCall = MutableLiveData<Boolean>()
val callPausedByRemote = MutableLiveData<Boolean>()
val pausedCalls = MutableLiveData<ArrayList<CallViewModel>>()
val noMoreCallEvent: MutableLiveData<Event<Boolean>> by lazy {
MutableLiveData<Event<Boolean>>()
}
val callUpdateEvent: MutableLiveData<Event<Call>> by lazy {
MutableLiveData<Event<Call>>()
}
val askWriteExternalStoragePermissionEvent: MutableLiveData<Event<Boolean>> by lazy {
MutableLiveData<Event<Boolean>>()
}
private val listener = object : CoreListenerStub() {
override fun onCallStateChanged(core: Core, call: Call, state: Call.State, message: String) {
Log.i("[Calls VM] Call state changed: $state")
callPausedByRemote.value = (state == Call.State.PausedByRemote) and (call.conference == null)
val currentCall = core.currentCall
noActiveCall.value = currentCall == null
if (currentCall == null) {
currentCallViewModel.value?.destroy()
} else if (currentCallViewModel.value?.call != currentCall) {
val viewModel = CallViewModel(currentCall)
currentCallViewModel.value = viewModel
}
if (state == Call.State.End || state == Call.State.Released || state == Call.State.Error) {
if (core.callsNb == 0) {
noMoreCallEvent.value = Event(true)
} else {
removeCallFromPausedListIfPresent(call)
}
} else if (state == Call.State.Paused) {
addCallToPausedList(call)
} else if (state == Call.State.Resuming) {
removeCallFromPausedListIfPresent(call)
} else if (call.state == Call.State.UpdatedByRemote) {
// If the correspondent asks to turn on video while audio call,
// defer update until user has chosen whether to accept it or not
val remoteVideo = call.remoteParams?.videoEnabled() ?: false
val localVideo = call.currentParams.videoEnabled()
val autoAccept = call.core.videoActivationPolicy.automaticallyAccept
if (remoteVideo && !localVideo && !autoAccept) {
if (coreContext.core.videoCaptureEnabled() || coreContext.core.videoDisplayEnabled()) {
call.deferUpdate()
callUpdateEvent.value = Event(call)
} else {
coreContext.answerCallVideoUpdateRequest(call, false)
}
}
} else if (state == Call.State.StreamsRunning) {
callUpdateEvent.value = Event(call)
}
}
}
init {
coreContext.core.addListener(listener)
val currentCall = coreContext.core.currentCall
noActiveCall.value = currentCall == null
if (currentCall != null) {
currentCallViewModel.value?.destroy()
val viewModel = CallViewModel(currentCall)
currentCallViewModel.value = viewModel
}
callPausedByRemote.value = currentCall?.state == Call.State.PausedByRemote
for (call in coreContext.core.calls) {
if (call.state == Call.State.Paused || call.state == Call.State.Pausing) {
addCallToPausedList(call)
}
}
}
override fun onCleared() {
coreContext.core.removeListener(listener)
super.onCleared()
}
fun answerCallVideoUpdateRequest(call: Call, accept: Boolean) {
coreContext.answerCallVideoUpdateRequest(call, accept)
}
fun takeScreenshot() {
if (!PermissionHelper.get().hasWriteExternalStoragePermission()) {
askWriteExternalStoragePermissionEvent.value = Event(true)
} else {
currentCallViewModel.value?.takeScreenshot()
}
}
private fun addCallToPausedList(call: Call) {
if (call.conference != null) return // Conference will be displayed as paused, no need to display the call as well
val list = arrayListOf<CallViewModel>()
list.addAll(pausedCalls.value.orEmpty())
for (pausedCallViewModel in list) {
if (pausedCallViewModel.call == call) {
return
}
}
val viewModel = CallViewModel(call)
list.add(viewModel)
pausedCalls.value = list
}
private fun removeCallFromPausedListIfPresent(call: Call) {
val list = arrayListOf<CallViewModel>()
list.addAll(pausedCalls.value.orEmpty())
for (pausedCallViewModel in list) {
if (pausedCallViewModel.call == call) {
pausedCallViewModel.destroy()
list.remove(pausedCallViewModel)
break
}
}
pausedCalls.value = list
}
}

View file

@ -0,0 +1,157 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.call.viewmodels
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.activities.call.data.ConferenceParticipantData
import org.linphone.core.*
import org.linphone.core.tools.Log
class ConferenceViewModel : ViewModel() {
val isConferencePaused = MutableLiveData<Boolean>()
val isMeConferenceFocus = MutableLiveData<Boolean>()
val conferenceAddress = MutableLiveData<Address>()
val conferenceParticipants = MutableLiveData<List<ConferenceParticipantData>>()
val isInConference = MutableLiveData<Boolean>()
private val conferenceListener = object : ConferenceListenerStub() {
override fun onParticipantAdded(conference: Conference, participant: Participant) {
if (conference.isMe(participant.address)) {
Log.i("[Conference VM] Entered conference")
isConferencePaused.value = false
} else {
Log.i("[Conference VM] Participant added")
updateParticipantsList(conference)
}
}
override fun onParticipantRemoved(conference: Conference, participant: Participant) {
if (conference.isMe(participant.address)) {
Log.i("[Conference VM] Left conference")
isConferencePaused.value = true
} else {
Log.i("[Conference VM] Participant removed")
updateParticipantsList(conference)
}
}
override fun onParticipantAdminStatusChanged(
conference: Conference,
participant: Participant
) {
Log.i("[Conference VM] Participant admin status changed")
updateParticipantsList(conference)
}
}
private val listener = object : CoreListenerStub() {
override fun onConferenceStateChanged(
core: Core,
conference: Conference,
state: Conference.State
) {
Log.i("[Conference VM] Conference state changed: $state")
isConferencePaused.value = !conference.isIn
if (state == Conference.State.Instantiated) {
conference.addListener(conferenceListener)
} else if (state == Conference.State.Created) {
updateParticipantsList(conference)
isMeConferenceFocus.value = conference.me.isFocus
conferenceAddress.value = conference.conferenceAddress
} else if (state == Conference.State.Terminated || state == Conference.State.TerminationFailed) {
isInConference.value = false
conference.removeListener(conferenceListener)
conferenceParticipants.value = arrayListOf()
}
}
}
init {
coreContext.core.addListener(listener)
isConferencePaused.value = coreContext.core.conference?.isIn != true
isMeConferenceFocus.value = false
conferenceParticipants.value = arrayListOf()
isInConference.value = false
val conference = coreContext.core.conference
if (conference != null) {
conference.addListener(conferenceListener)
isMeConferenceFocus.value = conference.me.isFocus
updateParticipantsList(conference)
}
}
override fun onCleared() {
coreContext.core.removeListener(listener)
super.onCleared()
}
fun pauseConference() {
val defaultProxyConfig = coreContext.core.defaultProxyConfig
val localAddress = defaultProxyConfig?.identityAddress
val participants = arrayOf<Address>()
val remoteConference = coreContext.core.searchConference(null, localAddress, conferenceAddress.value, participants)
val localConference = coreContext.core.searchConference(null, conferenceAddress.value, conferenceAddress.value, participants)
val conference = remoteConference ?: localConference
if (conference != null) {
Log.i("[Conference VM] Leaving conference with address ${conferenceAddress.value?.asStringUriOnly()} temporarily")
conference.leave()
} else {
Log.w("[Conference VM] Unable to find conference with address ${conferenceAddress.value?.asStringUriOnly()}")
}
}
fun resumeConference() {
val defaultProxyConfig = coreContext.core.defaultProxyConfig
val localAddress = defaultProxyConfig?.identityAddress
val participants = arrayOf<Address>()
val remoteConference = coreContext.core.searchConference(null, localAddress, conferenceAddress.value, participants)
val localConference = coreContext.core.searchConference(null, conferenceAddress.value, conferenceAddress.value, participants)
val conference = remoteConference ?: localConference
if (conference != null) {
Log.i("[Conference VM] Entering again conference with address ${conferenceAddress.value?.asStringUriOnly()}")
conference.enter()
} else {
Log.w("[Conference VM] Unable to find conference with address ${conferenceAddress.value?.asStringUriOnly()}")
}
}
private fun updateParticipantsList(conference: Conference) {
val participants = arrayListOf<ConferenceParticipantData>()
for (participant in conference.participantList) {
Log.i("[Conference VM] Participant found: ${participant.address.asStringUriOnly()}")
val viewModel = ConferenceParticipantData(conference, participant)
participants.add(viewModel)
}
conferenceParticipants.value = participants
isInConference.value = participants.isNotEmpty()
}
}

View file

@ -0,0 +1,154 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.call.viewmodels
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import java.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
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
import org.linphone.core.tools.Log
class ControlsFadingViewModel : ViewModel() {
val areControlsHidden = MutableLiveData<Boolean>()
val isVideoPreviewHidden = MutableLiveData<Boolean>()
val isVideoPreviewResizedForPip = MutableLiveData<Boolean>()
val videoEnabled = MutableLiveData<Boolean>()
val proximitySensorEnabled: MediatorLiveData<Boolean> = MediatorLiveData()
private val nonEarpieceOutputAudioDevice = MutableLiveData<Boolean>()
private var timer: Timer? = null
private var disabled: Boolean = false
private val listener = object : CoreListenerStub() {
override fun onCallStateChanged(
core: Core,
call: Call,
state: Call.State,
message: String
) {
if (state == Call.State.StreamsRunning || state == Call.State.Updating || state == Call.State.UpdatedByRemote) {
val isVideoCall = coreContext.isVideoCallOrConferenceActive()
Log.i("[Controls Fading] Call is in state $state, video is ${if (isVideoCall) "enabled" else "disabled"}")
if (isVideoCall) {
videoEnabled.value = true
startTimer()
} else {
videoEnabled.value = false
stopTimer()
}
}
}
override fun onAudioDeviceChanged(core: Core, audioDevice: AudioDevice) {
if (audioDevice.hasCapability(AudioDevice.Capabilities.CapabilityPlay)) {
Log.i("[Controls Fading] Output audio device changed to: ${audioDevice.id}")
nonEarpieceOutputAudioDevice.value = audioDevice.type != AudioDevice.Type.Earpiece
}
}
}
init {
coreContext.core.addListener(listener)
areControlsHidden.value = false
isVideoPreviewHidden.value = false
isVideoPreviewResizedForPip.value = false
nonEarpieceOutputAudioDevice.value = coreContext.core.outputAudioDevice?.type != AudioDevice.Type.Earpiece
val isVideoCall = coreContext.isVideoCallOrConferenceActive()
videoEnabled.value = isVideoCall
if (isVideoCall) {
startTimer()
}
proximitySensorEnabled.value = shouldEnableProximitySensor()
proximitySensorEnabled.addSource(videoEnabled) {
proximitySensorEnabled.value = shouldEnableProximitySensor()
}
proximitySensorEnabled.addSource(nonEarpieceOutputAudioDevice) {
proximitySensorEnabled.value = shouldEnableProximitySensor()
}
}
override fun onCleared() {
coreContext.core.removeListener(listener)
stopTimer()
super.onCleared()
}
fun showMomentarily() {
stopTimer()
startTimer()
}
fun disable(disable: Boolean) {
disabled = disable
if (disabled) {
stopTimer()
} else {
startTimer()
}
}
private fun shouldEnableProximitySensor(): Boolean {
return !(videoEnabled.value ?: false) && !(nonEarpieceOutputAudioDevice.value ?: false)
}
private fun stopTimer() {
timer?.cancel()
areControlsHidden.value = false
}
private fun startTimer() {
timer?.cancel()
if (disabled) return
timer = Timer("Hide UI controls scheduler")
timer?.schedule(
object : TimerTask() {
override fun run() {
viewModelScope.launch {
withContext(Dispatchers.Main) {
val videoEnabled = coreContext.isVideoCallOrConferenceActive()
areControlsHidden.postValue(videoEnabled)
}
}
}
},
3000
)
}
}

View file

@ -0,0 +1,476 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.call.viewmodels
import android.Manifest
import android.animation.ValueAnimator
import android.content.Context
import android.os.Vibrator
import android.view.animation.LinearInterpolator
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlin.math.max
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.LinphoneApplication.Companion.corePreferences
import org.linphone.R
import org.linphone.activities.main.dialer.NumpadDigitListener
import org.linphone.compatibility.Compatibility
import org.linphone.core.*
import org.linphone.core.tools.Log
import org.linphone.utils.*
import org.linphone.utils.Event
class ControlsViewModel : ViewModel() {
val isMicrophoneMuted = MutableLiveData<Boolean>()
val isMuteMicrophoneEnabled = MutableLiveData<Boolean>()
val isSpeakerSelected = MutableLiveData<Boolean>()
val isBluetoothHeadsetSelected = MutableLiveData<Boolean>()
val isVideoAvailable = MutableLiveData<Boolean>()
val isVideoEnabled = MutableLiveData<Boolean>()
val isVideoUpdateInProgress = MutableLiveData<Boolean>()
val showSwitchCamera = MutableLiveData<Boolean>()
val isPauseEnabled = MutableLiveData<Boolean>()
val isRecording = MutableLiveData<Boolean>()
val isConferencingAvailable = MutableLiveData<Boolean>()
val unreadMessagesCount = MutableLiveData<Int>()
val numpadVisibility = MutableLiveData<Boolean>()
val optionsVisibility = MutableLiveData<Boolean>()
val audioRoutesSelected = MutableLiveData<Boolean>()
val audioRoutesEnabled = MutableLiveData<Boolean>()
val takeScreenshotEnabled = MutableLiveData<Boolean>()
val chatClickedEvent: MutableLiveData<Event<Boolean>> by lazy {
MutableLiveData<Event<Boolean>>()
}
val addCallClickedEvent: MutableLiveData<Event<Boolean>> by lazy {
MutableLiveData<Event<Boolean>>()
}
val transferCallClickedEvent: MutableLiveData<Event<Boolean>> by lazy {
MutableLiveData<Event<Boolean>>()
}
val askPermissionEvent: MutableLiveData<Event<String>> by lazy {
MutableLiveData<Event<String>>()
}
val somethingClickedEvent = MutableLiveData<Event<Boolean>>()
val chatAllowed = !corePreferences.disableChat
private val vibrator = coreContext.context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
val chatUnreadCountTranslateY = MutableLiveData<Float>()
val optionsMenuTranslateY = MutableLiveData<Float>()
val audioRoutesMenuTranslateY = MutableLiveData<Float>()
val toggleNumpadEvent: MutableLiveData<Event<Boolean>> by lazy {
MutableLiveData<Event<Boolean>>()
}
private val bounceAnimator: ValueAnimator by lazy {
ValueAnimator.ofFloat(AppUtils.getDimension(R.dimen.tabs_fragment_unread_count_bounce_offset), 0f).apply {
addUpdateListener {
val value = it.animatedValue as Float
chatUnreadCountTranslateY.value = -value
}
interpolator = LinearInterpolator()
duration = 250
repeatMode = ValueAnimator.REVERSE
repeatCount = ValueAnimator.INFINITE
}
}
private val optionsMenuAnimator: ValueAnimator by lazy {
ValueAnimator.ofFloat(AppUtils.getDimension(R.dimen.call_options_menu_translate_y), 0f).apply {
addUpdateListener {
val value = it.animatedValue as Float
optionsMenuTranslateY.value = value
}
duration = if (corePreferences.enableAnimations) 500 else 0
}
}
private val audioRoutesMenuAnimator: ValueAnimator by lazy {
ValueAnimator.ofFloat(AppUtils.getDimension(R.dimen.call_audio_routes_menu_translate_y), 0f).apply {
addUpdateListener {
val value = it.animatedValue as Float
audioRoutesMenuTranslateY.value = value
}
duration = if (corePreferences.enableAnimations) 500 else 0
}
}
val onKeyClick: NumpadDigitListener = object : NumpadDigitListener {
override fun handleClick(key: Char) {
coreContext.core.playDtmf(key, 1)
somethingClickedEvent.value = Event(true)
coreContext.core.currentCall?.sendDtmf(key)
if (vibrator.hasVibrator() && corePreferences.dtmfKeypadVibration) {
Compatibility.eventVibration(vibrator)
}
}
override fun handleLongClick(key: Char): Boolean {
return true
}
}
private val listener: CoreListenerStub = object : CoreListenerStub() {
override fun onMessageReceived(core: Core, chatRoom: ChatRoom, message: ChatMessage) {
updateUnreadChatCount()
}
override fun onChatRoomRead(core: Core, chatRoom: ChatRoom) {
updateUnreadChatCount()
}
override fun onCallStateChanged(
core: Core,
call: Call,
state: Call.State,
message: String
) {
if (state == Call.State.StreamsRunning) {
isVideoUpdateInProgress.value = false
}
if (coreContext.isVideoCallOrConferenceActive() && !PermissionHelper.get().hasCameraPermission()) {
askPermissionEvent.value = Event(Manifest.permission.CAMERA)
}
updateUI()
}
override fun onAudioDeviceChanged(core: Core, audioDevice: AudioDevice) {
Log.i("[Call] Audio device changed: ${audioDevice.deviceName}")
updateSpeakerState()
updateBluetoothHeadsetState()
}
override fun onAudioDevicesListUpdated(core: Core) {
Log.i("[Call] Audio devices list updated")
val wasBluetoothPreviouslyAvailable = audioRoutesEnabled.value == true
updateAudioRoutesState()
if (AudioRouteUtils.isHeadsetAudioRouteAvailable()) {
AudioRouteUtils.routeAudioToHeadset()
} else if (!wasBluetoothPreviouslyAvailable && corePreferences.routeAudioToBluetoothIfAvailable) {
// Only attempt to route audio to bluetooth automatically when bluetooth device is connected
if (AudioRouteUtils.isBluetoothAudioRouteAvailable()) {
AudioRouteUtils.routeAudioToBluetooth()
}
}
}
}
init {
coreContext.core.addListener(listener)
val currentCall = coreContext.core.currentCall
updateMuteMicState()
updateAudioRelated()
updateUnreadChatCount()
numpadVisibility.value = false
optionsVisibility.value = false
audioRoutesSelected.value = false
isRecording.value = currentCall?.isRecording
isVideoUpdateInProgress.value = false
showSwitchCamera.value = coreContext.showSwitchCameraButton()
chatUnreadCountTranslateY.value = 0f
optionsMenuTranslateY.value = AppUtils.getDimension(R.dimen.call_options_menu_translate_y)
audioRoutesMenuTranslateY.value = AppUtils.getDimension(R.dimen.call_audio_routes_menu_translate_y)
takeScreenshotEnabled.value = corePreferences.showScreenshotButton
updateUI()
if (corePreferences.enableAnimations) bounceAnimator.start()
}
override fun onCleared() {
if (corePreferences.enableAnimations) bounceAnimator.end()
optionsMenuAnimator.end()
audioRoutesMenuAnimator.end()
coreContext.core.removeListener(listener)
super.onCleared()
}
fun updateUnreadChatCount() {
unreadMessagesCount.value = coreContext.core.unreadChatMessageCountFromActiveLocals
}
fun toggleMuteMicrophone() {
if (!PermissionHelper.get().hasRecordAudioPermission()) {
askPermissionEvent.value = Event(Manifest.permission.RECORD_AUDIO)
return
}
somethingClickedEvent.value = Event(true)
val micEnabled = coreContext.core.micEnabled()
coreContext.core.enableMic(!micEnabled)
updateMuteMicState()
}
fun toggleSpeaker() {
somethingClickedEvent.value = Event(true)
if (AudioRouteUtils.isSpeakerAudioRouteCurrentlyUsed()) {
forceEarpieceAudioRoute()
} else {
forceSpeakerAudioRoute()
}
}
fun switchCamera() {
somethingClickedEvent.value = Event(true)
coreContext.switchCamera()
}
fun terminateCall() {
val core = coreContext.core
when {
core.currentCall != null -> core.currentCall?.terminate()
core.conference?.isIn == true -> core.terminateConference()
else -> core.terminateAllCalls()
}
}
fun toggleVideo() {
if (!PermissionHelper.get().hasCameraPermission()) {
askPermissionEvent.value = Event(Manifest.permission.CAMERA)
return
}
val core = coreContext.core
val currentCall = core.currentCall
val conference = core.conference
if (conference != null && conference.isIn) {
val params = core.createConferenceParams()
val videoEnabled = conference.currentParams.isVideoEnabled
params.isVideoEnabled = !videoEnabled
Log.i("[Controls VM] Conference current param for video is $videoEnabled")
conference.updateParams(params)
} else if (currentCall != null) {
val state = currentCall.state
if (state == Call.State.End || state == Call.State.Released || state == Call.State.Error)
return
isVideoUpdateInProgress.value = true
val params = core.createCallParams(currentCall)
params?.enableVideo(!currentCall.currentParams.videoEnabled())
currentCall.update(params)
}
}
fun toggleOptionsMenu() {
somethingClickedEvent.value = Event(true)
optionsVisibility.value = optionsVisibility.value != true
if (optionsVisibility.value == true) {
optionsMenuAnimator.start()
} else {
optionsMenuAnimator.reverse()
}
}
fun toggleNumpadVisibility() {
somethingClickedEvent.value = Event(true)
numpadVisibility.value = numpadVisibility.value != true
toggleNumpadEvent.value = Event(numpadVisibility.value ?: true)
}
fun toggleRoutesMenu() {
somethingClickedEvent.value = Event(true)
audioRoutesSelected.value = audioRoutesSelected.value != true
if (audioRoutesSelected.value == true) {
audioRoutesMenuAnimator.start()
} else {
audioRoutesMenuAnimator.reverse()
}
}
fun toggleRecording(closeMenu: Boolean) {
somethingClickedEvent.value = Event(true)
val core = coreContext.core
val currentCall = core.currentCall
val conference = core.conference
if (currentCall != null) {
if (currentCall.isRecording) {
currentCall.stopRecording()
} else {
currentCall.startRecording()
}
isRecording.value = currentCall.isRecording
} else if (conference != null) {
val path = LinphoneUtils.getRecordingFilePathForConference()
if (conference.isRecording) {
conference.stopRecording()
} else {
conference.startRecording(path)
}
isRecording.value = conference.isRecording
} else {
isRecording.value = false
}
if (closeMenu) toggleOptionsMenu()
}
fun onChatClicked() {
chatClickedEvent.value = Event(true)
}
fun onAddCallClicked() {
addCallClickedEvent.value = Event(true)
toggleOptionsMenu()
}
fun onTransferCallClicked() {
transferCallClickedEvent.value = Event(true)
toggleOptionsMenu()
}
fun startConference() {
somethingClickedEvent.value = Event(true)
val core = coreContext.core
val currentCallVideoEnabled = core.currentCall?.currentParams?.videoEnabled() ?: false
val params = core.createConferenceParams()
params.isVideoEnabled = currentCallVideoEnabled
Log.i("[Call] Setting videoEnabled to [$currentCallVideoEnabled] in conference params")
val conference = core.conference ?: core.createConferenceWithParams(params)
conference?.addParticipants(core.calls)
toggleOptionsMenu()
}
fun forceEarpieceAudioRoute() {
somethingClickedEvent.value = Event(true)
if (AudioRouteUtils.isHeadsetAudioRouteAvailable()) {
Log.i("[Call] Headset found, route audio to it instead of earpiece")
AudioRouteUtils.routeAudioToHeadset()
} else {
AudioRouteUtils.routeAudioToEarpiece()
}
}
fun forceSpeakerAudioRoute() {
somethingClickedEvent.value = Event(true)
AudioRouteUtils.routeAudioToSpeaker()
}
fun forceBluetoothAudioRoute() {
somethingClickedEvent.value = Event(true)
AudioRouteUtils.routeAudioToBluetooth()
}
fun updateMuteMicState() {
isMicrophoneMuted.value = !PermissionHelper.get().hasRecordAudioPermission() || !coreContext.core.micEnabled()
isMuteMicrophoneEnabled.value = coreContext.core.currentCall != null || coreContext.core.conference?.isIn == true
}
private fun updateAudioRelated() {
updateSpeakerState()
updateBluetoothHeadsetState()
updateAudioRoutesState()
}
private fun updateUI() {
val currentCall = coreContext.core.currentCall
updateVideoAvailable()
updateVideoEnabled()
isPauseEnabled.value = currentCall != null && !currentCall.mediaInProgress()
isMuteMicrophoneEnabled.value = currentCall != null || coreContext.core.conference?.isIn == true
updateConferenceState()
// Check periodically until mediaInProgress is false
if (currentCall != null && currentCall.mediaInProgress()) {
viewModelScope.launch {
delay(1000)
updateUI()
}
}
}
private fun updateSpeakerState() {
isSpeakerSelected.value = AudioRouteUtils.isSpeakerAudioRouteCurrentlyUsed()
}
private fun updateAudioRoutesState() {
val bluetoothDeviceAvailable = AudioRouteUtils.isBluetoothAudioRouteAvailable()
audioRoutesEnabled.value = bluetoothDeviceAvailable
if (!bluetoothDeviceAvailable) {
audioRoutesSelected.value = false
}
}
private fun updateBluetoothHeadsetState() {
isBluetoothHeadsetSelected.value = AudioRouteUtils.isBluetoothAudioRouteCurrentlyUsed()
}
private fun updateVideoAvailable() {
val core = coreContext.core
val currentCall = core.currentCall
isVideoAvailable.value = (core.videoCaptureEnabled() || core.videoPreviewEnabled()) &&
(
(currentCall != null && !currentCall.mediaInProgress()) ||
core.conference?.isIn == true
)
}
private fun updateVideoEnabled() {
val enabled = coreContext.isVideoCallOrConferenceActive()
isVideoEnabled.value = enabled
}
private fun updateConferenceState() {
val core = coreContext.core
isConferencingAvailable.value = core.callsNb > max(1, core.conference?.participantCount ?: 0) && !core.soundResourcesLocked()
}
}

View file

@ -0,0 +1,81 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.call.viewmodels
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.LinphoneApplication.Companion.corePreferences
import org.linphone.core.*
import org.linphone.utils.Event
class IncomingCallViewModelFactory(private val call: Call) :
ViewModelProvider.NewInstanceFactory() {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return IncomingCallViewModel(call) as T
}
}
class IncomingCallViewModel(call: Call) : CallViewModel(call) {
val screenLocked = MutableLiveData<Boolean>()
val earlyMediaVideoEnabled = MutableLiveData<Boolean>()
val inviteWithVideo = MutableLiveData<Boolean>()
private val listener = object : CoreListenerStub() {
override fun onCallStateChanged(
core: Core,
call: Call,
state: Call.State,
message: String
) {
if (core.callsNb == 0) {
callEndedEvent.value = Event(true)
}
}
}
init {
coreContext.core.addListener(listener)
screenLocked.value = false
inviteWithVideo.value = call.remoteParams?.videoEnabled() == true && coreContext.core.videoActivationPolicy.automaticallyAccept
earlyMediaVideoEnabled.value = corePreferences.acceptEarlyMedia &&
call.state == Call.State.IncomingEarlyMedia &&
call.currentParams.videoEnabled()
}
override fun onCleared() {
coreContext.core.removeListener(listener)
super.onCleared()
}
fun answer(doAction: Boolean) {
if (doAction) coreContext.answerCall(call)
}
fun decline(doAction: Boolean) {
if (doAction) coreContext.declineCall(call)
}
}

View file

@ -0,0 +1,30 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.call.viewmodels
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import org.linphone.utils.Event
class SharedCallViewModel : ViewModel() {
val toggleDrawerEvent = MutableLiveData<Event<Boolean>>()
val resetHiddenInterfaceTimerInVideoCallEvent = MutableLiveData<Event<Boolean>>()
}

View file

@ -0,0 +1,68 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.call.viewmodels
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.activities.call.data.CallStatisticsData
import org.linphone.core.Call
import org.linphone.core.Core
import org.linphone.core.CoreListenerStub
class StatisticsListViewModel : ViewModel() {
val callStatsList = MutableLiveData<ArrayList<CallStatisticsData>>()
private val listener = object : CoreListenerStub() {
override fun onCallStateChanged(
core: Core,
call: Call,
state: Call.State,
message: String
) {
if (state == Call.State.End || state == Call.State.Error || state == Call.State.Connected) {
computeCallsList()
}
}
}
init {
coreContext.core.addListener(listener)
computeCallsList()
}
override fun onCleared() {
callStatsList.value.orEmpty().forEach(CallStatisticsData::destroy)
coreContext.core.removeListener(listener)
super.onCleared()
}
private fun computeCallsList() {
val list = arrayListOf<CallStatisticsData>()
for (call in coreContext.core.calls) {
if (call.state != Call.State.End && call.state != Call.State.Released && call.state != Call.State.Error) {
list.add(CallStatisticsData(call))
}
}
callStatsList.value = list
}
}

View file

@ -0,0 +1,153 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.call.viewmodels
import androidx.lifecycle.MutableLiveData
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.R
import org.linphone.activities.main.viewmodels.StatusViewModel
import org.linphone.core.*
import org.linphone.utils.Event
class StatusViewModel : StatusViewModel() {
val callQualityIcon = MutableLiveData<Int>()
val callQualityContentDescription = MutableLiveData<Int>()
val encryptionIcon = MutableLiveData<Int>()
val encryptionContentDescription = MutableLiveData<Int>()
val encryptionIconVisible = MutableLiveData<Boolean>()
val showZrtpDialogEvent: MutableLiveData<Event<Call>> by lazy {
MutableLiveData<Event<Call>>()
}
private val listener = object : CoreListenerStub() {
override fun onCallStatsUpdated(core: Core, call: Call, stats: CallStats) {
updateCallQualityIcon()
}
override fun onCallEncryptionChanged(
core: Core,
call: Call,
on: Boolean,
authenticationToken: String?
) {
if (call.currentParams.mediaEncryption == MediaEncryption.ZRTP && !call.authenticationTokenVerified) {
showZrtpDialogEvent.value = Event(call)
} else {
updateEncryptionInfo(call)
}
}
override fun onCallStateChanged(
core: Core,
call: Call,
state: Call.State,
message: String
) {
if (call == core.currentCall) {
updateEncryptionInfo(call)
}
}
}
init {
coreContext.core.addListener(listener)
updateCallQualityIcon()
val currentCall = coreContext.core.currentCall
if (currentCall != null) {
updateEncryptionInfo(currentCall)
if (currentCall.currentParams.mediaEncryption == MediaEncryption.ZRTP && !currentCall.authenticationTokenVerified) {
showZrtpDialogEvent.value = Event(currentCall)
}
}
}
override fun onCleared() {
coreContext.core.removeListener(listener)
super.onCleared()
}
fun showZrtpDialog() {
val currentCall = coreContext.core.currentCall
if (currentCall?.currentParams?.mediaEncryption == MediaEncryption.ZRTP) {
showZrtpDialogEvent.value = Event(currentCall)
}
}
fun updateEncryptionInfo(call: Call) {
if (call.dir == Call.Dir.Incoming && call.state == Call.State.IncomingReceived && call.core.isMediaEncryptionMandatory) {
// If the incoming call view is displayed while encryption is mandatory,
// we can safely show the security_ok icon
encryptionIcon.value = R.drawable.security_ok
encryptionIconVisible.value = true
encryptionContentDescription.value = R.string.content_description_call_secured
return
}
when (call.currentParams.mediaEncryption ?: MediaEncryption.None) {
MediaEncryption.SRTP, MediaEncryption.DTLS -> {
encryptionIcon.value = R.drawable.security_ok
encryptionIconVisible.value = true
encryptionContentDescription.value = R.string.content_description_call_secured
}
MediaEncryption.ZRTP -> {
encryptionIcon.value = when (call.authenticationTokenVerified) {
true -> R.drawable.security_ok
else -> R.drawable.security_pending
}
encryptionContentDescription.value = when (call.authenticationTokenVerified) {
true -> R.string.content_description_call_secured
else -> R.string.content_description_call_security_pending
}
encryptionIconVisible.value = true
}
MediaEncryption.None -> {
encryptionIcon.value = R.drawable.security_ko
// Do not show unsecure icon if user doesn't want to do call encryption
encryptionIconVisible.value = call.core.mediaEncryption != MediaEncryption.None
encryptionContentDescription.value = R.string.content_description_call_not_secured
}
}
}
private fun updateCallQualityIcon() {
val call = coreContext.core.currentCall ?: coreContext.core.calls.firstOrNull()
val quality = call?.currentQuality ?: 0f
callQualityIcon.value = when {
quality >= 4 -> R.drawable.call_quality_indicator_4
quality >= 3 -> R.drawable.call_quality_indicator_3
quality >= 2 -> R.drawable.call_quality_indicator_2
quality >= 1 -> R.drawable.call_quality_indicator_1
else -> R.drawable.call_quality_indicator_0
}
callQualityContentDescription.value = when {
quality >= 4 -> R.string.content_description_call_quality_4
quality >= 3 -> R.string.content_description_call_quality_3
quality >= 2 -> R.string.content_description_call_quality_2
quality >= 1 -> R.string.content_description_call_quality_1
else -> R.string.content_description_call_quality_0
}
}
}

View file

@ -0,0 +1,180 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.call.views
import android.animation.AnimatorSet
import android.animation.ObjectAnimator
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.View.OnTouchListener
import android.view.animation.LinearInterpolator
import android.widget.LinearLayout
import androidx.databinding.DataBindingUtil
import org.linphone.LinphoneApplication.Companion.corePreferences
import org.linphone.R
import org.linphone.activities.call.viewmodels.IncomingCallViewModel
import org.linphone.core.tools.Log
import org.linphone.databinding.CallIncomingAnswerDeclineButtonsBinding
class AnswerDeclineIncomingCallButtons : LinearLayout {
private lateinit var binding: CallIncomingAnswerDeclineButtonsBinding
private var mBegin = false
private var mDeclineX = 0f
private var mAnswerX = 0f
private var mOldSize = 0f
private val mAnswerTouchListener = OnTouchListener { view, motionEvent ->
val curX: Float
when (motionEvent.action) {
MotionEvent.ACTION_DOWN -> {
binding.declineButton.visibility = View.GONE
mAnswerX = motionEvent.x - view.width
mBegin = true
mOldSize = 0f
}
MotionEvent.ACTION_MOVE -> {
curX = motionEvent.x - view.width
view.scrollBy((mAnswerX - curX).toInt(), view.scrollY)
mOldSize -= mAnswerX - curX
mAnswerX = curX
if (mOldSize < -25) mBegin = false
if (curX < (width / 4) - view.width && !mBegin) {
binding.viewModel?.answer(true)
}
}
MotionEvent.ACTION_UP -> {
binding.declineButton.visibility = View.VISIBLE
view.scrollTo(0, view.scrollY)
}
}
true
}
private val mDeclineTouchListener = OnTouchListener { view, motionEvent ->
val curX: Float
when (motionEvent.action) {
MotionEvent.ACTION_DOWN -> {
binding.answerButton.visibility = View.GONE
mDeclineX = motionEvent.x
}
MotionEvent.ACTION_MOVE -> {
curX = motionEvent.x
view.scrollBy((mDeclineX - curX).toInt(), view.scrollY)
mDeclineX = curX
if (curX > 3 * width / 4) {
binding.viewModel?.decline(true)
}
}
MotionEvent.ACTION_UP -> {
binding.answerButton.visibility = View.VISIBLE
view.scrollTo(0, view.scrollY)
}
}
true
}
constructor(context: Context) : super(context) {
init(context)
}
constructor(context: Context, attrs: AttributeSet) : super(
context,
attrs
) {
init(context)
}
constructor(
context: Context,
attrs: AttributeSet,
defStyleAttr: Int
) : super(context, attrs, defStyleAttr) {
init(context)
}
fun setViewModel(viewModel: IncomingCallViewModel) {
binding.viewModel = viewModel
updateSlideMode()
}
private fun init(context: Context) {
binding = DataBindingUtil.inflate(
LayoutInflater.from(context), R.layout.call_incoming_answer_decline_buttons, this, true
)
updateSlideMode()
configureAnimation()
}
private fun updateSlideMode() {
val slideMode = binding.viewModel?.screenLocked?.value == true
Log.i("[Call Incoming Decline Button] Slide mode is $slideMode")
if (slideMode) {
binding.answerButton.setOnTouchListener(mAnswerTouchListener)
binding.declineButton.setOnTouchListener(mDeclineTouchListener)
}
}
private fun configureAnimation() {
if (!corePreferences.enableAnimations) return
val accept1 = ObjectAnimator.ofFloat(binding.arrowAccept1, "alpha", 1f, 0.6f, 0.4f, 1f).apply {
repeatCount = ObjectAnimator.INFINITE
repeatMode = ObjectAnimator.RESTART
}
val accept2 = ObjectAnimator.ofFloat(binding.arrowAccept2, "alpha", 0.6f, 1f, 0.4f, 0.6f).apply {
repeatCount = ObjectAnimator.INFINITE
repeatMode = ObjectAnimator.RESTART
}
val accept3 = ObjectAnimator.ofFloat(binding.arrowAccept3, "alpha", 0.4f, 0.6f, 1f, 0.4f).apply {
repeatCount = ObjectAnimator.INFINITE
repeatMode = ObjectAnimator.RESTART
}
val hangup1 = ObjectAnimator.ofFloat(binding.arrowHangup1, "alpha", 1f, 0.6f, 0.4f, 1f).apply {
repeatCount = ObjectAnimator.INFINITE
repeatMode = ObjectAnimator.RESTART
}
val hangup2 = ObjectAnimator.ofFloat(binding.arrowHangup2, "alpha", 0.6f, 1f, 0.4f, 0.6f).apply {
repeatCount = ObjectAnimator.INFINITE
repeatMode = ObjectAnimator.RESTART
}
val hangup3 = ObjectAnimator.ofFloat(binding.arrowHangup3, "alpha", 0.4f, 0.6f, 1f, 0.4f).apply {
repeatCount = ObjectAnimator.INFINITE
repeatMode = ObjectAnimator.RESTART
}
AnimatorSet().apply {
duration = 2000
interpolator = LinearInterpolator()
playTogether(accept1, accept2, accept3, hangup1, hangup2, hangup3)
start()
}
}
}

View file

@ -0,0 +1,71 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.call.views
import android.content.Context
import android.os.SystemClock
import android.util.AttributeSet
import android.view.LayoutInflater
import android.widget.LinearLayout
import androidx.databinding.DataBindingUtil
import org.linphone.R
import org.linphone.activities.call.data.ConferenceParticipantData
import org.linphone.core.tools.Log
import org.linphone.databinding.CallConferenceParticipantBinding
class ConferenceParticipantView : LinearLayout {
private lateinit var binding: CallConferenceParticipantBinding
constructor(context: Context) : super(context) {
init(context)
}
constructor(context: Context, attrs: AttributeSet) : super(
context,
attrs
) {
init(context)
}
constructor(
context: Context,
attrs: AttributeSet,
defStyleAttr: Int
) : super(context, attrs, defStyleAttr) {
init(context)
}
fun init(context: Context) {
binding = DataBindingUtil.inflate(
LayoutInflater.from(context), R.layout.call_conference_participant, this, true
)
}
fun setData(data: ConferenceParticipantData) {
binding.data = data
val currentTimeSecs = System.currentTimeMillis()
val participantTime = data.participant.creationTime * 1000 // Linphone timestamps are in seconds
val diff = currentTimeSecs - participantTime
Log.i("[Conference Participant] Participant joined conference at $participantTime == ${diff / 1000} seconds ago.")
binding.callTimer.base = SystemClock.elapsedRealtime() - diff
binding.callTimer.start()
}
}

View file

@ -0,0 +1,67 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.call.views
import android.content.Context
import android.os.SystemClock
import android.util.AttributeSet
import android.view.LayoutInflater
import android.widget.LinearLayout
import androidx.databinding.DataBindingUtil
import org.linphone.R
import org.linphone.activities.call.viewmodels.CallViewModel
import org.linphone.databinding.CallPausedBinding
class PausedCallView : LinearLayout {
private lateinit var binding: CallPausedBinding
constructor(context: Context) : super(context) {
init(context)
}
constructor(context: Context, attrs: AttributeSet) : super(
context,
attrs
) {
init(context)
}
constructor(
context: Context,
attrs: AttributeSet,
defStyleAttr: Int
) : super(context, attrs, defStyleAttr) {
init(context)
}
fun init(context: Context) {
binding = DataBindingUtil.inflate(
LayoutInflater.from(context), R.layout.call_paused, this, true
)
}
fun setViewModel(viewModel: CallViewModel) {
binding.viewModel = viewModel
binding.callTimer.base =
SystemClock.elapsedRealtime() - (1000 * viewModel.call.duration) // Linphone timestamps are in seconds
binding.callTimer.start()
}
}

View file

@ -0,0 +1,198 @@
/*
* Copyright (c) 2010-2021 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.chat_bubble
import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.databinding.DataBindingUtil
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.R
import org.linphone.activities.GenericActivity
import org.linphone.activities.main.MainActivity
import org.linphone.activities.main.chat.adapters.ChatMessagesListAdapter
import org.linphone.activities.main.chat.viewmodels.*
import org.linphone.activities.main.viewmodels.ListTopBarViewModel
import org.linphone.core.ChatRoom
import org.linphone.core.Factory
import org.linphone.core.tools.Log
import org.linphone.databinding.ChatBubbleActivityBinding
import org.linphone.utils.FileUtils
class ChatBubbleActivity : GenericActivity() {
private lateinit var binding: ChatBubbleActivityBinding
private lateinit var viewModel: ChatRoomViewModel
private lateinit var listViewModel: ChatMessagesListViewModel
private lateinit var chatSendingViewModel: ChatMessageSendingViewModel
private lateinit var adapter: ChatMessagesListAdapter
private val observer = object : RecyclerView.AdapterDataObserver() {
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
if (positionStart == adapter.itemCount - itemCount) {
adapter.notifyItemChanged(positionStart - 1) // For grouping purposes
scrollToBottom()
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = DataBindingUtil.setContentView(this, R.layout.chat_bubble_activity)
binding.lifecycleOwner = this
val localSipUri = intent.getStringExtra("LocalSipUri")
val remoteSipUri = intent.getStringExtra("RemoteSipUri")
var chatRoom: ChatRoom? = null
if (localSipUri != null && remoteSipUri != null) {
Log.i("[Chat Bubble] Found local [$localSipUri] & remote [$remoteSipUri] addresses in arguments")
val localAddress = Factory.instance().createAddress(localSipUri)
val remoteSipAddress = Factory.instance().createAddress(remoteSipUri)
chatRoom = coreContext.core.searchChatRoom(
null, localAddress, remoteSipAddress,
arrayOfNulls(
0
)
)
}
if (chatRoom == null) {
Log.e("[Chat Bubble] Chat room is null, aborting!")
finish()
return
}
// Workaround for the removed notification when a chat room is marked as read
coreContext.notificationsManager.dismissNotificationUponReadChatRoom = false
chatRoom.markAsRead()
coreContext.notificationsManager.dismissNotificationUponReadChatRoom = true
viewModel = ViewModelProvider(
this,
ChatRoomViewModelFactory(chatRoom)
)[ChatRoomViewModel::class.java]
binding.viewModel = viewModel
listViewModel = ViewModelProvider(
this,
ChatMessagesListViewModelFactory(chatRoom)
)[ChatMessagesListViewModel::class.java]
chatSendingViewModel = ViewModelProvider(
this,
ChatMessageSendingViewModelFactory(chatRoom)
)[ChatMessageSendingViewModel::class.java]
binding.chatSendingViewModel = chatSendingViewModel
val listSelectionViewModel = ViewModelProvider(this)[ListTopBarViewModel::class.java]
adapter = ChatMessagesListAdapter(listSelectionViewModel, this)
// SubmitList is done on a background thread
// We need this adapter data observer to know when to scroll
binding.chatMessagesList.adapter = adapter
adapter.registerAdapterDataObserver(observer)
// Disable context menu on each message
adapter.disableContextMenu()
adapter.openContentEvent.observe(
this,
{
it.consume { content ->
if (content.isFileEncrypted) {
Toast.makeText(this, R.string.chat_bubble_cant_open_enrypted_file, Toast.LENGTH_LONG).show()
} else {
FileUtils.openFileInThirdPartyApp(this, content.filePath.orEmpty(), true)
}
}
}
)
val layoutManager = LinearLayoutManager(this)
layoutManager.stackFromEnd = true
binding.chatMessagesList.layoutManager = layoutManager
listViewModel.events.observe(
this,
{ events ->
adapter.submitList(events)
}
)
chatSendingViewModel.textToSend.observe(
this,
{
chatSendingViewModel.onTextToSendChanged(it)
}
)
binding.setOpenAppClickListener {
coreContext.notificationsManager.currentlyDisplayedChatRoomAddress = null
val intent = Intent(this, MainActivity::class.java)
intent.putExtra("RemoteSipUri", remoteSipUri)
intent.putExtra("LocalSipUri", localSipUri)
intent.putExtra("Chat", true)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK and Intent.FLAG_ACTIVITY_CLEAR_TASK)
startActivity(intent)
}
binding.setCloseBubbleClickListener {
coreContext.notificationsManager.dismissChatNotification(viewModel.chatRoom)
}
binding.setSendMessageClickListener {
chatSendingViewModel.sendMessage()
binding.message.text?.clear()
}
}
override fun onResume() {
super.onResume()
val peerAddress = viewModel.chatRoom.peerAddress.asStringUriOnly()
coreContext.notificationsManager.currentlyDisplayedChatRoomAddress = peerAddress
coreContext.notificationsManager.resetChatNotificationCounterForSipUri(peerAddress)
lifecycleScope.launch {
// Without the delay the scroll to bottom doesn't happen...
delay(100)
scrollToBottom()
}
}
override fun onPause() {
coreContext.notificationsManager.currentlyDisplayedChatRoomAddress = null
super.onPause()
}
private fun scrollToBottom() {
if (adapter.itemCount > 0) {
binding.chatMessagesList.scrollToPosition(adapter.itemCount - 1)
}
}
}

View file

@ -0,0 +1,70 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.launcher
import android.content.Intent
import android.os.Bundle
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.LinphoneApplication.Companion.corePreferences
import org.linphone.R
import org.linphone.activities.GenericActivity
import org.linphone.activities.main.MainActivity
import org.linphone.core.tools.Log
class LauncherActivity : GenericActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.launcher_activity)
}
override fun onStart() {
super.onStart()
coreContext.handler.postDelayed({ onReady() }, 500)
}
private fun onReady() {
Log.i("[Launcher] Core is ready")
if (corePreferences.preventInterfaceFromShowingUp) {
Log.w("[Context] We were asked to not show the user interface")
finish()
return
}
val intent = Intent()
intent.setClass(this, MainActivity::class.java)
// Propagate current intent action, type and data
if (getIntent() != null) {
val extras = getIntent().extras
if (extras != null) intent.putExtras(extras)
}
intent.action = getIntent().action
intent.type = getIntent().type
intent.data = getIntent().data
startActivity(intent)
if (corePreferences.enableAnimations) {
overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out)
}
}
}

View file

@ -0,0 +1,497 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.main
import android.app.Activity
import android.content.ComponentCallbacks2
import android.content.Intent
import android.content.res.Configuration
import android.net.Uri
import android.os.Bundle
import android.os.Parcelable
import android.view.Gravity
import android.view.MotionEvent
import android.view.View
import android.view.inputmethod.InputMethodManager
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.databinding.DataBindingUtil
import androidx.fragment.app.FragmentContainerView
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavController
import androidx.navigation.NavDestination
import androidx.navigation.findNavController
import androidx.window.layout.FoldingFeature
import com.google.android.material.snackbar.Snackbar
import java.io.UnsupportedEncodingException
import java.net.URLDecoder
import kotlin.math.abs
import kotlinx.coroutines.*
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.LinphoneApplication.Companion.corePreferences
import org.linphone.R
import org.linphone.activities.GenericActivity
import org.linphone.activities.SnackBarActivity
import org.linphone.activities.assistant.AssistantActivity
import org.linphone.activities.main.viewmodels.CallOverlayViewModel
import org.linphone.activities.main.viewmodels.SharedMainViewModel
import org.linphone.activities.navigateToDialer
import org.linphone.compatibility.Compatibility
import org.linphone.contact.ContactsUpdatedListenerStub
import org.linphone.core.CorePreferences
import org.linphone.core.tools.Log
import org.linphone.databinding.MainActivityBinding
import org.linphone.utils.AppUtils
import org.linphone.utils.Event
import org.linphone.utils.FileUtils
import org.linphone.utils.GlideApp
class MainActivity : GenericActivity(), SnackBarActivity, NavController.OnDestinationChangedListener {
private lateinit var binding: MainActivityBinding
private lateinit var sharedViewModel: SharedMainViewModel
private lateinit var callOverlayViewModel: CallOverlayViewModel
private val listener = object : ContactsUpdatedListenerStub() {
override fun onContactsUpdated() {
Log.i("[Main Activity] Contact(s) updated, update shortcuts")
if (corePreferences.contactsShortcuts) {
Compatibility.createShortcutsToContacts(this@MainActivity)
} else if (corePreferences.chatRoomShortcuts) {
Compatibility.createShortcutsToChatRooms(this@MainActivity)
}
}
}
private lateinit var tabsFragment: FragmentContainerView
private lateinit var statusFragment: FragmentContainerView
private var overlayX = 0f
private var overlayY = 0f
private var initPosX = 0f
private var initPosY = 0f
private var overlay: View? = null
private val componentCallbacks = object : ComponentCallbacks2 {
override fun onConfigurationChanged(newConfig: Configuration) { }
override fun onLowMemory() {
Log.w("[Main Activity] onLowMemory !")
}
override fun onTrimMemory(level: Int) {
Log.w("[Main Activity] onTrimMemory called with level $level !")
GlideApp.get(this@MainActivity).clearMemory()
}
}
override fun onLayoutChanges(foldingFeature: FoldingFeature?) {
sharedViewModel.layoutChangedEvent.value = Event(true)
}
private var tabsFragmentVisible1 = true
private var tabsFragmentVisible2 = true
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = DataBindingUtil.setContentView(this, R.layout.main_activity)
binding.lifecycleOwner = this
sharedViewModel = ViewModelProvider(this)[SharedMainViewModel::class.java]
binding.viewModel = sharedViewModel
callOverlayViewModel = ViewModelProvider(this)[CallOverlayViewModel::class.java]
binding.callOverlayViewModel = callOverlayViewModel
sharedViewModel.toggleDrawerEvent.observe(
this,
{
it.consume {
if (binding.sideMenu.isDrawerOpen(Gravity.LEFT)) {
binding.sideMenu.closeDrawer(binding.sideMenuContent, true)
} else {
binding.sideMenu.openDrawer(binding.sideMenuContent, true)
}
}
}
)
coreContext.callErrorMessageResourceId.observe(
this,
{
it.consume { message ->
showSnackBar(message)
}
}
)
if (coreContext.core.accountList.isEmpty()) {
if (corePreferences.firstStart) {
corePreferences.firstStart = false
startActivity(Intent(this, AssistantActivity::class.java))
}
}
tabsFragment = findViewById(R.id.tabs_fragment)
statusFragment = findViewById(R.id.status_fragment)
initOverlay()
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
if (intent != null) handleIntentParams(intent)
}
override fun onResume() {
super.onResume()
coreContext.contactsManager.addListener(listener)
}
override fun onPause() {
coreContext.contactsManager.removeListener(listener)
super.onPause()
}
override fun showSnackBar(resourceId: Int) {
Snackbar.make(findViewById(R.id.coordinator), resourceId, Snackbar.LENGTH_LONG).show()
}
override fun showSnackBar(message: String) {
Snackbar.make(findViewById(R.id.coordinator), message, Snackbar.LENGTH_LONG).show()
}
override fun onPostCreate(savedInstanceState: Bundle?) {
super.onPostCreate(savedInstanceState)
registerComponentCallbacks(componentCallbacks)
findNavController(R.id.nav_host_fragment).addOnDestinationChangedListener(this)
binding.rootCoordinatorLayout.viewTreeObserver.addOnGlobalLayoutListener {
val portraitOrientation = resources.configuration.orientation != Configuration.ORIENTATION_LANDSCAPE
val keyboardVisible = ViewCompat.getRootWindowInsets(binding.rootCoordinatorLayout)
?.isVisible(WindowInsetsCompat.Type.ime()) == true
Log.d("[Tabs Fragment] Keyboard is ${if (keyboardVisible) "visible" else "invisible"}")
tabsFragmentVisible2 = !portraitOrientation || !keyboardVisible
updateTabsFragmentVisibility()
}
if (intent != null) handleIntentParams(intent)
}
override fun onDestroy() {
findNavController(R.id.nav_host_fragment).removeOnDestinationChangedListener(this)
unregisterComponentCallbacks(componentCallbacks)
super.onDestroy()
}
override fun onDestinationChanged(
controller: NavController,
destination: NavDestination,
arguments: Bundle?
) {
currentFocus?.hideKeyboard()
if (statusFragment.visibility == View.GONE) {
statusFragment.visibility = View.VISIBLE
}
tabsFragmentVisible1 = when (destination.id) {
R.id.masterCallLogsFragment, R.id.masterContactsFragment, R.id.dialerFragment, R.id.masterChatRoomsFragment ->
true
else -> false
}
updateTabsFragmentVisibility()
}
private fun updateTabsFragmentVisibility() {
tabsFragment.visibility = if (tabsFragmentVisible1 && tabsFragmentVisible2) View.VISIBLE else View.GONE
}
private fun View.hideKeyboard() {
val imm = context.getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(windowToken, 0)
}
private fun handleIntentParams(intent: Intent) {
when (intent.action) {
Intent.ACTION_SEND, Intent.ACTION_SENDTO -> {
if (intent.type == "text/plain") {
handleSendText(intent)
} else {
lifecycleScope.launch {
handleSendFile(intent)
}
}
}
Intent.ACTION_SEND_MULTIPLE -> {
lifecycleScope.launch {
handleSendMultipleFiles(intent)
}
}
Intent.ACTION_VIEW -> {
val uri = intent.data
if (intent.type == AppUtils.getString(R.string.linphone_address_mime_type)) {
if (uri != null) {
val contactId = coreContext.contactsManager.getAndroidContactIdFromUri(uri)
if (contactId != null) {
val deepLink = "linphone-android://contact/view/$contactId"
Log.i("[Main Activity] Found contact URI parameter in intent: $uri, starting deep link: $deepLink")
findNavController(R.id.nav_host_fragment).navigate(Uri.parse(deepLink))
}
}
} else {
if (uri != null) {
handleTelOrSipUri(uri)
}
}
}
Intent.ACTION_DIAL, Intent.ACTION_CALL -> {
val uri = intent.data
if (uri != null) {
handleTelOrSipUri(uri)
}
}
Intent.ACTION_VIEW_LOCUS -> {
if (corePreferences.disableChat) return
val locus = Compatibility.extractLocusIdFromIntent(intent)
if (locus != null) {
Log.i("[Main Activity] Found chat room locus intent extra: $locus")
handleLocusOrShortcut(locus)
}
}
else -> {
when {
intent.hasExtra("ContactId") -> {
val id = intent.getStringExtra("ContactId")
val deepLink = "linphone-android://contact/view/$id"
Log.i("[Main Activity] Found contact id parameter in intent: $id, starting deep link: $deepLink")
findNavController(R.id.nav_host_fragment).navigate(Uri.parse(deepLink))
}
intent.hasExtra("Chat") -> {
if (corePreferences.disableChat) return
if (intent.hasExtra("RemoteSipUri") && intent.hasExtra("LocalSipUri")) {
val peerAddress = intent.getStringExtra("RemoteSipUri")
val localAddress = intent.getStringExtra("LocalSipUri")
Log.i("[Main Activity] Found chat room intent extra: local SIP URI=[$localAddress], peer SIP URI=[$peerAddress]")
findNavController(R.id.nav_host_fragment).navigate(Uri.parse("linphone-android://chat-room/$localAddress/$peerAddress"))
} else {
Log.i("[Main Activity] Found chat intent extra, go to chat rooms list")
findNavController(R.id.nav_host_fragment).navigate(R.id.action_global_masterChatRoomsFragment)
}
}
intent.hasExtra("Dialer") -> {
Log.i("[Main Activity] Found dialer intent extra, go to dialer")
val args = Bundle()
args.putBoolean("Transfer", intent.getBooleanExtra("Transfer", false))
navigateToDialer(args)
}
}
}
}
}
private fun handleTelOrSipUri(uri: Uri) {
Log.i("[Main Activity] Found uri: $uri to call")
val stringUri = uri.toString()
var addressToCall: String = stringUri
when {
addressToCall.startsWith("tel:") -> {
Log.i("[Main Activity] Removing tel: prefix")
addressToCall = addressToCall.substring("tel:".length)
}
addressToCall.startsWith("linphone:") -> {
Log.i("[Main Activity] Removing linphone: prefix")
addressToCall = addressToCall.substring("linphone:".length)
}
addressToCall.startsWith("sip-linphone:") -> {
Log.i("[Main Activity] Removing linphone: sip-linphone")
addressToCall = addressToCall.substring("sip-linphone:".length)
}
}
val address = coreContext.core.interpretUrl(addressToCall)
if (address != null) {
addressToCall = address.asStringUriOnly()
}
Log.i("[Main Activity] Starting dialer with pre-filled URI $addressToCall")
val args = Bundle()
args.putString("URI", addressToCall)
navigateToDialer(args)
}
private fun handleSendText(intent: Intent) {
if (corePreferences.disableChat) return
intent.getStringExtra(Intent.EXTRA_TEXT)?.let {
sharedViewModel.textToShare.value = it
}
handleSendChatRoom(intent)
}
private suspend fun handleSendFile(intent: Intent) {
if (corePreferences.disableChat) return
Log.i("[Main Activity] Found single file to share with type ${intent.type}")
(intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri)?.let {
val list = arrayListOf<String>()
coroutineScope {
val deferred = async {
FileUtils.getFilePath(this@MainActivity, it)
}
val path = deferred.await()
if (path != null) {
list.add(path)
Log.i("[Main Activity] Found single file to share: $path")
}
}
sharedViewModel.filesToShare.value = list
}
// Check that the current fragment hasn't already handled the event on filesToShare
// If it has, don't go further.
// For example this may happen when picking a GIF from the keyboard while inside a chat room
if (!sharedViewModel.filesToShare.value.isNullOrEmpty()) {
handleSendChatRoom(intent)
}
}
private suspend fun handleSendMultipleFiles(intent: Intent) {
if (corePreferences.disableChat) return
intent.getParcelableArrayListExtra<Parcelable>(Intent.EXTRA_STREAM)?.let {
val list = arrayListOf<String>()
coroutineScope {
val deferred = arrayListOf<Deferred<String?>>()
for (parcelable in it) {
val uri = parcelable as Uri
deferred.add(async { FileUtils.getFilePath(this@MainActivity, uri) })
}
val paths = deferred.awaitAll()
for (path in paths) {
Log.i("[Main Activity] Found file to share: $path")
if (path != null) list.add(path)
}
}
sharedViewModel.filesToShare.value = list
}
handleSendChatRoom(intent)
}
private fun handleSendChatRoom(intent: Intent) {
if (corePreferences.disableChat) return
val uri = intent.data
if (uri != null) {
Log.i("[Main Activity] Found uri: $uri to send a message to")
val stringUri = uri.toString()
var addressToIM: String = stringUri
try {
addressToIM = URLDecoder.decode(stringUri, "UTF-8")
} catch (e: UnsupportedEncodingException) {
Log.e("[Main Activity] UnsupportedEncodingException: $e")
}
when {
addressToIM.startsWith("sms:") ->
addressToIM = addressToIM.substring("sms:".length)
addressToIM.startsWith("smsto:") ->
addressToIM = addressToIM.substring("smsto:".length)
addressToIM.startsWith("mms:") ->
addressToIM = addressToIM.substring("mms:".length)
addressToIM.startsWith("mmsto:") ->
addressToIM = addressToIM.substring("mmsto:".length)
}
val peerAddress = coreContext.core.interpretUrl(addressToIM)?.asStringUriOnly()
val localAddress =
coreContext.core.defaultAccount?.params?.identityAddress?.asStringUriOnly()
val deepLink = "linphone-android://chat-room/$localAddress/$peerAddress"
Log.i("[Main Activity] Starting deep link: $deepLink")
findNavController(R.id.nav_host_fragment).navigate(Uri.parse(deepLink))
} else {
val shortcutId = intent.getStringExtra("android.intent.extra.shortcut.ID") // Intent.EXTRA_SHORTCUT_ID
if (shortcutId != null) {
Log.i("[Main Activity] Found shortcut ID: $shortcutId")
handleLocusOrShortcut(shortcutId)
} else {
Log.i("[Main Activity] Going into chat rooms list")
findNavController(R.id.nav_host_fragment).navigate(R.id.action_global_masterChatRoomsFragment)
}
}
}
private fun handleLocusOrShortcut(id: String) {
val split = id.split("~")
if (split.size == 2) {
val localAddress = split[0]
val peerAddress = split[1]
val deepLink = "linphone-android://chat-room/$localAddress/$peerAddress"
findNavController(R.id.nav_host_fragment).navigate(Uri.parse(deepLink))
} else {
Log.e("[Main Activity] Failed to parse shortcut/locus id: $id")
findNavController(R.id.nav_host_fragment).navigate(R.id.action_global_masterChatRoomsFragment)
}
}
private fun initOverlay() {
overlay = binding.root.findViewById(R.id.call_overlay)
val callOverlay = overlay
callOverlay ?: return
callOverlay.setOnTouchListener { view, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> {
overlayX = view.x - event.rawX
overlayY = view.y - event.rawY
initPosX = view.x
initPosY = view.y
}
MotionEvent.ACTION_MOVE -> {
view.animate()
.x(event.rawX + overlayX)
.y(event.rawY + overlayY)
.setDuration(0)
.start()
}
MotionEvent.ACTION_UP -> {
if (abs(initPosX - view.x) < CorePreferences.OVERLAY_CLICK_SENSITIVITY &&
abs(initPosY - view.y) < CorePreferences.OVERLAY_CLICK_SENSITIVITY
) {
view.performClick()
}
}
else -> return@setOnTouchListener false
}
true
}
callOverlay.setOnClickListener {
coreContext.onCallOverlayClick()
}
}
}

View file

@ -0,0 +1,62 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.main.about
import android.content.*
import android.net.Uri
import android.os.Bundle
import android.view.View
import androidx.lifecycle.ViewModelProvider
import org.linphone.R
import org.linphone.activities.main.fragments.SecureFragment
import org.linphone.databinding.AboutFragmentBinding
class AboutFragment : SecureFragment<AboutFragmentBinding>() {
private lateinit var viewModel: AboutViewModel
override fun getLayoutId(): Int = R.layout.about_fragment
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.lifecycleOwner = viewLifecycleOwner
viewModel = ViewModelProvider(this)[AboutViewModel::class.java]
binding.viewModel = viewModel
binding.setBackClickListener { goBack() }
binding.setPrivacyPolicyClickListener {
val browserIntent = Intent(
Intent.ACTION_VIEW,
Uri.parse(getString(R.string.about_privacy_policy_link))
)
startActivity(browserIntent)
}
binding.setLicenseClickListener {
val browserIntent = Intent(
Intent.ACTION_VIEW,
Uri.parse(getString(R.string.about_license_link))
)
startActivity(browserIntent)
}
}
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2010-2023 Belledonne Communications SARL.
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
@ -17,15 +17,13 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.ui.main.settings.model
package org.linphone.activities.main.about
import androidx.annotation.AnyThread
import androidx.annotation.UiThread
import androidx.lifecycle.ViewModel
import org.linphone.LinphoneApplication.Companion.coreContext
@AnyThread
class CardDavLdapModel(val name: String, private val onClicked: (name: String) -> (Unit)) {
@UiThread
fun clicked() {
onClicked.invoke(name)
}
class AboutViewModel : ViewModel() {
val appVersion: String = coreContext.appVersion
val sdkVersion: String = coreContext.sdkVersion
}

View file

@ -0,0 +1,40 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.main.adapters
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import org.linphone.activities.main.viewmodels.ListTopBarViewModel
abstract class SelectionListAdapter<T, VH : RecyclerView.ViewHolder>(
selectionVM: ListTopBarViewModel,
diff: DiffUtil.ItemCallback<T>
) :
ListAdapter<T, VH>(diff) {
private var _selectionViewModel: ListTopBarViewModel? = selectionVM
protected val selectionViewModel get() = _selectionViewModel!!
override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) {
super.onDetachedFromRecyclerView(recyclerView)
_selectionViewModel = null
}
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2010-2025 Belledonne Communications SARL.
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
@ -17,31 +17,27 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.ui.main.chat
package org.linphone.activities.main.chat
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import org.linphone.core.tools.Log
internal abstract class RecyclerViewScrollListener(private val layoutManager: LinearLayoutManager, private val visibleThreshold: Int, private val scrollingTopToBottom: Boolean) :
internal abstract class ChatScrollListener(private val mLayoutManager: LinearLayoutManager) :
RecyclerView.OnScrollListener() {
companion object {
private const val TAG = "[RecyclerView Scroll Listener]"
}
// The total number of items in the data set after the last load
private var previousTotalItemCount = 0
// True if we are still waiting for the last set of data to load.
private var loading = true
var userHasScrolledUp: Boolean = false
// This happens many times a second during a scroll, so be wary of the code you place here.
// We are given a few useful parameters to help us work out if we need to load some more data,
// 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 = layoutManager.itemCount
val firstVisibleItemPosition: Int = layoutManager.findFirstVisibleItemPosition()
val lastVisibleItemPosition: Int = layoutManager.findLastVisibleItemPosition()
val totalItemCount = mLayoutManager.itemCount
val firstVisibleItemPosition: Int = mLayoutManager.findFirstVisibleItemPosition()
val lastVisibleItemPosition: Int = mLayoutManager.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
@ -60,37 +56,24 @@ internal abstract class RecyclerViewScrollListener(private val layoutManager: Li
previousTotalItemCount = totalItemCount
}
val userHasScrolledUp = lastVisibleItemPosition != totalItemCount - 1
userHasScrolledUp = lastVisibleItemPosition != totalItemCount - 1
if (userHasScrolledUp) {
onScrolledUp()
Log.d("$TAG Scrolled up")
} else {
onScrolledToEnd()
Log.d("$TAG Scrolled to end")
}
// If it isnt currently loading, we check to see if we have breached
// the visibleThreshold and need to reload more data.
// the mVisibleThreshold 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) {
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)
}
}
if (!loading &&
firstVisibleItemPosition < mVisibleThreshold &&
firstVisibleItemPosition >= 0 &&
lastVisibleItemPosition < totalItemCount - mVisibleThreshold
) {
onLoadMore(totalItemCount)
loading = true
}
}
@ -102,4 +85,10 @@ internal abstract class RecyclerViewScrollListener(private val layoutManager: Li
// Called when user has scrolled and reached the end of the items
protected abstract fun onScrolledToEnd()
companion object {
// The minimum amount of items to have below your current scroll position
// before loading more.
private const val mVisibleThreshold = 5
}
}

View file

@ -17,22 +17,16 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.ui.main.contacts.model
package org.linphone.activities.main.chat
import androidx.annotation.UiThread
import androidx.annotation.WorkerThread
import org.linphone.core.Address
import org.linphone.core.ChatRoomSecurityLevel
class ContactDeviceModel
@WorkerThread
constructor(
val name: String,
data class GroupChatRoomMember(
val address: Address,
val trusted: Boolean,
private val onCall: ((model: ContactDeviceModel) -> Unit)? = null
) {
@UiThread
fun startCallToDevice() {
onCall?.invoke(this)
}
}
var isAdmin: Boolean = false,
val securityLevel: ChatRoomSecurityLevel = ChatRoomSecurityLevel.ClearText,
val hasLimeX3DHCapability: Boolean = false,
// A participant not yet added to a group can't be set admin at the same time it's added
val canBeSetAdmin: Boolean = false
)

View file

@ -0,0 +1,459 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.main.chat.adapters
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.PopupWindow
import androidx.databinding.DataBindingUtil
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.MutableLiveData
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.R
import org.linphone.activities.main.adapters.SelectionListAdapter
import org.linphone.activities.main.chat.data.ChatMessageData
import org.linphone.activities.main.chat.data.EventData
import org.linphone.activities.main.chat.data.EventLogData
import org.linphone.activities.main.chat.data.OnContentClickedListener
import org.linphone.activities.main.viewmodels.ListTopBarViewModel
import org.linphone.core.ChatMessage
import org.linphone.core.ChatRoomCapabilities
import org.linphone.core.Content
import org.linphone.core.EventLog
import org.linphone.databinding.*
import org.linphone.utils.AppUtils
import org.linphone.utils.Event
import org.linphone.utils.HeaderAdapter
class ChatMessagesListAdapter(
selectionVM: ListTopBarViewModel,
private val viewLifecycleOwner: LifecycleOwner
) : SelectionListAdapter<EventLogData, RecyclerView.ViewHolder>(selectionVM, ChatMessageDiffCallback()),
HeaderAdapter {
companion object {
const val MAX_TIME_TO_GROUP_MESSAGES = 60 // 1 minute
}
val resendMessageEvent: MutableLiveData<Event<ChatMessage>> by lazy {
MutableLiveData<Event<ChatMessage>>()
}
val deleteMessageEvent: MutableLiveData<Event<ChatMessage>> by lazy {
MutableLiveData<Event<ChatMessage>>()
}
val forwardMessageEvent: MutableLiveData<Event<ChatMessage>> by lazy {
MutableLiveData<Event<ChatMessage>>()
}
val replyMessageEvent: MutableLiveData<Event<ChatMessage>> by lazy {
MutableLiveData<Event<ChatMessage>>()
}
val showImdnForMessageEvent: MutableLiveData<Event<ChatMessage>> by lazy {
MutableLiveData<Event<ChatMessage>>()
}
val addSipUriToContactEvent: MutableLiveData<Event<String>> by lazy {
MutableLiveData<Event<String>>()
}
val openContentEvent: MutableLiveData<Event<Content>> by lazy {
MutableLiveData<Event<Content>>()
}
val scrollToChatMessageEvent: MutableLiveData<Event<ChatMessage>> by lazy {
MutableLiveData<Event<ChatMessage>>()
}
private val contentClickedListener = object : OnContentClickedListener {
override fun onContentClicked(content: Content) {
openContentEvent.value = Event(content)
}
}
private var contextMenuDisabled: Boolean = false
private var unreadMessagesCount: Int = 0
private var firstUnreadMessagePosition: Int = -1
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when (viewType) {
EventLog.Type.ConferenceChatMessage.toInt() -> createChatMessageViewHolder(parent)
else -> createEventViewHolder(parent)
}
}
private fun createChatMessageViewHolder(parent: ViewGroup): ChatMessageViewHolder {
val binding: ChatMessageListCellBinding = DataBindingUtil.inflate(
LayoutInflater.from(parent.context),
R.layout.chat_message_list_cell, parent, false
)
return ChatMessageViewHolder(binding)
}
private fun createEventViewHolder(parent: ViewGroup): EventViewHolder {
val binding: ChatEventListCellBinding = DataBindingUtil.inflate(
LayoutInflater.from(parent.context),
R.layout.chat_event_list_cell, parent, false
)
return EventViewHolder(binding)
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val eventLog = getItem(position)
when (holder) {
is ChatMessageViewHolder -> holder.bind(eventLog)
is EventViewHolder -> holder.bind(eventLog)
}
}
override fun getItemViewType(position: Int): Int {
val eventLog = getItem(position)
return eventLog.eventLog.type.toInt()
}
override fun onCurrentListChanged(
previousList: MutableList<EventLogData>,
currentList: MutableList<EventLogData>
) {
// Need to wait for messages to be added before computing new first unread message position
firstUnreadMessagePosition = -1
}
override fun displayHeaderForPosition(position: Int): Boolean {
if (unreadMessagesCount > 0 && firstUnreadMessagePosition == -1) {
computeFirstUnreadMessagePosition()
}
return position == firstUnreadMessagePosition
}
override fun getHeaderViewForPosition(context: Context, position: Int): View {
val binding: ChatUnreadMessagesListHeaderBinding = DataBindingUtil.inflate(
LayoutInflater.from(context),
R.layout.chat_unread_messages_list_header, null, false
)
binding.title = AppUtils.getStringWithPlural(R.plurals.chat_room_unread_messages_event, unreadMessagesCount)
binding.executePendingBindings()
return binding.root
}
fun disableContextMenu() {
contextMenuDisabled = true
}
fun setUnreadMessageCount(count: Int, forceUpdate: Boolean) {
// Once list has been filled once, don't show the unread message header
// when new messages are added to the history whilst it is visible
unreadMessagesCount = if (itemCount == 0 || forceUpdate) count else 0
firstUnreadMessagePosition = -1
}
fun getFirstUnreadMessagePosition(): Int {
return firstUnreadMessagePosition
}
private fun computeFirstUnreadMessagePosition() {
if (unreadMessagesCount > 0) {
var messageCount = 0
for (position in itemCount - 1 downTo 0) {
val eventLog = getItem(position)
val data = eventLog.data
if (data is ChatMessageData) {
messageCount += 1
if (messageCount == unreadMessagesCount) {
firstUnreadMessagePosition = position
break
}
}
}
}
}
inner class ChatMessageViewHolder(
val binding: ChatMessageListCellBinding
) : RecyclerView.ViewHolder(binding.root) {
fun bind(eventLog: EventLogData) {
with(binding) {
if (eventLog.eventLog.type == EventLog.Type.ConferenceChatMessage) {
val chatMessageViewModel = eventLog.data as ChatMessageData
chatMessageViewModel.setContentClickListener(contentClickedListener)
val chatMessage = chatMessageViewModel.chatMessage
data = chatMessageViewModel
lifecycleOwner = viewLifecycleOwner
// This is for item selection through ListTopBarFragment
selectionListViewModel = selectionViewModel
selectionViewModel.isEditionEnabled.observe(
viewLifecycleOwner,
{
position = adapterPosition
}
)
setClickListener {
if (selectionViewModel.isEditionEnabled.value == true) {
selectionViewModel.onToggleSelect(adapterPosition)
}
}
setReplyClickListener {
val reply = chatMessageViewModel.replyData.value?.chatMessage
if (reply != null) {
scrollToChatMessageEvent.value = Event(reply)
}
}
// Grouping
var hasPrevious = false
var hasNext = false
if (adapterPosition > 0) {
val previousItem = getItem(adapterPosition - 1)
if (previousItem.eventLog.type == EventLog.Type.ConferenceChatMessage) {
val previousMessage = previousItem.eventLog.chatMessage
if (previousMessage != null && previousMessage.fromAddress.weakEqual(chatMessage.fromAddress)) {
if (chatMessage.time - previousMessage.time < MAX_TIME_TO_GROUP_MESSAGES) {
hasPrevious = true
}
}
}
}
if (adapterPosition >= 0 && adapterPosition < itemCount - 1) {
val nextItem = getItem(adapterPosition + 1)
if (nextItem.eventLog.type == EventLog.Type.ConferenceChatMessage) {
val nextMessage = nextItem.eventLog.chatMessage
if (nextMessage != null && nextMessage.fromAddress.weakEqual(chatMessage.fromAddress)) {
if (nextMessage.time - chatMessage.time < MAX_TIME_TO_GROUP_MESSAGES) {
hasNext = true
}
}
}
}
chatMessageViewModel.updateBubbleBackground(hasPrevious, hasNext)
executePendingBindings()
if (contextMenuDisabled) return
setContextMenuClickListener {
val popupView: ChatMessageLongPressMenuBindingImpl = DataBindingUtil.inflate(
LayoutInflater.from(root.context),
R.layout.chat_message_long_press_menu, null, false
)
val itemSize = AppUtils.getDimension(R.dimen.chat_message_popup_item_height).toInt()
var totalSize = itemSize * 7
if (chatMessage.chatRoom.hasCapability(ChatRoomCapabilities.OneToOne.toInt()) ||
chatMessage.state == ChatMessage.State.NotDelivered
) { // No message id
popupView.imdnHidden = true
totalSize -= itemSize
}
if (chatMessage.state != ChatMessage.State.NotDelivered) {
popupView.resendHidden = true
totalSize -= itemSize
}
if (chatMessage.contents.find { content -> content.isText } == null) {
popupView.copyTextHidden = true
totalSize -= itemSize
}
if (chatMessage.isOutgoing || chatMessageViewModel.contact.value != null) {
popupView.addToContactsHidden = true
totalSize -= itemSize
}
if (chatMessage.chatRoom.hasBeenLeft()) {
popupView.replyHidden = true
totalSize -= itemSize
}
// When using WRAP_CONTENT instead of real size, fails to place the
// popup window above if not enough space is available below
val popupWindow = PopupWindow(
popupView.root,
AppUtils.getDimension(R.dimen.chat_message_popup_width).toInt(),
totalSize,
true
)
// Elevation is for showing a shadow around the popup
popupWindow.elevation = 20f
popupView.setResendClickListener {
resendMessage()
popupWindow.dismiss()
}
popupView.setCopyTextClickListener {
copyTextToClipboard()
popupWindow.dismiss()
}
popupView.setForwardClickListener {
forwardMessage()
popupWindow.dismiss()
}
popupView.setReplyClickListener {
replyMessage()
popupWindow.dismiss()
}
popupView.setImdnClickListener {
showImdnDeliveryFragment()
popupWindow.dismiss()
}
popupView.setAddToContactsClickListener {
addSenderToContacts()
popupWindow.dismiss()
}
popupView.setDeleteClickListener {
deleteMessage()
popupWindow.dismiss()
}
val gravity = if (chatMessage.isOutgoing) Gravity.END else Gravity.START
popupWindow.showAsDropDown(background, 0, 0, gravity or Gravity.TOP)
true
}
}
}
}
private fun resendMessage() {
val chatMessage = binding.data?.chatMessage
if (chatMessage != null) {
chatMessage.userData = adapterPosition
resendMessageEvent.value = Event(chatMessage)
}
}
private fun copyTextToClipboard() {
val chatMessage = binding.data?.chatMessage
if (chatMessage != null) {
val content = chatMessage.contents.find { content -> content.isText }
if (content != null) {
val clipboard: ClipboardManager =
coreContext.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("Message", content.utf8Text)
clipboard.setPrimaryClip(clip)
}
}
}
private fun forwardMessage() {
val chatMessage = binding.data?.chatMessage
if (chatMessage != null) {
forwardMessageEvent.value = Event(chatMessage)
}
}
private fun replyMessage() {
val chatMessage = binding.data?.chatMessage
if (chatMessage != null) {
replyMessageEvent.value = Event(chatMessage)
}
}
private fun showImdnDeliveryFragment() {
val chatMessage = binding.data?.chatMessage
if (chatMessage != null) {
showImdnForMessageEvent.value = Event(chatMessage)
}
}
private fun deleteMessage() {
val chatMessage = binding.data?.chatMessage
if (chatMessage != null) {
chatMessage.userData = adapterPosition
deleteMessageEvent.value = Event(chatMessage)
}
}
private fun addSenderToContacts() {
val chatMessage = binding.data?.chatMessage
if (chatMessage != null) {
val copy = chatMessage.fromAddress.clone()
copy.clean() // To remove gruu if any
addSipUriToContactEvent.value = Event(copy.asStringUriOnly())
}
}
}
inner class EventViewHolder(
private val binding: ChatEventListCellBinding
) : RecyclerView.ViewHolder(binding.root) {
fun bind(eventLog: EventLogData) {
with(binding) {
val eventViewModel = eventLog.data as EventData
data = eventViewModel
binding.lifecycleOwner = viewLifecycleOwner
// This is for item selection through ListTopBarFragment
selectionListViewModel = selectionViewModel
selectionViewModel.isEditionEnabled.observe(
viewLifecycleOwner,
{
position = adapterPosition
}
)
binding.setClickListener {
if (selectionViewModel.isEditionEnabled.value == true) {
selectionViewModel.onToggleSelect(adapterPosition)
}
}
executePendingBindings()
}
}
}
}
private class ChatMessageDiffCallback : DiffUtil.ItemCallback<EventLogData>() {
override fun areItemsTheSame(
oldItem: EventLogData,
newItem: EventLogData
): Boolean {
return if (oldItem.eventLog.type == EventLog.Type.ConferenceChatMessage &&
newItem.eventLog.type == EventLog.Type.ConferenceChatMessage
) {
oldItem.eventLog.chatMessage?.time == newItem.eventLog.chatMessage?.time &&
oldItem.eventLog.chatMessage?.isOutgoing == newItem.eventLog.chatMessage?.isOutgoing
} else oldItem.eventLog.notifyId == newItem.eventLog.notifyId
}
override fun areContentsTheSame(
oldItem: EventLogData,
newItem: EventLogData
): Boolean {
return if (newItem.eventLog.type == EventLog.Type.ConferenceChatMessage) {
newItem.eventLog.chatMessage?.state == ChatMessage.State.Displayed
} else true
}
}

View file

@ -0,0 +1,135 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.main.chat.adapters
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.databinding.DataBindingUtil
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.MutableLiveData
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.R
import org.linphone.activities.main.chat.data.ChatRoomCreationContactData
import org.linphone.core.Address
import org.linphone.core.FriendCapability
import org.linphone.core.SearchResult
import org.linphone.databinding.ChatRoomCreationContactCellBinding
import org.linphone.utils.Event
class ChatRoomCreationContactsAdapter(
private val viewLifecycleOwner: LifecycleOwner
) : ListAdapter<SearchResult, RecyclerView.ViewHolder>(SearchResultDiffCallback()) {
val selectedContact = MutableLiveData<Event<SearchResult>>()
var groupChatEnabled: Boolean = false
private var selectedAddresses = ArrayList<Address>()
private var securityEnabled: Boolean = false
fun updateSelectedAddresses(selection: ArrayList<Address>) {
selectedAddresses = selection
notifyDataSetChanged()
}
fun updateSecurity(enabled: Boolean) {
securityEnabled = enabled
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val binding: ChatRoomCreationContactCellBinding = DataBindingUtil.inflate(
LayoutInflater.from(parent.context),
R.layout.chat_room_creation_contact_cell, parent, false
)
return ViewHolder(binding)
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
(holder as ViewHolder).bind(getItem(position))
}
inner class ViewHolder(
private val binding: ChatRoomCreationContactCellBinding
) : RecyclerView.ViewHolder(binding.root) {
fun bind(searchResult: SearchResult) {
with(binding) {
val searchResultViewModel = ChatRoomCreationContactData(searchResult)
data = searchResultViewModel
lifecycleOwner = viewLifecycleOwner
updateSecurity(searchResult, searchResultViewModel, securityEnabled)
val selected = selectedAddresses.find { address ->
val searchAddress = searchResult.address
if (searchAddress != null) address.weakEqual(searchAddress) else false
}
searchResultViewModel.isSelected.value = selected != null
setClickListener {
selectedContact.value = Event(searchResult)
}
executePendingBindings()
}
}
private fun updateSecurity(
searchResult: SearchResult,
viewModel: ChatRoomCreationContactData,
securityEnabled: Boolean
) {
val searchAddress = searchResult.address
val isMyself = securityEnabled && searchAddress != null && coreContext.core.defaultAccount?.params?.identityAddress?.weakEqual(searchAddress) ?: false
val limeCheck = !securityEnabled || (securityEnabled && searchResult.hasCapability(FriendCapability.LimeX3Dh))
val groupCheck = !groupChatEnabled || (groupChatEnabled && searchResult.hasCapability(FriendCapability.GroupChat))
val disabled = if (searchResult.friend != null) !limeCheck || !groupCheck || isMyself else false // Generated entry from search filter
viewModel.isDisabled.value = disabled
if (disabled && viewModel.isSelected.value == true) {
// Remove item from selection if both selected and disabled
selectedContact.postValue(Event(searchResult))
}
}
}
}
private class SearchResultDiffCallback : DiffUtil.ItemCallback<SearchResult>() {
override fun areItemsTheSame(
oldItem: SearchResult,
newItem: SearchResult
): Boolean {
val oldAddress = oldItem.address
val newAddress = newItem.address
return if (oldAddress != null && newAddress != null) oldAddress.weakEqual(newAddress) else false
}
override fun areContentsTheSame(
oldItem: SearchResult,
newItem: SearchResult
): Boolean {
return newItem.friend != null
}
}

View file

@ -0,0 +1,121 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.main.chat.adapters
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.databinding.DataBindingUtil
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.MutableLiveData
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import org.linphone.R
import org.linphone.activities.main.adapters.SelectionListAdapter
import org.linphone.activities.main.chat.viewmodels.ChatRoomViewModel
import org.linphone.activities.main.viewmodels.ListTopBarViewModel
import org.linphone.core.ChatRoom
import org.linphone.databinding.ChatRoomListCellBinding
import org.linphone.utils.Event
class ChatRoomsListAdapter(
selectionVM: ListTopBarViewModel,
private val viewLifecycleOwner: LifecycleOwner
) : SelectionListAdapter<ChatRoomViewModel, RecyclerView.ViewHolder>(selectionVM, ChatRoomDiffCallback()) {
val selectedChatRoomEvent: MutableLiveData<Event<ChatRoom>> by lazy {
MutableLiveData<Event<ChatRoom>>()
}
private var isForwardPending = false
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding: ChatRoomListCellBinding = DataBindingUtil.inflate(
LayoutInflater.from(parent.context),
R.layout.chat_room_list_cell, parent, false
)
return ViewHolder(binding)
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
(holder as ViewHolder).bind(getItem(position))
}
fun forwardPending(pending: Boolean) {
isForwardPending = pending
notifyDataSetChanged()
}
inner class ViewHolder(
private val binding: ChatRoomListCellBinding
) : RecyclerView.ViewHolder(binding.root) {
fun bind(chatRoomViewModel: ChatRoomViewModel) {
with(binding) {
viewModel = chatRoomViewModel
lifecycleOwner = viewLifecycleOwner
// This is for item selection through ListTopBarFragment
selectionListViewModel = selectionViewModel
selectionViewModel.isEditionEnabled.observe(
viewLifecycleOwner,
{
position = adapterPosition
}
)
forwardPending = isForwardPending
setClickListener {
if (selectionViewModel.isEditionEnabled.value == true) {
selectionViewModel.onToggleSelect(adapterPosition)
} else {
selectedChatRoomEvent.value = Event(chatRoomViewModel.chatRoom)
}
}
setLongClickListener {
if (selectionViewModel.isEditionEnabled.value == false) {
selectionViewModel.isEditionEnabled.value = true
// Selection will be handled by click listener
true
}
false
}
executePendingBindings()
}
}
}
}
private class ChatRoomDiffCallback : DiffUtil.ItemCallback<ChatRoomViewModel>() {
override fun areItemsTheSame(
oldItem: ChatRoomViewModel,
newItem: ChatRoomViewModel
): Boolean {
return oldItem.chatRoom == newItem.chatRoom
}
override fun areContentsTheSame(
oldItem: ChatRoomViewModel,
newItem: ChatRoomViewModel
): Boolean {
return newItem.unreadMessagesCount.value == 0
}
}

View file

@ -0,0 +1,98 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.main.chat.adapters
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.databinding.DataBindingUtil
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.MutableLiveData
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import org.linphone.R
import org.linphone.activities.main.chat.GroupChatRoomMember
import org.linphone.activities.main.chat.data.GroupInfoParticipantData
import org.linphone.databinding.ChatRoomGroupInfoParticipantCellBinding
import org.linphone.utils.Event
class GroupInfoParticipantsAdapter(
private val viewLifecycleOwner: LifecycleOwner,
private val isEncryptionEnabled: Boolean
) : ListAdapter<GroupInfoParticipantData, RecyclerView.ViewHolder>(ParticipantDiffCallback()) {
private var showAdmin: Boolean = false
val participantRemovedEvent: MutableLiveData<Event<GroupChatRoomMember>> by lazy {
MutableLiveData<Event<GroupChatRoomMember>>()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding: ChatRoomGroupInfoParticipantCellBinding = DataBindingUtil.inflate(
LayoutInflater.from(parent.context),
R.layout.chat_room_group_info_participant_cell, parent, false
)
return ViewHolder(binding)
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
(holder as ViewHolder).bind(getItem(position))
}
fun showAdminControls(show: Boolean) {
showAdmin = show
notifyDataSetChanged()
}
inner class ViewHolder(
val binding: ChatRoomGroupInfoParticipantCellBinding
) : RecyclerView.ViewHolder(binding.root) {
fun bind(participantViewModel: GroupInfoParticipantData) {
with(binding) {
participantViewModel.showAdminControls.value = showAdmin
data = participantViewModel
lifecycleOwner = viewLifecycleOwner
setRemoveClickListener {
participantRemovedEvent.value = Event(participantViewModel.participant)
}
isEncrypted = isEncryptionEnabled
executePendingBindings()
}
}
}
}
private class ParticipantDiffCallback : DiffUtil.ItemCallback<GroupInfoParticipantData>() {
override fun areItemsTheSame(
oldItem: GroupInfoParticipantData,
newItem: GroupInfoParticipantData
): Boolean {
return oldItem.sipUri == newItem.sipUri
}
override fun areContentsTheSame(
oldItem: GroupInfoParticipantData,
newItem: GroupInfoParticipantData
): Boolean {
return false
}
}

View file

@ -0,0 +1,123 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.main.chat.adapters
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.databinding.DataBindingUtil
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import org.linphone.R
import org.linphone.activities.main.chat.data.ImdnParticipantData
import org.linphone.core.ChatMessage
import org.linphone.databinding.ChatRoomImdnParticipantCellBinding
import org.linphone.databinding.ImdnListHeaderBinding
import org.linphone.utils.HeaderAdapter
class ImdnAdapter(
private val viewLifecycleOwner: LifecycleOwner
) : ListAdapter<ImdnParticipantData, RecyclerView.ViewHolder>(ParticipantImdnStateDiffCallback()), HeaderAdapter {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding: ChatRoomImdnParticipantCellBinding = DataBindingUtil.inflate(
LayoutInflater.from(parent.context),
R.layout.chat_room_imdn_participant_cell, parent, false
)
return ViewHolder(binding)
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
(holder as ViewHolder).bind(getItem(position))
}
inner class ViewHolder(
val binding: ChatRoomImdnParticipantCellBinding
) : RecyclerView.ViewHolder(binding.root) {
fun bind(participantImdnData: ImdnParticipantData) {
with(binding) {
data = participantImdnData
lifecycleOwner = viewLifecycleOwner
executePendingBindings()
}
}
}
override fun displayHeaderForPosition(position: Int): Boolean {
if (position >= itemCount) return false
val participantImdnState = getItem(position)
val previousPosition = position - 1
return if (previousPosition >= 0) {
getItem(previousPosition).imdnState.state != participantImdnState.imdnState.state
} else true
}
override fun getHeaderViewForPosition(context: Context, position: Int): View {
val participantImdnState = getItem(position).imdnState
val binding: ImdnListHeaderBinding = DataBindingUtil.inflate(
LayoutInflater.from(context),
R.layout.imdn_list_header, null, false
)
when (participantImdnState.state) {
ChatMessage.State.Displayed -> {
binding.title = R.string.chat_message_imdn_displayed
binding.textColor = R.color.imdn_read_color
binding.icon = R.drawable.chat_read
}
ChatMessage.State.DeliveredToUser -> {
binding.title = R.string.chat_message_imdn_delivered
binding.textColor = R.color.grey_color
binding.icon = R.drawable.chat_delivered
}
ChatMessage.State.Delivered -> {
binding.title = R.string.chat_message_imdn_sent
binding.textColor = R.color.grey_color
binding.icon = R.drawable.chat_delivered
}
ChatMessage.State.NotDelivered -> {
binding.title = R.string.chat_message_imdn_undelivered
binding.textColor = R.color.red_color
binding.icon = R.drawable.chat_error
}
}
binding.executePendingBindings()
return binding.root
}
}
private class ParticipantImdnStateDiffCallback : DiffUtil.ItemCallback<ImdnParticipantData>() {
override fun areItemsTheSame(
oldItem: ImdnParticipantData,
newItem: ImdnParticipantData
): Boolean {
return oldItem.sipUri == newItem.sipUri
}
override fun areContentsTheSame(
oldItem: ImdnParticipantData,
newItem: ImdnParticipantData
): Boolean {
return false
}
}

View file

@ -0,0 +1,58 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.main.chat.data
import android.graphics.Bitmap
import androidx.lifecycle.MutableLiveData
import kotlinx.coroutines.*
import org.linphone.utils.FileUtils
import org.linphone.utils.ImageUtils
class ChatMessageAttachmentData(
val path: String,
private val deleteCallback: (attachment: ChatMessageAttachmentData) -> Unit
) {
val fileName: String = FileUtils.getNameFromFilePath(path)
val isImage: Boolean = FileUtils.isExtensionImage(path)
val isVideo: Boolean = FileUtils.isExtensionVideo(path)
val isAudio: Boolean = FileUtils.isExtensionAudio(path)
val isPdf: Boolean = FileUtils.isExtensionPdf(path)
val videoPreview = MutableLiveData<Bitmap>()
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
init {
if (isVideo) {
scope.launch {
withContext(Dispatchers.IO) {
videoPreview.postValue(ImageUtils.getVideoPreview(path))
}
}
}
}
fun destroy() {
scope.cancel()
}
fun delete() {
deleteCallback(this)
}
}

View file

@ -0,0 +1,349 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.main.chat.data
import android.graphics.Bitmap
import android.text.Spannable
import android.text.SpannableString
import android.text.Spanned
import android.text.style.UnderlineSpan
import android.widget.Toast
import androidx.lifecycle.MutableLiveData
import androidx.media.AudioFocusRequestCompat
import java.text.SimpleDateFormat
import java.util.*
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.R
import org.linphone.core.*
import org.linphone.core.tools.Log
import org.linphone.utils.AppUtils
import org.linphone.utils.FileUtils
import org.linphone.utils.ImageUtils
class ChatMessageContentData(
private val chatMessage: ChatMessage,
private val contentIndex: Int,
) {
var listener: OnContentClickedListener? = null
val isOutgoing = chatMessage.isOutgoing
val isImage = MutableLiveData<Boolean>()
val isVideo = MutableLiveData<Boolean>()
val isAudio = MutableLiveData<Boolean>()
val videoPreview = MutableLiveData<Bitmap>()
val isPdf = MutableLiveData<Boolean>()
val isGenericFile = MutableLiveData<Boolean>()
val isVoiceRecording = MutableLiveData<Boolean>()
val fileName = MutableLiveData<String>()
val filePath = MutableLiveData<String>()
val fileSize = MutableLiveData<String>()
val downloadable = MutableLiveData<Boolean>()
val downloadEnabled = MutableLiveData<Boolean>()
val downloadProgressInt = MutableLiveData<Int>()
val downloadProgressString = MutableLiveData<String>()
val downloadLabel = MutableLiveData<Spannable>()
val voiceRecordDuration = MutableLiveData<Int>()
val formattedDuration = MutableLiveData<String>()
val voiceRecordPlayingPosition = MutableLiveData<Int>()
val isVoiceRecordPlaying = MutableLiveData<Boolean>()
var voiceRecordAudioFocusRequest: AudioFocusRequestCompat? = null
val isAlone: Boolean
get() {
var count = 0
for (content in chatMessage.contents) {
val content = getContent()
if (content.isFileTransfer || content.isFile) {
count += 1
}
}
return count == 1
}
var isFileEncrypted: Boolean = false
private lateinit var voiceRecordingPlayer: Player
private val playerListener = PlayerListener {
Log.i("[Voice Recording] End of file reached")
stopVoiceRecording()
}
private fun getContent(): Content {
return chatMessage.contents[contentIndex]
}
private val chatMessageListener: ChatMessageListenerStub = object : ChatMessageListenerStub() {
override fun onFileTransferProgressIndication(
message: ChatMessage,
c: Content,
offset: Int,
total: Int
) {
if (c.filePath == getContent().filePath) {
val percent = offset * 100 / total
Log.d("[Content] Download progress is: $offset / $total ($percent%)")
downloadProgressInt.value = percent
downloadProgressString.value = "$percent%"
}
}
override fun onMsgStateChanged(message: ChatMessage, state: ChatMessage.State) {
downloadEnabled.value = state != ChatMessage.State.FileTransferInProgress
if (state == ChatMessage.State.FileTransferDone || state == ChatMessage.State.FileTransferError) {
updateContent()
if (state == ChatMessage.State.FileTransferDone) {
Log.i("[Chat Message] File transfer done")
if (!message.isOutgoing && !message.isEphemeral) {
Log.i("[Chat Message] Adding content to media store")
coreContext.addContentToMediaStore(getContent())
}
}
}
}
}
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
init {
isVoiceRecordPlaying.value = false
voiceRecordDuration.value = 0
voiceRecordPlayingPosition.value = 0
updateContent()
chatMessage.addListener(chatMessageListener)
}
fun destroy() {
scope.cancel()
val path = filePath.value.orEmpty()
if (path.isNotEmpty() && isFileEncrypted) {
Log.i("[Content] Deleting file used for preview: $path")
FileUtils.deleteFile(path)
filePath.value = ""
}
chatMessage.removeListener(chatMessageListener)
if (this::voiceRecordingPlayer.isInitialized) {
Log.i("[Voice Recording] Destroying voice record")
stopVoiceRecording()
voiceRecordingPlayer.removeListener(playerListener)
}
}
fun download() {
val content = getContent()
val filePath = content.filePath
if (content.isFileTransfer && (filePath == null || filePath.isEmpty())) {
val contentName = content.name
if (contentName != null) {
val file = FileUtils.getFileStoragePath(contentName)
content.filePath = file.path
downloadEnabled.value = false
Log.i("[Content] Started downloading $contentName into ${content.filePath}")
chatMessage.downloadContent(content)
}
}
}
fun openFile() {
listener?.onContentClicked(getContent())
}
private fun updateContent() {
val content = getContent()
isFileEncrypted = content.isFileEncrypted
filePath.value = ""
fileName.value = if (content.name.isNullOrEmpty() && !content.filePath.isNullOrEmpty()) {
FileUtils.getNameFromFilePath(content.filePath!!)
} else {
content.name
}
// Display download size and underline text
fileSize.value = AppUtils.bytesToDisplayableSize(content.fileSize.toLong())
val spannable = SpannableString("${AppUtils.getString(R.string.chat_message_download_file)} (${fileSize.value})")
spannable.setSpan(UnderlineSpan(), 0, spannable.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
downloadLabel.value = spannable
if (content.isFile || (content.isFileTransfer && chatMessage.isOutgoing)) {
Log.i("[Content] Is content encrypted ? $isFileEncrypted")
val path = if (isFileEncrypted) content.plainFilePath else content.filePath ?: ""
downloadable.value = content.filePath.orEmpty().isEmpty()
if (path.isNotEmpty()) {
Log.i("[Content] Found displayable content: $path")
val isVoiceRecord = content.isVoiceRecording
filePath.value = path
isImage.value = FileUtils.isExtensionImage(path)
isVideo.value = FileUtils.isExtensionVideo(path) && !isVoiceRecord
isAudio.value = FileUtils.isExtensionAudio(path) && !isVoiceRecord
isPdf.value = FileUtils.isExtensionPdf(path)
isVoiceRecording.value = isVoiceRecord
if (isVoiceRecord) {
val duration = content.fileDuration // duration is in ms
voiceRecordDuration.value = duration
formattedDuration.value = SimpleDateFormat("mm:ss", Locale.getDefault()).format(duration)
Log.i("[Voice Recording] Duration is ${voiceRecordDuration.value} ($duration)")
}
if (isVideo.value == true) {
scope.launch {
videoPreview.postValue(ImageUtils.getVideoPreview(path))
}
}
} else {
Log.w("[Content] Found content with empty path...")
isImage.value = false
isVideo.value = false
isAudio.value = false
isPdf.value = false
isVoiceRecording.value = false
}
} else {
downloadable.value = true
isImage.value = FileUtils.isExtensionImage(fileName.value!!)
isVideo.value = FileUtils.isExtensionVideo(fileName.value!!)
isAudio.value = FileUtils.isExtensionAudio(fileName.value!!)
isPdf.value = FileUtils.isExtensionPdf(fileName.value!!)
isVoiceRecording.value = false
}
isGenericFile.value = !isPdf.value!! && !isAudio.value!! && !isVideo.value!! && !isImage.value!! && !isVoiceRecording.value!!
downloadEnabled.value = !chatMessage.isFileTransferInProgress
downloadProgressInt.value = 0
downloadProgressString.value = "0%"
}
/** Voice recording specifics */
fun playVoiceRecording() {
Log.i("[Voice Recording] Playing voice record")
if (isPlayerClosed()) {
Log.w("[Voice Recording] Player closed, let's open it first")
initVoiceRecordPlayer()
}
if (AppUtils.isMediaVolumeLow(coreContext.context)) {
Toast.makeText(coreContext.context, R.string.chat_message_voice_recording_playback_low_volume, Toast.LENGTH_LONG).show()
}
if (voiceRecordAudioFocusRequest == null) {
voiceRecordAudioFocusRequest = AppUtils.acquireAudioFocusForVoiceRecordingOrPlayback(
coreContext.context
)
}
voiceRecordingPlayer.start()
isVoiceRecordPlaying.value = true
tickerFlow().onEach {
voiceRecordPlayingPosition.postValue(voiceRecordingPlayer.currentPosition)
}.launchIn(scope)
}
fun pauseVoiceRecording() {
Log.i("[Voice Recording] Pausing voice record")
if (!isPlayerClosed()) {
voiceRecordingPlayer.pause()
}
val request = voiceRecordAudioFocusRequest
if (request != null) {
AppUtils.releaseAudioFocusForVoiceRecordingOrPlayback(coreContext.context, request)
voiceRecordAudioFocusRequest = null
}
isVoiceRecordPlaying.value = false
}
private fun tickerFlow() = flow {
while (isVoiceRecordPlaying.value == true) {
emit(Unit)
delay(100)
}
}
private fun initVoiceRecordPlayer() {
Log.i("[Voice Recording] Creating player for voice record")
// Use speaker sound card to play recordings, otherwise use earpiece
// If none are available, default one will be used
var speakerCard: String? = null
var earpieceCard: String? = null
for (device in coreContext.core.audioDevices) {
if (device.hasCapability(AudioDevice.Capabilities.CapabilityPlay)) {
if (device.type == AudioDevice.Type.Speaker) {
speakerCard = device.id
} else if (device.type == AudioDevice.Type.Earpiece) {
earpieceCard = device.id
}
}
}
Log.i("[Voice Recording] Found speaker sound card [$speakerCard] and earpiece sound card [$earpieceCard]")
val localPlayer = coreContext.core.createLocalPlayer(speakerCard ?: earpieceCard, null, null)
if (localPlayer != null) {
voiceRecordingPlayer = localPlayer
} else {
Log.e("[Voice Recording] Couldn't create local player!")
return
}
voiceRecordingPlayer.addListener(playerListener)
val content = getContent()
val path = if (content.isFileEncrypted) content.plainFilePath else content.filePath ?: ""
voiceRecordingPlayer.open(path.orEmpty())
voiceRecordDuration.value = voiceRecordingPlayer.duration
formattedDuration.value = SimpleDateFormat("mm:ss", Locale.getDefault()).format(voiceRecordingPlayer.duration) // is already in milliseconds
Log.i("[Voice Recording] Duration is ${voiceRecordDuration.value} (${voiceRecordingPlayer.duration})")
}
private fun stopVoiceRecording() {
if (!isPlayerClosed()) {
Log.i("[Voice Recording] Stopping voice record")
pauseVoiceRecording()
voiceRecordingPlayer.seek(0)
voiceRecordPlayingPosition.value = 0
voiceRecordingPlayer.close()
}
}
private fun isPlayerClosed(): Boolean {
return !this::voiceRecordingPlayer.isInitialized || voiceRecordingPlayer.state == Player.State.Closed
}
}
interface OnContentClickedListener {
fun onContentClicked(content: Content)
}

View file

@ -0,0 +1,215 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.main.chat.data
import android.os.CountDownTimer
import android.text.Spannable
import android.text.util.Linkify
import androidx.core.text.util.LinkifyCompat
import androidx.lifecycle.MutableLiveData
import org.linphone.R
import org.linphone.contact.GenericContactData
import org.linphone.core.ChatMessage
import org.linphone.core.ChatMessageListenerStub
import org.linphone.core.tools.Log
import org.linphone.utils.AppUtils
import org.linphone.utils.TimestampUtils
class ChatMessageData(val chatMessage: ChatMessage) : GenericContactData(chatMessage.fromAddress) {
private var contentListener: OnContentClickedListener? = null
val sendInProgress = MutableLiveData<Boolean>()
val transferInProgress = MutableLiveData<Boolean>()
val showImdn = MutableLiveData<Boolean>()
val imdnIcon = MutableLiveData<Int>()
val backgroundRes = MutableLiveData<Int>()
val hideAvatar = MutableLiveData<Boolean>()
val hideTime = MutableLiveData<Boolean>()
val contents = MutableLiveData<ArrayList<ChatMessageContentData>>()
val time = MutableLiveData<String>()
val ephemeralLifetime = MutableLiveData<String>()
val text = MutableLiveData<Spannable>()
val replyData = MutableLiveData<ChatMessageData>()
private var countDownTimer: CountDownTimer? = null
private val listener = object : ChatMessageListenerStub() {
override fun onMsgStateChanged(message: ChatMessage, state: ChatMessage.State) {
time.value = TimestampUtils.toString(chatMessage.time)
updateChatMessageState(state)
}
override fun onEphemeralMessageTimerStarted(message: ChatMessage) {
updateEphemeralTimer()
}
}
init {
chatMessage.addListener(listener)
backgroundRes.value = if (chatMessage.isOutgoing) R.drawable.chat_bubble_outgoing_full else R.drawable.chat_bubble_incoming_full
hideAvatar.value = false
if (chatMessage.isReply) {
val reply = chatMessage.replyMessage
if (reply != null) {
Log.i("[Chat Message Data] Message is a reply of message id [${chatMessage.replyMessageId}] sent by [${chatMessage.replyMessageSenderAddress?.asStringUriOnly()}]")
replyData.value = ChatMessageData(reply)
}
}
time.value = TimestampUtils.toString(chatMessage.time)
updateEphemeralTimer()
updateChatMessageState(chatMessage.state)
updateContentsList()
}
override fun destroy() {
super.destroy()
if (chatMessage.isReply) {
replyData.value?.destroy()
}
contents.value.orEmpty().forEach(ChatMessageContentData::destroy)
chatMessage.removeListener(listener)
contentListener = null
}
fun updateBubbleBackground(hasPrevious: Boolean, hasNext: Boolean) {
if (hasPrevious) {
hideTime.value = true
}
if (chatMessage.isOutgoing) {
if (hasNext && hasPrevious) {
backgroundRes.value = R.drawable.chat_bubble_outgoing_split_2
} else if (hasNext) {
backgroundRes.value = R.drawable.chat_bubble_outgoing_split_1
} else if (hasPrevious) {
backgroundRes.value = R.drawable.chat_bubble_outgoing_split_3
} else {
backgroundRes.value = R.drawable.chat_bubble_outgoing_full
}
} else {
if (hasNext && hasPrevious) {
hideAvatar.value = true
backgroundRes.value = R.drawable.chat_bubble_incoming_split_2
} else if (hasNext) {
backgroundRes.value = R.drawable.chat_bubble_incoming_split_1
} else if (hasPrevious) {
hideAvatar.value = true
backgroundRes.value = R.drawable.chat_bubble_incoming_split_3
} else {
backgroundRes.value = R.drawable.chat_bubble_incoming_full
}
}
}
fun setContentClickListener(listener: OnContentClickedListener) {
contentListener = listener
for (data in contents.value.orEmpty()) {
data.listener = listener
}
}
private fun updateChatMessageState(state: ChatMessage.State) {
transferInProgress.value = state == ChatMessage.State.FileTransferInProgress
sendInProgress.value = state == ChatMessage.State.InProgress || state == ChatMessage.State.FileTransferInProgress
showImdn.value = when (state) {
ChatMessage.State.DeliveredToUser, ChatMessage.State.Displayed, ChatMessage.State.NotDelivered -> true
else -> false
}
imdnIcon.value = when (state) {
ChatMessage.State.DeliveredToUser -> R.drawable.chat_delivered
ChatMessage.State.Displayed -> R.drawable.chat_read
else -> R.drawable.chat_error
}
}
private fun updateContentsList() {
contents.value.orEmpty().forEach(ChatMessageContentData::destroy)
val list = arrayListOf<ChatMessageContentData>()
val contentsList = chatMessage.contents
for (index in 0 until contentsList.size) {
val content = contentsList[index]
if (content.isFileTransfer || content.isFile) {
val data = ChatMessageContentData(chatMessage, index)
data.listener = contentListener
list.add(data)
} else if (content.isText) {
val spannable = Spannable.Factory.getInstance().newSpannable(content.utf8Text)
LinkifyCompat.addLinks(spannable, Linkify.WEB_URLS)
text.value = spannable
}
}
contents.value = list
}
private fun updateEphemeralTimer() {
if (chatMessage.isEphemeral) {
if (chatMessage.ephemeralExpireTime == 0L) {
// This means the message hasn't been read by all participants yet, so the countdown hasn't started
// In this case we simply display the configured value for lifetime
ephemeralLifetime.value = formatLifetime(chatMessage.ephemeralLifetime)
} else {
// Countdown has started, display remaining time
val remaining = chatMessage.ephemeralExpireTime - (System.currentTimeMillis() / 1000)
ephemeralLifetime.value = formatLifetime(remaining)
if (countDownTimer == null) {
countDownTimer = object : CountDownTimer(remaining * 1000, 1000) {
override fun onFinish() {}
override fun onTick(millisUntilFinished: Long) {
ephemeralLifetime.postValue(formatLifetime(millisUntilFinished / 1000))
}
}
countDownTimer?.start()
}
}
}
}
private fun formatLifetime(seconds: Long): String {
val days = seconds / 86400
return when {
days >= 1L -> AppUtils.getStringWithPlural(R.plurals.days, days.toInt())
else -> String.format("%02d:%02d:%02d", seconds / 3600, (seconds % 3600) / 60, (seconds % 60))
}
}
}

View file

@ -0,0 +1,73 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.main.chat.data
import androidx.lifecycle.MutableLiveData
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.contact.Contact
import org.linphone.contact.ContactDataInterface
import org.linphone.core.*
import org.linphone.utils.LinphoneUtils
class ChatRoomCreationContactData(private val searchResult: SearchResult) : ContactDataInterface {
override val contact: MutableLiveData<Contact> = MutableLiveData<Contact>()
override val displayName: MutableLiveData<String> = MutableLiveData<String>()
override val securityLevel: MutableLiveData<ChatRoomSecurityLevel> = MutableLiveData<ChatRoomSecurityLevel>()
val isDisabled: MutableLiveData<Boolean> by lazy {
MutableLiveData<Boolean>()
}
val isSelected: MutableLiveData<Boolean> by lazy {
MutableLiveData<Boolean>()
}
val isLinphoneUser: Boolean by lazy {
searchResult.friend?.getPresenceModelForUriOrTel(searchResult.phoneNumber ?: searchResult.address?.asStringUriOnly() ?: "")?.basicStatus == PresenceBasicStatus.Open
}
val sipUri: String by lazy {
searchResult.phoneNumber ?: LinphoneUtils.getDisplayableAddress(searchResult.address)
}
val address: Address? by lazy {
searchResult.address
}
val hasLimeX3DHCapability: Boolean
get() = searchResult.hasCapability(FriendCapability.LimeX3Dh)
init {
isDisabled.value = false
isSelected.value = false
searchMatchingContact()
}
private fun searchMatchingContact() {
val address = searchResult.address
if (address != null) {
contact.value = coreContext.contactsManager.findContactByAddress(address)
displayName.value = searchResult.friend?.name ?: LinphoneUtils.getDisplayName(address)
} else if (searchResult.phoneNumber != null) {
contact.value = coreContext.contactsManager.findContactByPhoneNumber(searchResult.phoneNumber.orEmpty())
displayName.value = searchResult.friend?.name ?: searchResult.phoneNumber.orEmpty()
}
}
}

View file

@ -0,0 +1,49 @@
/*
* Copyright (c) 2010-2020 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.main.chat.data
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.R
import org.linphone.core.ChatRoomSecurityLevel
import org.linphone.core.ParticipantDevice
class DevicesListChildData(private val device: ParticipantDevice) {
val deviceName: String = device.name.orEmpty()
val securityLevelIcon: Int by lazy {
when (device.securityLevel) {
ChatRoomSecurityLevel.Safe -> R.drawable.security_2_indicator
ChatRoomSecurityLevel.Encrypted -> R.drawable.security_1_indicator
else -> R.drawable.security_alert_indicator
}
}
val securityContentDescription: Int by lazy {
when (device.securityLevel) {
ChatRoomSecurityLevel.Safe -> R.string.content_description_security_level_safe
ChatRoomSecurityLevel.Encrypted -> R.string.content_description_security_level_encrypted
else -> R.string.content_description_security_level_unsafe
}
}
fun onClick() {
coreContext.startCall(device.address, true)
}
}

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