first commit

This commit is contained in:
zhongjin 2023-01-25 15:19:50 +08:00
commit 8cf155b9e9
990 changed files with 72409 additions and 0 deletions

65
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@ -0,0 +1,65 @@
---
name: Bug report
about: How to create a proper bug report
title: ''
labels: ''
assignees: ''
---
First of all, please say "Hi" or "Hello", it doesn't cost much.
1. **Describe the bug** (mandatory)
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)
A clear and concise description of what you expected to happen.
4. **Please complete the following information** (mandatory)
- 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.
If you are using a SDK that isn't the latest release, please update first as it's likely your issue is already solved.
5. **SDK logs** (mandatory)
Enable debug logs in advanced section of the settings, restart the app, reproduce the issue and then go back to advanced settings, click on "Send logs" and copy/paste the link here.
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)
If applicable, add screenshots to help explain your problem.
8. **Additional context** (optionnal)
Add any other context about the problem here.
Thank you in advance for filling bug reports properly!

29
.gitignore vendored Normal file
View File

@ -0,0 +1,29 @@
*.orig
*.rej
.DS_Store
.gradle
.idea
.settings
adb.pid
bc-android.keystore
build
*.iml
lint.xml
local.properties
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
app/releaseWithCrashlytics
keystore.properties
app/src/main/res/xml/contacts.xml

View File

@ -0,0 +1,35 @@
job-android:
stage: build
tags: [ "docker-android" ]
image: gitlab.linphone.org:4567/bc/public/linphone-android/bc-dev-android:20220609_android_33
before_script:
- if ! [ -z ${SCP_PRIVATE_KEY+x} ]; then eval $(ssh-agent -s); fi
- if ! [ -z ${SCP_PRIVATE_KEY+x} ]; then echo "$SCP_PRIVATE_KEY" | tr -d '\r' | ssh-add - > /dev/null; fi
- echo "$ANDROID_SETTINGS_GRADLE" > settings.gradle
script:
- sdkmanager
- 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
- echo keyPassword=$ANDROID_KEYSTORE_KEY_PASSWORD >> keystore.properties
- echo keyAlias=$ANDROID_KEYSTORE_KEY_ALIAS >> keystore.properties
- echo storeFile=$ANDROID_KEYSTORE_FILE >> keystore.properties
- ./gradlew app:dependencies | grep org.linphone
- ./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 week
.scheduled-job-android:
extends: job-android
only:
- schedules

View File

@ -0,0 +1,12 @@
job-android-upload:
stage: deploy
tags: [ "deploy" ]
only:
- schedules
dependencies:
- job-android
script:
- cd app/build/outputs/apk/ && rsync ./debug/*.apk $DEPLOY_SERVER:$ANDROID_DEPLOY_DIRECTORY

19
.gitlab-ci.yml Normal file
View File

@ -0,0 +1,19 @@
#################################################
# Base configuration
#################################################
#################################################
# Platforms to test
#################################################
include:
- '.gitlab-ci-files/job-android.yml'
- '.gitlab-ci-files/job-upload.yml'
stages:
- build
- deploy

11
.tx/config Normal file
View File

@ -0,0 +1,11 @@
[main]
host = https://www.transifex.com
lang_map = fr_CA:fr-rCA,pt_BR:pt-rBR,zh_CN:zh-rCN,zh_HK:zh-rHK,zh_TW:zh-rTW,da_DK:da-rDK,sv_SE:sv-rSE,kn_IN:kn-rIN,nl_NL:nl-rNL,en_NL:en-rNL,he:iw
minimum_perc = 1
type = ANDROID
[linphone-android.stringsxml]
file_filter = app/src/main/res/values-<lang>/strings.xml
source_file = app/src/main/res/values/strings.xml
source_lang = en

599
CHANGELOG.md Normal file
View File

@ -0,0 +1,599 @@
# Change Log
All notable changes to this project will be documented in this file.
Group changes to describe their impact on the project, as follows:
Added for new features.
Changed for changes in existing functionality.
Deprecated for once-stable features removed in upcoming releases.
Removed for deprecated features removed in this release.
Fixed for any bug fixes.
Security to invite users to upgrade in case of vulnerabilities.
## [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
### 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
- 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
### Changed
- SDK updated to 5.0.45
## [4.5.4] - 2021-10-19
### Changed
- SDK updated to 5.0.38
### Fixed
- Side menu not showing the newly configured account until next start
## [4.5.3] - 2021-10-04
### Added
- Russian translation
### Changed
- SDK updated to 5.0.31
### Fixed
- AccountSettingsViewModel leak causing number of REGISTER to grow
## [4.5.2] - 2021-08-27
### Added
- Added a contact cache at app level
- Glide cache cleared on low memory
### Changed
- Fixed encrypted file export when VFS is enabled
- Fixed in-app video player size when VFS is enabled
- Fixed background mode setting
- Fixed proximity sensor during calls
- Fixed missing notification for missed call when call history view is active
- Fixed shortcuts on launcher
- Fixed a few memory leaks
- Fixed various crashes & other issues
- SDK bumped to 5.0.10
## [4.5.1] - 2021-07-15
### Changed
- Bugs & crashes have been fixed
- SDK bumped to 5.0.1
## [4.5.0] - 2021-07-08
This version is a full rewrite of the app in kotlin, using modern Android components like navigation, viewmodel, databinding, coroutines, etc...
### Added
- Using linphone SDK 5.0 API to better handle audio route (see linphone-sdk changelog)
- All files used by the app can now be encrypted for more security (VFS setting)
- In-app file viewers for PDFs, images, videos, sounds and texts
- Ephemeral messages
- Messages can be forwarded between chat rooms
- Numpad can be displayed in outgoing call view if the call has early media
- Can display multiple files in the same chat bubble
- Display video in recordings if available
- "Swipe left to delete" action available on calls history, contacts & chat rooms list
- "Swipe right" to mark a chat room as read
- Android 11 people & conversation compliant
- New animations between fragments and for unread chat messages / missed calls counters (can be disabled)
- Bubble & conversation support for chat message notifications
- Direct share support for chat room shortcuts
- Option to mark messages as read when dismissing the notification
- More settings are available
- Call view can be displayed in full-screen
- Display phone number label (home, work, etc...) in contacts' details
### Changed
- Call history view groups call from the same SIP URI (like linphone-iphone)
- Reworked conference (using new linphone-sdk APIs)
- Route audio to headset / headphones / bluetooth device automatically when available
- Send logs / Reset logs buttons moved from About page to Advanced Settings like iOS
- Improved how Android native contacts are used
- Switched to material design for text input fields & switches
- Launcher shortcuts can be to either contacts or chat rooms
- Improved preview when sharing video files through the chat
- UI changes
### Removed
- "back-to-call" button from dialer & chat views, use notification or overlay (see call settings for in-app/system-wide overlay)
- Don't ask for "Do not disturb settings" permission anymore
- Previous translations, starting again from scratch using Weblate instead of Transifex
### [4.4.0] - 2021-03-29
### Added
- Dedicated notification channel for missed calls
### Changed
- SDK updated to 4.5.0
- Min Android version updated from 21 to 23 (Android 6) due to SDK audio routes feature
- Rely on SDK audio routes feature instead of doing it in the application
- User can now check incoming messages delivery status in group chat rooms
- Asking user to read and accept privacy policy and general terms
- Updated translations
- Various crashes & issues fixed
## [4.3.1] - 2020-09-25
### Fixed
- Added phoneCall foregroundServiceType for Android Q and newer
- Contact sorting when first character has an accent
### Changed
- SDK updated to 4.4.2
- Updated translations
## [4.3.0] - 2020-06-23
### Added
- Forward message between chat rooms
### Changed
- Files from chat messages are now stored in a private space and will be deleted when the message or room will be deleted
- SDK updated to 4.4 version
- Fixed ANRs
- Fixed various issues
## [4.2.3] - 2020-03-03
### Changed
- Fixed various crashes
- Updated SDK to 4.3.3
## [4.2.2] - 2020-02-24
### Changed
- Fixed various issues
- Updated SDK to 4.3.1
- Removed AAudio plugin for now (we have observed quality issues on some popular devices with their latest updates)
## [4.2.1] - 2020-01-13
### Changed
- Fixed various issues
## [4.2.0] - 2019-12-09
### Added
- Added shortcuts to contacts' latest chat rooms
- Improved device's do not disturb policy compliance
- Added sample application to help developpers getting started with our SDK
- Added picture in picture feature if supported instead of video overlay
- Added camera preview as dialer's background on tablets
- Contact section in the settings
- Using new AAudio & Camera2 frameworks for better performances (if available)
- Android 10 compatibility
- New plugin loader to be compatible with app bundle distribution mode
- Restart service if foreground service setting is on when app is updated
- Change bluetooth volume while in call if BT device connected and used
### Changed
- Improved performances to reduce startup time
- Call statistics are now available for each call & conference
- Added our own devices in LIME encrypted chatrooms' security view
- No longer display incoming call activity from Service, instead use incoming call notification with full screen intent
- Improved reply notification when replying to a chat message from the notification
- License changed from GPLv2 to GPLv3
- Switched from MD5 to SHA-256 as password protection algorithm
## [4.1.0] - 2019-05-03
### Added
- End-to-end encryption for instant messaging, for both one-to-one and group conversations.
- Video H.265 codec support, based on android MediaCodec.
- Enhanced call and IM notifications, so that it is possible to answer, decline, reply or mark as read directly from them.
- Setting to request attachments to be automatically downloaded, unconditionnally or based on their size.
- Possibility to send multiple attachments (images, documents) in a same message.
- Possibility to share multiple images through Linphone from an external application (ex: photo app)
- Rich input from keyboard (images, animated gifs...) when composing messages.
- Rendering of animated gifs in conversations.
- Button to invite contacts to use Linphone by sending them a SMS.
- Possibility to record calls (audio only), and replay them from the "Recordings" menu.
- Remote provisioning from a QR code providing the http(s) url of a provisioning server.
- Option for a dark theme
### Changed
- Compilation procedure is simplified: a binary SDK containing dependencies (liblinphone) is retrieved automatically from a Maven repository.
Full compilation remains absolutely supported. Please check local README.md for more details.
- Updated translations, mainly French and English.
- Call history view shows last calls for a given contact.
- Improved ergonomy of answer/decline buttons, including accessibility support.
- Enhanced user interface, including new icons, cleanups of unused graphical resources.
- Contact view is faster thanks to an asynchronous fetching.
- Adaptive icon for Android 8+.
- Video overlay now also shows local view.
- Reworked settings view, cleanup of useless settings.
- About section links to full GPLv2 license text.
### Deprecated
- The video rendering method based on GL2JNIView is deprecated in favour of TextureView, which is easier to use.
Please read [this article](https://wiki.linphone.org/xwiki/wiki/public/view/Lib/Features/Android%20TextureView%20Display/) for more information.
### Fixed
- One to one text conversations mixed up when initiated from differents SIP accounts.
## [4.0.1] - 2018-06-26
### Fixed
- fix loading of plugins
- fix issue with video stream, not started when receiving an incoming call just after the app is launched
- fix issue with TURN
## [4.0.0] - 2018-06-15
### Added
- Group chat between linphone.org SIP accounts.
- new JAVA/JNI wrapper. This new wrapper is automatically generated from liblinphone C API. It breaks compatibility with previous, hand-made wrapper.
(more information about new wrapper [here.](https://wiki.linphone.org/xwiki/wiki/public/view/Lib/Linphone%20%28Android%29%20Java%20wrapper/) )
### Deprecated
- hand-made java API in submodules/linphone/java is deprecated. However it is still possible to use it by checking out the 3.4.x branch of linphone-android.
### Fixed
- issue with changing push notification token not passed to library, possibly resulting in a loss of incoming calls.
## [3.3.0] - 2017-10-18
### Added
- Integration with Android O
- New video adaptive bitrate algorithm(More informations [here](https://wiki.linphone.org/xwiki/wiki/public/view/FAQ/How%20does%20adaptive%20bitrate%20algorithm%20work%20%3F/))
### Changed
- Application is no more managing in-call wakelock, it's now managed by the library
### Fixed
- Crashs in new chat view
- Contacts management
- Random crash in chatroom
- Improve chats list loading time
## [3.2.7] - 2017-05-15
### Fixed
- Crash with devices X86 on Android < 5
## [3.2.6] - 2017-04-10
### Added
- Notification of message reading on chat
- New permission to kill linphone app
### Fixed
- Crash with firebase push
- Problems with contacts
## [3.2.5] - 2017-03-06
### Added
- Doze mode(energy saving) button in Network settings
### Changed
- Migrate Linphone build from ANT to gradle
- No pause VOIP Call on incoming GSM call until we off hook this
- Subscription friends list enabled by default only for linphone domain
### Fixed
- Rotation after screen locking
- Contacts background task
- No more asking phone number for non-linphone domain
- Bug with Linphone credential login
## [Unreleased]
### Added
- Lime integration
## [3.2.4] - 2017-01-19
### Fixed
- Some crashs
- Some UI bugs
## [3.2.3] - 2017-01-11
### Fixed
- Somes crashs
### Changed
- Improved performance of contacts loading
## [3.2.2] - 2017-01-04
### Fixed
- Some bug with the download of OpenH264 for Android < 5.1
- Some crashs
### Changed
- Disable AAC codecs
## [3.2.1] - 2016-11-24
### Added
- Open H264 binary download for ARM Android < 5.1
### Fixed
- Crashes for x86 CPU at starting
- Crashes in somes view in cause of bad locale time
- Crashes in contacts view if we don't have permission
## [3.2.0] - 2016-11-10
### Added
- Change your password in your account settings
### Changed
- Media H264 support improved for Android >= 5.1
- Optimize memory footprint and performance of contacts list an IM view
### Fixed
- Crashes Android 6/7 at starting
- Permissions issues
- Layout of tablet views

674
LICENSE.txt Normal file
View File

@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
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 <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

179
README.md Normal file
View File

@ -0,0 +1,179 @@
[![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/widgets/linphone/-/linphone-android/svg-badge.svg)](https://weblate.linphone.org/engage/linphone/?utm_source=widget)
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://www.linphone.org/technical-corner/linphone).
### 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).
### License
Copyright © Belledonne Communications
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://www.linphone.org/contact) for any question about costs and services.
### Documentation
- Supported features and RFCs : https://www.linphone.org/technical-corner/linphone/features
- Linphone public wiki : https://wiki.linphone.org/xwiki/wiki/public/view/Linphone/
- Tutorials : https://gitlab.linphone.org/BC/public/tutorials/-/tree/master/android/kotlin
# What's new
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 detailed list.
The first linphone-android release that will be based on this will be 4.5.0, using 5.0.0 SDK.
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.
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
If you have Android Studio, simply open the project, wait for the gradle synchronization and then build/install the app.
It will download the linphone library from our Maven repository as an AAR file so you don't have to build anything yourself.
If you don't have Android Studio, you can build and install the app using gradle:
```
./gradlew assembleDebug
```
will compile the APK file (assembleRelease to instead if you want to build a release package), and then
```
./gradlew installDebug
```
to install the generated APK in the previous step (use installRelease instead if you built a release package).
APK files are stored within ```./app/build/outputs/apk/debug/``` and ```./app/build/outputs/apk/release/``` directories.
When building a release AppBundle, use releaseAppBundle target instead of release.
Also make sure you have a NDK installed and that you have an environment variable named ```ANDROID_NDK_HOME``` that contains the path to the NDK.
This is to be able to include native libraries symbols into app bundle for the Play Store.
## Building a local SDK
1. Clone the linphone-sdk repository from out gitlab:
```
git clone https://gitlab.linphone.org/BC/public/linphone-sdk.git --recursive
```
2. Follow the instructions in the linphone-sdk/README file to build the SDK.
3. Create or edit the gradle.properties file in $GRADLE_USER_HOME (usually ~/.gradle/) and add the absolute path to your linphone-sdk build directory, for example:
```
LinphoneSdkBuildDir=/home/<username>/linphone-sdk/build/
```
4. Rebuild the app in Android Studio.
## Native debugging
1. Install LLDB from SDK Tools in Android-studio.
2. In Android-studio go to Run->Edit Configurations->Debugger.
3. Select 'Dual' or 'Native' and add the path to linphone-sdk debug libraries (build/libs-debug/ for example).
4. Open native file and put your breakpoint on it.
5. Make sure you are using the debug AAR in the app/build.gradle script and not the release one (to have faster builds by default the release AAR is used even for debug APK flavor).
6. Debug app.
## Known issues
- 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
### 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:
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.
2. Then restart the app, reproduce the issue and upload the logs using the `Send logs` button on the advanced settings page.
3. Finally paste the link to the uploaded logs (link is already in the clipboard after a successful upload).
### Native crash
First of all, to be able to get a symbolized stack trace, you need the debug version of our libraries.
If you haven't built the SDK locally (see [building a local SDK](#BuildingalocalSDK)), here's how to get them:
1. Go to our [maven repository](https://download.linphone.org/maven_repository/org/linphone/linphone-sdk-android-debug/), in the linphone-android-debug directory.
2. Download the AAR file with **the exact same version** as the AAR that was used to generate the crash's stacktrace.
3. Extract the AAR somewhere on your computer (it's a simple ZIP file even it's doesn't have the extension). Libraries are stored inside the ```jni``` folder (a directory for each architectured built, usually ```arm64-v8a, armeabi-v7a, x86_64 and x86```).
4. To get consistent with locally built SDK, rename the ```jni``` directory into ```libs-debug```.
Now you need the ```ndk-stack``` tool and possibly ```adb logcat```.
If your computer isn't used for Android development, you can download those tools from [Google website](https://developer.android.com/studio#downloads), in the ```Command line tools only``` section.
Once you have the debug libraries and the proper tools installed, you can use the ```ndk-stack``` tool to symbolize your stacktrace. Note that you also need to know the architecture (armv7, arm64, x86, etc...) of the libraries that were used.
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!
## Create an APK with a different package name
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.
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
Now that Google Cloud Messaging has been deprecated and will be completely removed on April 11th 2019, the only official way of using push notifications is through Firebase.
However to make Firebase push notifications work, the project needs to have a ```app/google-services.json``` file that contains the configuration.
We have archived our own, so you can build your linphone-android application and still receive push notifications from our free SIP service (sip.linphone.org).
If you delete it, you won't receive any push notification.
If you have your own push server, replace this file by yours.
## Translations
We no longer use transifex for the translation process, instead we have deployed our own instance of [Weblate](https://weblate.linphone.org/).
Due to the full app rewrite we can't re-use previous translations, so we'll be very happy if you want to contribute.
# 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.
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.

1
app/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

275
app/build.gradle Normal file
View File

@ -0,0 +1,275 @@
plugins {
id 'com.android.application'
id 'kotlin-android'
id 'kotlin-kapt'
id 'org.jlleitschuh.gradle.ktlint'
}
def appVersionName = "5.0.2"
def appVersionCode = 50002
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 = appVersionName
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 + " (" + appVersionCode + ")")
} catch (ignored) {
println("Git not found, using " + gitVersion + " (" + appVersionCode + ")")
}
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 33
buildToolsVersion '33.0.0'
defaultConfig {
minSdkVersion 23
targetSdkVersion 33
versionCode appVersionCode
versionName "${project.version}"
applicationId getPackageName()
}
applicationVariants.all { variant ->
variant.outputs.all {
outputFileName = "linphone-android-${variant.buildType.name}-${project.version}.apk"
}
var enableFirebaseService = "false"
if (firebaseEnabled) {
enableFirebaseService = "true"
}
// 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 == "releaseWithCrashlytics") {
variant.getMergedFlavor().manifestPlaceholders = [linphone_address_mime_type: "vnd.android.cursor.item/vnd." + getPackageName() + ".provider.sip_address",
linphone_file_provider: getPackageName() + ".fileprovider",
appLabel: "@string/app_name",
firebaseServiceEnabled: enableFirebaseService,
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",
firebaseServiceEnabled: enableFirebaseService,
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"
resValue "bool", "crashlytics_enabled", crashlyticsEnabled.toString()
if (!firebaseEnabled) {
resValue "string", "gcm_defaultSenderId", "none"
}
if (crashlyticsEnabled) {
firebaseCrashlytics {
nativeSymbolUploadEnabled true
unstrippedNativeLibsDir file(LinphoneSdkBuildDir + '/libs-debug/').toString()
}
}
}
}
buildFeatures {
dataBinding = true
}
namespace 'org.linphone'
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation 'androidx.appcompat:appcompat:1.6.0-rc01'
implementation 'androidx.core:core-ktx:1.9.0'
implementation 'androidx.core:core-splashscreen:1.0.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1'
implementation 'androidx.media:media:1.6.0'
implementation "androidx.security:security-crypto-ktx:1.1.0-alpha03"
implementation "androidx.window:window:1.0.0"
def nav_version = "2.5.3"
implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
implementation "androidx.slidingpanelayout:slidingpanelayout:1.2.0"
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation "androidx.gridlayout:gridlayout:1.0.0"
implementation 'androidx.recyclerview:recyclerview:1.3.0-rc01'
// https://github.com/material-components/material-components-android/blob/master/LICENSE Apache v2.0
implementation 'com.google.android.material:material:1.7.0'
// https://github.com/google/flexbox-layout/blob/main/LICENSE Apache v2.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'
// https://github.com/coil-kt/coil/blob/main/LICENSE.txt Apache v2.0
def coil_version = "2.1.0"
implementation("io.coil-kt:coil:$coil_version")
implementation("io.coil-kt:coil-gif:$coil_version")
implementation("io.coil-kt:coil-svg:$coil_version")
implementation("io.coil-kt:coil-video:$coil_version")
// https://github.com/Baseflow/PhotoView/blob/master/LICENSE Apache v2.0
implementation 'com.github.chrisbanes:PhotoView:2.3.0'
implementation platform('com.google.firebase:firebase-bom:31.0.3')
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.2+'
// 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)
}
}

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>

57
app/google-services.json Normal file
View File

@ -0,0 +1,57 @@
{
"project_info": {
"project_number": "929724111839",
"firebase_url": "https://linphone-android-8a563.firebaseio.com",
"project_id": "linphone-android-8a563",
"storage_bucket": "linphone-android-8a563.appspot.com"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:929724111839:android:4662ea9a056188c4",
"android_client_info": {
"package_name": "org.linphone"
}
},
"oauth_client": [
{
"client_id": "929724111839-co5kffto4j7dets7oolvfv0056cvpfbl.apps.googleusercontent.com",
"client_type": 1,
"android_info": {
"package_name": "org.linphone",
"certificate_hash": "85463a95603f7b6331899b74b85d53d043dcd500"
}
},
{
"client_id": "929724111839-v5so1tcd65iil7dd7sde8jgii44h8luf.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "AIzaSyCKrwWhkbA7Iy3wpEI8_ZvKOMp5jf6vV6A"
}
]
},
{
"client_info": {
"mobilesdk_app_id": "1:929724111839:android:3cf90ee1d2f8fcb6",
"android_client_info": {
"package_name": "org.linphone.debug"
}
},
"oauth_client": [
{
"client_id": "929724111839-v5so1tcd65iil7dd7sde8jgii44h8luf.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "AIzaSyCKrwWhkbA7Iy3wpEI8_ZvKOMp5jf6vV6A"
}
]
}
],
"configuration_version": "1"
}

24
app/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,24 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
-keep public class * extends androidx.fragment.app.Fragment { *; }
-dontwarn com.google.errorprone.annotations.Immutable

View File

@ -0,0 +1,243 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- To be able to display contacts list & match calling/called numbers -->
<uses-permission android:name="android.permission.READ_CONTACTS" />
<!-- For in-app contact edition -->
<uses-permission android:name="android.permission.WRITE_CONTACTS" />
<!-- 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" />
<!-- Starting Android 13 we need to ask notification permission -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<!-- Needed for full screen intent in incoming call notifications -->
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<!-- To vibrate when pressing DTMF keys on numpad & incoming calls -->
<uses-permission android:name="android.permission.VIBRATE" />
<!-- Needed to attach file(s) in chat room fragment -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32"/>
<!-- Starting Android 13 you need those 3 permissions instead (https://developer.android.com/about/versions/13/behavior-changes-13) -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<!-- Needed to shared downloaded files if setting is on -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<!-- Both permissions below are for contacts sync account, needed to store presence in native contact if enabled -->
<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" />
<!-- Needed to check current Do not disturb policy -->
<uses-permission android:name="android.permission.ACCESS_NOTIFICATION_POLICY" />
<!-- Needed starting Android 12 for broadcast receiver
to be triggered when BT device is connected / disconnected
(https://developer.android.com/guide/topics/connectivity/bluetooth/permissions) -->
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- Needed for foreground service
(https://developer.android.com/guide/components/foreground-services) -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<application
android:name=".LinphoneApplication"
android:allowBackup="false"
android:enableOnBackInvokedCallback="true"
android:icon="@mipmap/ic_launcher"
android:label="${appLabel}"
android:localeConfig="@xml/locales_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:extractNativeLibs="${extractNativeLibs}"
android:theme="@style/AppTheme"
android:allowNativeHeapPointerTagging="false">
<activity android:name=".activities.main.MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize"
android:theme="@style/AppSplashScreenTheme">
<nav-graph android:value="@navigation/main_nav_graph" />
<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" />
<intent-filter>
<action android:name="android.intent.action.VIEW_LOCUS" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/*" />
<data android:mimeType="image/*" />
<data android:mimeType="audio/*" />
<data android:mimeType="video/*" />
<data android:mimeType="application/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
<data android:mimeType="audio/*" />
<data android:mimeType="video/*" />
<data android:mimeType="application/*" />
</intent-filter>
<intent-filter>
<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="sips" />
<data android:scheme="linphone" />
<data android:scheme="sip-linphone" />
</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=".activities.assistant.AssistantActivity"
android:windowSoftInputMode="adjustResize"/>
<activity android:name=".activities.voip.CallActivity"
android:launchMode="singleTask"
android:turnScreenOn="true"
android:showWhenLocked="true"
android:resizeableActivity="true"
android:supportsPictureInPicture="true" />
<activity
android:name=".activities.chat_bubble.ChatBubbleActivity"
android:allowEmbedded="true"
android:documentLaunchMode="always"
android:resizeableActivity="true" />
<!-- Services -->
<service
android:name=".core.CoreService"
android:exported="false"
android:foregroundServiceType="phoneCall|camera|microphone"
android:stopWithTask="false"
android:label="@string/app_name" />
<service android:name="org.linphone.core.tools.firebase.FirebaseMessaging"
android:enabled="${firebaseServiceEnabled}"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<service
android:name=".contact.DummySyncService"
android:exported="true">
<intent-filter>
<action android:name="android.content.SyncAdapter" />
</intent-filter>
<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 -->
<receiver android:name=".core.CorePushReceiver"
android:exported="false">
<intent-filter>
<action android:name="org.linphone.core.action.PUSH_RECEIVED"/>
</intent-filter>
</receiver>
<receiver
android:name=".notifications.NotificationBroadcastReceiver"
android:enabled="true"
android:exported="false" />
<receiver android:name=".core.BootReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<!-- Providers -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${linphone_file_provider}"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
</provider>
</application>
</manifest>

View File

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<config xmlns="http://www.linphone.org/xsds/lpconfig.xsd" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.linphone.org/xsds/lpconfig.xsd lpconfig.xsd">
<section name="proxy_default_values">
<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="quality_reporting_collector" overwrite="true"></entry>
<entry name="quality_reporting_enabled" overwrite="true">0</entry>
<entry name="quality_reporting_interval" overwrite="true">0</entry>
<entry name="reg_expires" overwrite="true">3600</entry>
<entry name="reg_identity" overwrite="true"></entry>
<entry name="reg_proxy" overwrite="true"></entry>
<entry name="reg_route" overwrite="true"></entry>
<entry name="reg_sendregister" overwrite="true">1</entry>
<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>
</section>
<section name="nat_policy_default_values">
<entry name="stun_server" overwrite="true"></entry>
<entry name="protocols" overwrite="true"></entry>
</section>
<section name="net">
<entry name="friendlist_subscription_enabled" overwrite="true">0</entry>
</section>
<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

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<config xmlns="http://www.linphone.org/xsds/lpconfig.xsd" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.linphone.org/xsds/lpconfig.xsd lpconfig.xsd">
<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">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>
<entry name="reg_expires" overwrite="true">31536000</entry>
<entry name="reg_identity" overwrite="true">sip:?@sip.linphone.org</entry>
<entry name="reg_proxy" overwrite="true">&lt;sip:sip.linphone.org;transport=tls&gt;</entry>
<entry name="reg_route" overwrite="true">&lt;sip:sip.linphone.org;transport=tls&gt;</entry>
<entry name="reg_sendregister" overwrite="true">1</entry>
<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>
</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="net">
<entry name="friendlist_subscription_enabled" overwrite="true">1</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

@ -0,0 +1,43 @@
## Start of default rc
[sip]
contact="Linphone Android" <sip:linphone.android@unknown-host>
use_info=0
use_ipv6=1
keepalive_period=30000
sip_port=-1
sip_tcp_port=-1
sip_tls_port=-1
media_encryption=none
[net]
#Because dynamic bitrate adaption can increase bitrate, we must allow "no limit"
download_bw=0
upload_bw=0
[video]
size=vga
[app]
tunnel=disabled
auto_start=1
record_aware=1
[tunnel]
host=
port=443
[misc]
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
[in-app-purchase]
server_url=https://subscribe.linphone.org:444/inapp.php
purchasable_items_ids=test_account_subscription
## End of default rc

View File

@ -0,0 +1,54 @@
## Start of factory rc
# This file shall not contain path referencing package name, in order to be portable when app is renamed.
# Paths to resources must be set from LinphoneManager, after creating LinphoneCore.
[net]
mtu=1300
force_ice_disablement=0
[rtp]
accept_any_encryption=1
[sip]
guess_hostname=1
register_only_when_network_is_up=1
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_KYB512
chat_messages_aggregation_delay=1000
chat_messages_aggregation=1
[sound]
#remove this property for any application that is not Linphone public version itself
ec_calibrator_cool_tones=1
[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
[app]
activation_code_length=4
prefer_basic_chat_room=1
record_aware=1
[assistant]
xmlrpc_url=https://subscribe.linphone.org:444/wizard.php
[account_creator]
backend=0
[lime]
lime_update_threshold=-1
## End of factory rc

View File

@ -0,0 +1,129 @@
/*
* 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
import android.annotation.SuppressLint
import android.app.Application
import android.content.Context
import coil.ImageLoader
import coil.ImageLoaderFactory
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import coil.decode.SvgDecoder
import coil.decode.VideoFrameDecoder
import coil.disk.DiskCache
import coil.memory.MemoryCache
import org.linphone.core.*
import org.linphone.core.tools.Log
import org.linphone.mediastream.Version
class LinphoneApplication : Application(), ImageLoaderFactory {
companion object {
@SuppressLint("StaticFieldLeak")
lateinit var corePreferences: CorePreferences
@SuppressLint("StaticFieldLeak")
lateinit var coreContext: CoreContext
private fun createConfig(context: Context) {
if (::corePreferences.isInitialized) {
return
}
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 (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 config & preferences created")
}
fun ensureCoreExists(
context: Context,
pushReceived: Boolean = false,
service: CoreService? = null,
useAutoStartDescription: Boolean = false
): Boolean {
if (::coreContext.isInitialized && !coreContext.stopped) {
Log.d("[Application] Skipping Core creation (push received? $pushReceived)")
return false
}
Log.i("[Application] Core context is being created ${if (pushReceived) "from push" else ""}")
coreContext = CoreContext(context, corePreferences.config, service, useAutoStartDescription)
coreContext.start()
return true
}
fun contextExists(): Boolean {
return ::coreContext.isInitialized
}
}
override fun onCreate() {
super.onCreate()
val appName = getString(R.string.app_name)
android.util.Log.i("[$appName]", "Application is being created")
createConfig(applicationContext)
Log.i("[Application] Created")
}
override fun newImageLoader(): ImageLoader {
return ImageLoader.Builder(this)
.components {
add(VideoFrameDecoder.Factory())
add(SvgDecoder.Factory())
if (Version.sdkAboveOrEqual(Version.API28_PIE_90)) {
add(ImageDecoderDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
}
.memoryCache {
MemoryCache.Builder(this)
.maxSizePercent(0.25)
.build()
}
.diskCache {
DiskCache.Builder()
.directory(cacheDir.resolve("image_cache"))
.maxSizePercent(0.02)
.build()
}
.build()
}
}

View File

@ -0,0 +1,137 @@
/*
* 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 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.WindowInfoTracker
import androidx.window.layout.WindowLayoutInfo
import java.util.*
import kotlinx.coroutines.Dispatchers
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)
Log.i("[Generic Activity] Ensuring Core exists")
ensureCoreExists(applicationContext)
lifecycleScope.launch(Dispatchers.Main) {
WindowInfoTracker
.getOrCreate(this@GenericActivity)
.windowLayoutInfo(this@GenericActivity)
.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()
// 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,171 @@
/*
* 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 android.widget.ImageView
import androidx.activity.OnBackPressedCallback
import androidx.core.view.doOnPreDraw
import androidx.databinding.DataBindingUtil
import androidx.databinding.ViewDataBinding
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.fragment.findNavController
import com.google.android.material.transition.MaterialSharedAxis
import org.linphone.LinphoneApplication.Companion.corePreferences
import org.linphone.R
import org.linphone.activities.main.viewmodels.SharedMainViewModel
import org.linphone.core.tools.Log
abstract class GenericFragment<T : ViewDataBinding> : Fragment() {
companion object {
val emptyFragmentsIds = arrayListOf(
R.id.emptyChatFragment,
R.id.emptyContactFragment,
R.id.emptySettingsFragment,
R.id.emptyCallHistoryFragment
)
}
private var _binding: T? = null
protected val binding get() = _binding!!
protected var useMaterialSharedAxisXForwardAnimation = true
protected lateinit var sharedViewModel: SharedMainViewModel
protected fun isSharedViewModelInitialized(): Boolean {
return ::sharedViewModel.isInitialized
}
protected fun isBindingAvailable(): Boolean {
return _binding != null
}
private fun getFragmentRealClassName(): String {
return this.javaClass.name
}
private val onBackPressedCallback = object : OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
try {
val navController = findNavController()
Log.d("[Generic Fragment] ${getFragmentRealClassName()} handleOnBackPressed")
if (!navController.popBackStack()) {
Log.d("[Generic Fragment] ${getFragmentRealClassName()} couldn't pop")
if (!navController.navigateUp()) {
Log.d("[Generic Fragment] ${getFragmentRealClassName()} couldn't navigate up")
// Disable this callback & start a new back press event
isEnabled = false
goBack()
}
}
} catch (ise: IllegalStateException) {
Log.e("[Generic Fragment] ${getFragmentRealClassName()} Can't go back: $ise")
}
}
}
abstract fun getLayoutId(): Int
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
sharedViewModel = requireActivity().run {
ViewModelProvider(this)[SharedMainViewModel::class.java]
}
sharedViewModel.isSlidingPaneSlideable.observe(viewLifecycleOwner) {
Log.d("[Generic Fragment] ${getFragmentRealClassName()} shared main VM sliding pane has changed")
onBackPressedCallback.isEnabled = backPressedCallBackEnabled()
}
_binding = DataBindingUtil.inflate(inflater, getLayoutId(), container, false)
return _binding!!.root
}
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() }
}
setupBackPressCallback()
}
override fun onDestroyView() {
super.onDestroyView()
onBackPressedCallback.remove()
_binding = null
}
protected fun goBack() {
try {
requireActivity().onBackPressedDispatcher.onBackPressed()
} catch (ise: IllegalStateException) {
Log.e("[Generic Fragment] ${getFragmentRealClassName()} can't go back: $ise")
}
}
private fun setupBackPressCallback() {
Log.d("[Generic Fragment] ${getFragmentRealClassName()} setupBackPressCallback")
val backButton = binding.root.findViewById<ImageView>(R.id.back)
if (backButton != null) {
Log.d("[Generic Fragment] ${getFragmentRealClassName()} found back button")
// If popping navigation back stack entry would bring us to an "empty" fragment
// then don't do it if sliding pane layout isn't "flat"
onBackPressedCallback.isEnabled = backPressedCallBackEnabled()
backButton.setOnClickListener { goBack() }
} else {
onBackPressedCallback.isEnabled = false
}
requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, onBackPressedCallback)
}
private fun backPressedCallBackEnabled(): Boolean {
// This allow to navigate a SlidingPane child nav graph.
// This only concerns fragments for which the nav graph is inside a SlidingPane layout.
// In our case it's all graphs except the main one.
if (findNavController().graph.id == R.id.main_nav_graph_xml) return false
val isSlidingPaneFlat = sharedViewModel.isSlidingPaneSlideable.value == false
Log.d("[Generic Fragment] ${getFragmentRealClassName()} isSlidingPaneFlat ? $isSlidingPaneFlat")
val isPreviousFragmentEmpty = findNavController().previousBackStackEntry?.destination?.id in emptyFragmentsIds
Log.d("[Generic Fragment] ${getFragmentRealClassName()} isPreviousFragmentEmpty ? $isPreviousFragmentEmpty")
val popBackStack = isSlidingPaneFlat || !isPreviousFragmentEmpty
Log.d("[Generic Fragment] ${getFragmentRealClassName()} popBackStack ? $popBackStack")
return popBackStack
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,89 @@
/*
* 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.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.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.core.currentCall?.currentParams?.isVideoEnabled ?: false
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,28 @@
/*
* 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 androidx.annotation.StringRes
interface SnackBarActivity {
fun showSnackBar(@StringRes resourceId: Int)
fun showSnackBar(@StringRes resourceId: Int, action: Int, listener: () -> Unit)
fun showSnackBar(message: String)
}

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
import android.os.Bundle
import androidx.annotation.StringRes
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.lifecycle.ViewModelProvider
import com.google.android.material.snackbar.Snackbar
import org.linphone.LinphoneApplication.Companion.corePreferences
import org.linphone.R
import org.linphone.activities.GenericActivity
import org.linphone.activities.SnackBarActivity
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)
corePreferences.firstStart = false
}
override fun showSnackBar(@StringRes resourceId: Int) {
Snackbar.make(coordinator, resourceId, Snackbar.LENGTH_LONG).show()
}
override fun showSnackBar(@StringRes resourceId: Int, action: Int, listener: () -> Unit) {
Snackbar
.make(findViewById(R.id.coordinator), resourceId, Snackbar.LENGTH_LONG)
.setAction(action) {
listener()
}
.show()
}
override fun showSnackBar(message: String) {
Snackbar.make(coordinator, message, Snackbar.LENGTH_LONG).show()
}
}

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,86 @@
/*
* 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>() {
companion object {
const val READ_PHONE_STATE_PERMISSION_REQUEST_CODE = 0
}
abstract val viewModel: AbstractPhoneViewModel
@Deprecated("Deprecated in Java")
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
if (requestCode == READ_PHONE_STATE_PERMISSION_REQUEST_CODE) {
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
Log.i("[Assistant] READ_PHONE_STATE/READ_PHONE_NUMBERS permission granted")
updateFromDeviceInfo()
} else {
Log.w("[Assistant] READ_PHONE_STATE/READ_PHONE_NUMBERS permission denied")
}
}
}
protected fun checkPermissions() {
if (!resources.getBoolean(R.bool.isTablet)) {
if (!PermissionHelper.get().hasReadPhoneStateOrPhoneNumbersPermission()) {
Log.i("[Assistant] Asking for READ_PHONE_STATE/READ_PHONE_NUMBERS permission")
Compatibility.requestReadPhoneStateOrNumbersPermission(this, READ_PHONE_STATE_PERMISSION_REQUEST_CODE)
} 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,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.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 sharedAssistantViewModel: 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
sharedAssistantViewModel = requireActivity().run {
ViewModelProvider(this)[SharedAssistantViewModel::class.java]
}
viewModel = ViewModelProvider(
this,
AccountLoginViewModelFactory(sharedAssistantViewModel.getAccountCreator())
)[AccountLoginViewModel::class.java]
binding.viewModel = viewModel
if (resources.getBoolean(R.bool.isTablet)) {
viewModel.loginWithUsernamePassword.value = true
}
binding.setInfoClickListener {
showPhoneNumberInfoDialog()
}
binding.setSelectCountryClickListener {
val countryPickerFragment = CountryPickerFragment()
countryPickerFragment.listener = viewModel
countryPickerFragment.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.newAccountConfigured(true)
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)
}
}
checkPermissions()
}
}

View File

@ -0,0 +1,87 @@
/*
* 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() : DialogFragment() {
private var _binding: AssistantCountryPickerFragmentBinding? = null
private val binding get() = _binding!!
private lateinit var adapter: CountryPickerAdapter
var listener: CountryPickedListener? = null
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,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.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>() {
companion object {
const val RECORD_AUDIO_PERMISSION_REQUEST_CODE = 0
}
private lateinit var viewModel: EchoCancellerCalibrationViewModel
override fun getLayoutId(): Int = R.layout.assistant_echo_canceller_calibration_fragment
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), RECORD_AUDIO_PERMISSION_REQUEST_CODE)
} else {
viewModel.startEchoCancellerCalibration()
}
}
@Deprecated("Deprecated in Java")
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
if (requestCode == RECORD_AUDIO_PERMISSION_REQUEST_CODE) {
val granted =
grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED
if (granted) {
Log.i("[Echo Canceller Calibration] RECORD_AUDIO permission granted")
viewModel.startEchoCancellerCalibration()
} else {
Log.w("[Echo Canceller Calibration] RECORD_AUDIO permission denied")
requireActivity().finish()
}
}
}
}

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.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 sharedAssistantViewModel: 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
sharedAssistantViewModel = requireActivity().run {
ViewModelProvider(this)[SharedAssistantViewModel::class.java]
}
viewModel = ViewModelProvider(this, EmailAccountCreationViewModelFactory(sharedAssistantViewModel.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,78 @@
/*
* 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.LinphoneApplication.Companion.corePreferences
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 sharedAssistantViewModel: 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
sharedAssistantViewModel = requireActivity().run {
ViewModelProvider(this)[SharedAssistantViewModel::class.java]
}
viewModel = ViewModelProvider(this, EmailAccountValidationViewModelFactory(sharedAssistantViewModel.getAccountCreator()))[EmailAccountValidationViewModel::class.java]
binding.viewModel = viewModel
viewModel.leaveAssistantEvent.observe(
viewLifecycleOwner
) {
it.consume {
coreContext.newAccountConfigured(true)
if (!corePreferences.hideLinkPhoneNumber) {
val args = Bundle()
args.putBoolean("AllowSkip", true)
args.putString("Username", viewModel.accountCreator.username)
args.putString("Password", viewModel.accountCreator.password)
navigateToAccountLinking(args)
} else {
requireActivity().finish()
}
}
}
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.LinphoneApplication.Companion.corePreferences
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 sharedAssistantViewModel: 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
sharedAssistantViewModel = requireActivity().run {
ViewModelProvider(this)[SharedAssistantViewModel::class.java]
}
viewModel = ViewModelProvider(this, GenericLoginViewModelFactory(sharedAssistantViewModel.getAccountCreator(true)))[GenericLoginViewModel::class.java]
binding.viewModel = viewModel
viewModel.leaveAssistantEvent.observe(
viewLifecycleOwner
) {
it.consume {
val isLinphoneAccount = viewModel.domain.value.orEmpty() == corePreferences.defaultDomain
coreContext.newAccountConfigured(isLinphoneAccount)
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,41 @@
/*
* Copyright (c) 2010-2022 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.assistant.fragments
import android.os.Bundle
import android.view.View
import org.linphone.R
import org.linphone.activities.GenericFragment
import org.linphone.activities.navigateToGenericLogin
import org.linphone.databinding.AssistantGenericAccountWarningFragmentBinding
class GenericAccountWarningFragment : GenericFragment<AssistantGenericAccountWarningFragmentBinding>() {
override fun getLayoutId(): Int = R.layout.assistant_generic_account_warning_fragment
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.lifecycleOwner = viewLifecycleOwner
binding.setUnderstoodClickListener {
navigateToGenericLogin()
}
}
}

View File

@ -0,0 +1,86 @@
/*
* 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 sharedAssistantViewModel: 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
sharedAssistantViewModel = requireActivity().run {
ViewModelProvider(this)[SharedAssistantViewModel::class.java]
}
viewModel = ViewModelProvider(
this,
PhoneAccountCreationViewModelFactory(sharedAssistantViewModel.getAccountCreator())
)[PhoneAccountCreationViewModel::class.java]
binding.viewModel = viewModel
binding.setInfoClickListener {
showPhoneNumberInfoDialog()
}
binding.setSelectCountryClickListener {
val countryPickerFragment = CountryPickerFragment()
countryPickerFragment.listener = viewModel
countryPickerFragment.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)
}
}
checkPermissions()
}
}

View File

@ -0,0 +1,109 @@
/*
* 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.SnackBarActivity
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 sharedAssistantViewModel: 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
sharedAssistantViewModel = requireActivity().run {
ViewModelProvider(this)[SharedAssistantViewModel::class.java]
}
val accountCreator = sharedAssistantViewModel.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 {
val countryPickerFragment = CountryPickerFragment()
countryPickerFragment.listener = viewModel
countryPickerFragment.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 (coreContext.core.isEchoCancellerCalibrationRequired) {
navigateToEchoCancellerCalibration()
} else {
requireActivity().finish()
}
}
}
viewModel.onErrorEvent.observe(
viewLifecycleOwner
) {
it.consume { message ->
(requireActivity() as SnackBarActivity).showSnackBar(message)
}
}
checkPermissions()
}
}

View File

@ -0,0 +1,116 @@
/*
* 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 androidx.navigation.fragment.findNavController
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.R
import org.linphone.activities.GenericFragment
import org.linphone.activities.SnackBarActivity
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.core.tools.Log
import org.linphone.databinding.AssistantPhoneAccountValidationFragmentBinding
class PhoneAccountValidationFragment : GenericFragment<AssistantPhoneAccountValidationFragmentBinding>() {
private lateinit var sharedAssistantViewModel: 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
sharedAssistantViewModel = requireActivity().run {
ViewModelProvider(this)[SharedAssistantViewModel::class.java]
}
viewModel = ViewModelProvider(this, PhoneAccountValidationViewModelFactory(sharedAssistantViewModel.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.newAccountConfigured(true)
if (coreContext.core.isEchoCancellerCalibrationRequired) {
navigateToEchoCancellerCalibration()
} else {
requireActivity().finish()
}
}
viewModel.isLinking.value == true -> {
if (findNavController().graph.id == R.id.settings_nav_graph_xml) {
val args = Bundle()
args.putString(
"Identity",
"sip:${viewModel.accountCreator.username}@${viewModel.accountCreator.domain}"
)
navigateToAccountSettings(args)
} else {
requireActivity().finish()
}
}
}
}
}
viewModel.onErrorEvent.observe(
viewLifecycleOwner
) {
it.consume { message ->
(requireActivity() as SnackBarActivity).showSnackBar(message)
}
}
// This won't work starting Android 10 as clipboard access is denied unless app has focus,
// which won't be the case when the SMS arrives unless it is added into clipboard from a notification
val clipboard = requireContext().getSystemService(CLIPBOARD_SERVICE) as ClipboardManager
clipboard.addPrimaryClipChangedListener {
val data = clipboard.primaryClip
if (data != null && data.itemCount > 0) {
val clip = data.getItemAt(0).text.toString()
if (clip.length == 4) {
Log.i("[Assistant] [Phone Account Validation] Found 4 digits as primary clip in clipboard, using it and clear it")
viewModel.code.value = clip
clipboard.clearPrimaryClip()
}
}
}
}
}

View File

@ -0,0 +1,109 @@
/*
* 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>() {
companion object {
const val CAMERA_PERMISSION_REQUEST_CODE = 0
}
private lateinit var sharedAssistantViewModel: 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
sharedAssistantViewModel = requireActivity().run {
ViewModelProvider(this)[SharedAssistantViewModel::class.java]
}
viewModel = ViewModelProvider(this)[QrCodeViewModel::class.java]
binding.viewModel = viewModel
viewModel.qrCodeFoundEvent.observe(
viewLifecycleOwner
) {
it.consume { url ->
sharedAssistantViewModel.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), CAMERA_PERMISSION_REQUEST_CODE)
}
}
override fun onResume() {
super.onResume()
coreContext.core.nativePreviewWindowId = binding.qrCodeCaptureTexture
coreContext.core.isQrcodeVideoPreviewEnabled = true
coreContext.core.isVideoPreviewEnabled = true
}
override fun onPause() {
coreContext.core.nativePreviewWindowId = null
coreContext.core.isQrcodeVideoPreviewEnabled = false
coreContext.core.isVideoPreviewEnabled = false
super.onPause()
}
@Deprecated("Deprecated in Java")
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
if (requestCode == CAMERA_PERMISSION_REQUEST_CODE) {
val granted =
grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED
if (granted) {
Log.i("[QR Code] CAMERA permission granted")
coreContext.core.reloadVideoDevices()
viewModel.setBackCamera()
} else {
Log.w("[QR Code] CAMERA permission denied")
findNavController().navigateUp()
}
}
}
}

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.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 sharedAssistantViewModel: 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
sharedAssistantViewModel = 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 = sharedAssistantViewModel.remoteProvisioningUrl.value ?: coreContext.core.provisioningUri
}
override fun onDestroy() {
super.onDestroy()
if (::sharedAssistantViewModel.isInitialized) {
sharedAssistantViewModel.remoteProvisioningUrl.value = null
}
}
}

View File

@ -0,0 +1,37 @@
/*
* 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 org.linphone.R
import org.linphone.activities.GenericFragment
import org.linphone.databinding.AssistantTopBarFragmentBinding
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)
binding.lifecycleOwner = viewLifecycleOwner
useMaterialSharedAxisXForwardAnimation = false
}
}

View File

@ -0,0 +1,134 @@
/*
* 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.navigateToRemoteProvisioning
import org.linphone.core.tools.Log
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 {
navigateToGenericLoginWarning()
}
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))
)
try {
startActivity(browserIntent)
} catch (e: Exception) {
Log.e("[Welcome] Can't start activity: $e")
}
}
}
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))
)
try {
startActivity(browserIntent)
} catch (e: Exception) {
Log.e("[Welcome] Can't start activity: $e")
}
}
}
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,87 @@
/*
* 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?) {
val internationalPrefix = "+${dialPlan?.countryCallingCode}"
if (dialPlan != null) {
Log.i("[Assistant] Found prefix from dial plan: ${dialPlan.countryCallingCode}")
prefix.value = internationalPrefix
}
if (number != null) {
Log.i("[Assistant] Found phone number: $number")
phoneNumber.value = if (number.startsWith(internationalPrefix)) {
number.substring(internationalPrefix.length)
} else {
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,244 @@
/*
* 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
import org.linphone.utils.PhoneNumberUtils
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 displayName = MutableLiveData<String>()
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() {
@Deprecated("Deprecated in Java")
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() {
accountCreator.displayName = displayName.value
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
if (proxyConfig.dialPrefix.isNullOrEmpty()) {
val dialPlan = PhoneNumberUtils.getDialPlanForCurrentCountry(coreContext.context)
if (dialPlan != null) {
Log.i("[Assistant] [Account Login] Found dial plan country ${dialPlan.country} with international prefix ${dialPlan.countryCallingCode}")
proxyConfig.edit()
proxyConfig.dialPrefix = dialPlan.countryCallingCode
proxyConfig.done()
} else {
Log.w("[Assistant] [Account Login] Failed to find dial plan")
}
}
Log.i("[Assistant] [Account Login] Proxy config created")
return true
}
}

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.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")
}
EcCalibratorStatus.InProgress -> {
Log.i("[Echo Canceller Calibration] In progress")
}
}
echoCalibrationTerminated.value = Event(true)
}
}

View File

@ -0,0 +1,173 @@
/*
* 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 displayName = 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
accountCreator.displayName = displayName.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,126 @@
/*
* 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.LinphoneApplication
import org.linphone.core.AccountCreator
import org.linphone.core.AccountCreatorListenerStub
import org.linphone.core.ProxyConfig
import org.linphone.core.tools.Log
import org.linphone.utils.Event
import org.linphone.utils.PhoneNumberUtils
class EmailAccountValidationViewModelFactory(private val accountCreator: AccountCreator) :
ViewModelProvider.NewInstanceFactory() {
@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
if (proxyConfig.dialPrefix.isNullOrEmpty()) {
val dialPlan = PhoneNumberUtils.getDialPlanForCurrentCountry(LinphoneApplication.coreContext.context)
if (dialPlan != null) {
Log.i("[Assistant] [Account Validation] Found dial plan country ${dialPlan.country} with international prefix ${dialPlan.countryCallingCode}")
proxyConfig.edit()
proxyConfig.dialPrefix = dialPlan.countryCallingCode
proxyConfig.done()
} else {
Log.w("[Assistant] [Account Validation] Failed to find dial plan")
}
}
Log.i("[Assistant] [Account Validation] Proxy config created")
return true
}
}

View File

@ -0,0 +1,149 @@
/*
* 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.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() {
@Deprecated("Deprecated in Java")
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")
waitForServerAnswer.value = false
return
}
Log.i("[Assistant] [Generic Login] Proxy config created")
}
private fun isLoginButtonEnabled(): Boolean {
return username.value.orEmpty().isNotEmpty() && domain.value.orEmpty().isNotEmpty() && password.value.orEmpty().isNotEmpty()
}
}

View File

@ -0,0 +1,173 @@
/*
* 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 displayName = 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.displayName = displayName.value
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,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.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)
}
else -> {}
}
}
}
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

@ -0,0 +1,37 @@
/*
* 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.corePreferences
class WelcomeViewModel : ViewModel() {
val showCreateAccount: Boolean = corePreferences.showCreateAccount
val showLinphoneLogin: Boolean = corePreferences.showLinphoneLogin
val showGenericLogin: Boolean = corePreferences.showGenericLogin
val showRemoteProvisioning: Boolean = corePreferences.showRemoteProvisioning
val termsAndPrivacyAccepted = MutableLiveData<Boolean>()
init {
termsAndPrivacyAccepted.value = corePreferences.readAndAgreeTermsAndPrivacy
}
}

View File

@ -0,0 +1,212 @@
/*
* 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.ChatRoomListenerStub
import org.linphone.core.EventLog
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()
}
}
}
private val listener = object : ChatRoomListenerStub() {
override fun onChatMessagesReceived(chatRoom: ChatRoom, eventLogs: Array<out EventLog>) {
chatRoom.markAsRead()
}
}
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
}
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.disableAdvancedContextMenuOptions()
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
coreContext.notificationsManager.changeDismissNotificationUponReadForChatRoom(viewModel.chatRoom, false)
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()
viewModel.chatRoom.addListener(listener)
// Workaround for the removed notification when a chat room is marked as read
coreContext.notificationsManager.changeDismissNotificationUponReadForChatRoom(viewModel.chatRoom, true)
viewModel.chatRoom.markAsRead()
val peerAddress = viewModel.chatRoom.peerAddress.asStringUriOnly()
coreContext.notificationsManager.currentlyDisplayedChatRoomAddress = peerAddress
coreContext.notificationsManager.resetChatNotificationCounterForSipUri(peerAddress)
lifecycleScope.launch {
// Without the delay the scroll to bottom doesn't happen...
delay(100)
scrollToBottom()
}
}
override fun onPause() {
viewModel.chatRoom.removeListener(listener)
coreContext.notificationsManager.currentlyDisplayedChatRoomAddress = null
coreContext.notificationsManager.changeDismissNotificationUponReadForChatRoom(viewModel.chatRoom, false)
super.onPause()
}
private fun scrollToBottom() {
if (adapter.itemCount > 0) {
binding.chatMessagesList.scrollToPosition(adapter.itemCount - 1)
}
}
}

View File

@ -0,0 +1,542 @@
/*
* 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.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 androidx.annotation.StringRes
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.doOnAttach
import androidx.databinding.DataBindingUtil
import androidx.fragment.app.FragmentContainerView
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavController
import androidx.navigation.NavDestination
import androidx.navigation.findNavController
import androidx.window.layout.FoldingFeature
import coil.imageLoader
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.*
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.*
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) {
ShortcutsHelper.createShortcutsToContacts(this@MainActivity)
} else if (corePreferences.chatRoomShortcuts) {
ShortcutsHelper.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 !")
applicationContext.imageLoader.memoryCache?.clear()
}
}
override fun onLayoutChanges(foldingFeature: FoldingFeature?) {
sharedViewModel.layoutChangedEvent.value = Event(true)
}
private var shouldTabsBeVisibleDependingOnDestination = true
private var shouldTabsBeVisibleDueToOrientationAndKeyboard = true
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Must be done before the setContentView
installSplashScreen()
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) {
startActivity(Intent(this, AssistantActivity::class.java))
}
}
tabsFragment = findViewById(R.id.tabs_fragment)
statusFragment = findViewById(R.id.status_fragment)
binding.root.doOnAttach {
Log.i("[Main Activity] Report UI has been fully drawn (TTFD)")
try {
reportFullyDrawn()
} catch (se: SecurityException) {
Log.e("[Main Activity] Security exception when doing reportFullyDrawn(): $se")
}
}
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
if (intent != null) {
Log.d("[Main Activity] Found new intent")
handleIntentParams(intent)
}
}
override fun onResume() {
super.onResume()
coreContext.contactsManager.addListener(listener)
}
override fun onPause() {
coreContext.contactsManager.removeListener(listener)
super.onPause()
}
override fun showSnackBar(@StringRes resourceId: Int) {
Snackbar.make(findViewById(R.id.coordinator), resourceId, Snackbar.LENGTH_LONG).show()
}
override fun showSnackBar(@StringRes resourceId: Int, action: Int, listener: () -> Unit) {
Snackbar
.make(findViewById(R.id.coordinator), resourceId, Snackbar.LENGTH_LONG)
.setAction(action) {
Log.i("[Snack Bar] Action listener triggered")
listener()
}
.show()
}
override fun showSnackBar(message: String) {
Snackbar.make(findViewById(R.id.coordinator), message, Snackbar.LENGTH_LONG).show()
}
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"}")
shouldTabsBeVisibleDueToOrientationAndKeyboard = !portraitOrientation || !keyboardVisible
updateTabsFragmentVisibility()
}
initOverlay()
if (intent != null) {
Log.d("[Main Activity] Found post create intent")
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?
) {
hideKeyboard()
if (statusFragment.visibility == View.GONE) {
statusFragment.visibility = View.VISIBLE
}
shouldTabsBeVisibleDependingOnDestination = when (destination.id) {
R.id.masterCallLogsFragment, R.id.masterContactsFragment, R.id.dialerFragment, R.id.masterChatRoomsFragment ->
true
else -> false
}
updateTabsFragmentVisibility()
}
fun hideKeyboard() {
currentFocus?.hideKeyboard()
}
fun hideStatusFragment(hide: Boolean) {
statusFragment.visibility = if (hide) View.GONE else View.VISIBLE
}
private fun updateTabsFragmentVisibility() {
tabsFragment.visibility = if (shouldTabsBeVisibleDependingOnDestination && shouldTabsBeVisibleDueToOrientationAndKeyboard) View.VISIBLE else View.GONE
}
private fun handleIntentParams(intent: Intent) {
Log.i("[Main Activity] Handling intent with action [${intent.action}], type [${intent.type}] and data [${intent.data}]")
when (intent.action) {
Intent.ACTION_MAIN -> handleMainIntent(intent)
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 (uri != null) {
if (
intent.type == AppUtils.getString(R.string.linphone_address_mime_type) &&
PermissionHelper.get().hasReadContactsPermission()
) {
val contactId =
coreContext.contactsManager.getAndroidContactIdFromUri(uri)
if (contactId != null) {
Log.i("[Main Activity] Found contact URI parameter in intent: $uri")
navigateToContact(contactId)
}
} else {
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 -> handleMainIntent(intent)
}
// Prevent this intent to be processed again
intent.action = null
intent.data = null
intent.extras?.clear()
}
private fun handleMainIntent(intent: Intent) {
when {
intent.hasExtra("ContactId") -> {
val id = intent.getStringExtra("ContactId")
Log.i("[Main Activity] Found contact ID in extras: $id")
navigateToContact(id)
}
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]")
navigateToChatRoom(localAddress, peerAddress)
} else {
Log.i("[Main Activity] Found chat intent extra, go to chat rooms list")
navigateToChatRooms()
}
}
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)
}
else -> {
val core = coreContext.core
val call = core.currentCall ?: core.calls.firstOrNull()
if (call != null) {
Log.i("[Main Activity] Launcher clicked while there is at least one active call, go to CallActivity")
val callIntent = Intent(this, org.linphone.activities.voip.CallActivity::class.java)
callIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
startActivity(callIntent)
}
}
}
}
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, LinphoneUtils.applyInternationalPrefix())
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 localAddress =
coreContext.core.defaultAccount?.params?.identityAddress?.asStringUriOnly()
val peerAddress = coreContext.core.interpretUrl(addressToIM, LinphoneUtils.applyInternationalPrefix())?.asStringUriOnly()
Log.i("[Main Activity] Navigating to chat room with local [$localAddress] and peer [$peerAddress] addresses")
navigateToChatRoom(localAddress, peerAddress)
} else {
val shortcutId = intent.getStringExtra("android.intent.extra.shortcut.ID") // Intent.EXTRA_SHORTCUT_ID
if (shortcutId != null) {
Log.i("[Main Activity] Found shortcut ID: $shortcutId")
handleLocusOrShortcut(shortcutId)
} else {
Log.i("[Main Activity] Going into chat rooms list")
navigateToChatRooms()
}
}
}
private fun handleLocusOrShortcut(id: String) {
val split = id.split("~")
if (split.size == 2) {
val localAddress = split[0]
val peerAddress = split[1]
Log.i("[Main Activity] Navigating to chat room with local [$localAddress] and peer [$peerAddress] addresses, computed from shortcut/locus id")
navigateToChatRoom(localAddress, peerAddress)
} else {
Log.e("[Main Activity] Failed to parse shortcut/locus id: $id, going to chat rooms list")
navigateToChatRooms()
}
}
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,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.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.core.tools.Log
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.setPrivacyPolicyClickListener {
val browserIntent = Intent(
Intent.ACTION_VIEW,
Uri.parse(getString(R.string.about_privacy_policy_link))
)
try {
startActivity(browserIntent)
} catch (se: SecurityException) {
Log.e("[About] Failed to start browser intent, $se")
}
}
binding.setLicenseClickListener {
val browserIntent = Intent(
Intent.ACTION_VIEW,
Uri.parse(getString(R.string.about_license_link))
)
try {
startActivity(browserIntent)
} catch (se: SecurityException) {
Log.e("[About] Failed to start browser intent, $se")
}
}
binding.setWeblateClickListener {
val browserIntent = Intent(
Intent.ACTION_VIEW,
Uri.parse(getString(R.string.about_weblate_link))
)
try {
startActivity(browserIntent)
} catch (se: SecurityException) {
Log.e("[About] Failed to start browser intent, $se")
}
}
}
}

View File

@ -0,0 +1,29 @@
/*
* 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 androidx.lifecycle.ViewModel
import org.linphone.LinphoneApplication.Companion.coreContext
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

@ -0,0 +1,94 @@
/*
* 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
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
internal abstract class ChatScrollListener(private val mLayoutManager: LinearLayoutManager) :
RecyclerView.OnScrollListener() {
// 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
private var userHasScrolledUp: Boolean = false
// This happens many times a second during a scroll, so be wary of the code you place here.
// We are given a few useful parameters to help us work out if we need to load some more data,
// but first we check if we are waiting for the previous load to finish.
override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) {
val totalItemCount = mLayoutManager.itemCount
val firstVisibleItemPosition: Int = mLayoutManager.findFirstVisibleItemPosition()
val lastVisibleItemPosition: Int = mLayoutManager.findLastVisibleItemPosition()
// 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
if (totalItemCount < previousTotalItemCount) {
previousTotalItemCount = totalItemCount
if (totalItemCount == 0) {
loading = true
}
}
// If its still loading, we check to see if the data set count has
// changed, if so we conclude it has finished loading and update the current page
// number and total item count.
if (loading && totalItemCount > previousTotalItemCount) {
loading = false
previousTotalItemCount = totalItemCount
}
userHasScrolledUp = lastVisibleItemPosition != totalItemCount - 1
if (userHasScrolledUp) {
onScrolledUp()
} else {
onScrolledToEnd()
}
// If it isnt currently loading, we check to see if we have breached
// 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 &&
firstVisibleItemPosition < mVisibleThreshold &&
firstVisibleItemPosition >= 0 &&
lastVisibleItemPosition < totalItemCount - mVisibleThreshold
) {
onLoadMore(totalItemCount)
loading = true
}
}
// Defines the process for actually loading more data based on page
protected abstract fun onLoadMore(totalItemsCount: Int)
// Called when user has started to scroll up, opposed to onScrolledToEnd()
protected abstract fun onScrolledUp()
// 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

@ -0,0 +1,32 @@
/*
* 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
import org.linphone.core.Address
import org.linphone.core.ChatRoomSecurityLevel
data class GroupChatRoomMember(
val address: Address,
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,519 @@
/*
* 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.*
import org.linphone.core.tools.Log
import org.linphone.databinding.ChatEventListCellBinding
import org.linphone.databinding.ChatMessageListCellBinding
import org.linphone.databinding.ChatMessageLongPressMenuBindingImpl
import org.linphone.databinding.ChatUnreadMessagesListHeaderBinding
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 urlClickEvent: MutableLiveData<Event<String>> by lazy {
MutableLiveData<Event<String>>()
}
val sipUriClickedEvent: MutableLiveData<Event<String>> by lazy {
MutableLiveData<Event<String>>()
}
val callConferenceEvent: MutableLiveData<Event<Pair<String, String?>>> by lazy {
MutableLiveData<Event<Pair<String, String?>>>()
}
val scrollToChatMessageEvent: MutableLiveData<Event<ChatMessage>> by lazy {
MutableLiveData<Event<ChatMessage>>()
}
val errorEvent: MutableLiveData<Event<Int>> by lazy {
MutableLiveData<Event<Int>>()
}
private val contentClickedListener = object : OnContentClickedListener {
override fun onContentClicked(content: Content) {
openContentEvent.value = Event(content)
}
override fun onWebUrlClicked(url: String) {
if (popup?.isShowing == true) {
Log.w("[Chat Message Data] Long press that displayed context menu detected, aborting click on URL [$url]")
return
}
urlClickEvent.value = Event(url)
}
override fun onSipAddressClicked(sipUri: String) {
if (popup?.isShowing == true) {
Log.w("[Chat Message Data] Long press that displayed context menu detected, aborting click on SIP URI [$sipUri]")
return
}
sipUriClickedEvent.value = Event(sipUri)
}
override fun onCallConference(address: String, subject: String?) {
callConferenceEvent.value = Event(Pair(address, subject))
}
override fun onError(messageId: Int) {
errorEvent.value = Event(messageId)
}
}
private var advancedContextMenuOptionsDisabled: Boolean = false
private var popup: PopupWindow? = null
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 disableAdvancedContextMenuOptions() {
advancedContextMenuOptionsDisabled = 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 = bindingAdapterPosition
}
setClickListener {
if (selectionViewModel.isEditionEnabled.value == true) {
selectionViewModel.onToggleSelect(bindingAdapterPosition)
}
}
setReplyClickListener {
val reply = chatMessageViewModel.replyData.value?.chatMessage
if (reply != null) {
scrollToChatMessageEvent.value = Event(reply)
}
}
// Grouping
var hasPrevious = false
var hasNext = false
if (bindingAdapterPosition > 0) {
val previousItem = getItem(bindingAdapterPosition - 1)
if (previousItem.eventLog.type == EventLog.Type.ConferenceChatMessage) {
val previousMessage = previousItem.eventLog.chatMessage
if (previousMessage != null && previousMessage.fromAddress.weakEqual(chatMessage.fromAddress)) {
if (chatMessage.time - previousMessage.time < MAX_TIME_TO_GROUP_MESSAGES) {
hasPrevious = true
}
}
}
}
if (bindingAdapterPosition >= 0 && bindingAdapterPosition < itemCount - 1) {
val nextItem = getItem(bindingAdapterPosition + 1)
if (nextItem.eventLog.type == EventLog.Type.ConferenceChatMessage) {
val nextMessage = nextItem.eventLog.chatMessage
if (nextMessage != null && nextMessage.fromAddress.weakEqual(chatMessage.fromAddress)) {
if (nextMessage.time - chatMessage.time < MAX_TIME_TO_GROUP_MESSAGES) {
hasNext = true
}
}
}
}
chatMessageViewModel.updateBubbleBackground(hasPrevious, hasNext)
executePendingBindings()
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())) {
// 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 ||
advancedContextMenuOptionsDisabled
) {
popupView.addToContactsHidden = true
totalSize -= itemSize
}
if (chatMessage.chatRoom.isReadOnly) {
popupView.replyHidden = true
totalSize -= itemSize
}
if (advancedContextMenuOptionsDisabled) {
popupView.forwardHidden = 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
)
popup = popupWindow
// 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 = bindingAdapterPosition
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 = bindingAdapterPosition
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 = bindingAdapterPosition
}
binding.setClickListener {
if (selectionViewModel.isEditionEnabled.value == true) {
selectionViewModel.onToggleSelect(bindingAdapterPosition)
}
}
executePendingBindings()
}
}
}
}
private class ChatMessageDiffCallback : DiffUtil.ItemCallback<EventLogData>() {
override fun areItemsTheSame(
oldItem: EventLogData,
newItem: EventLogData
): Boolean {
return if (oldItem.type == EventLog.Type.ConferenceChatMessage &&
newItem.type == EventLog.Type.ConferenceChatMessage
) {
val oldData = (oldItem.data as ChatMessageData)
val newData = (newItem.data as ChatMessageData)
oldData.time.value == newData.time.value &&
oldData.isOutgoing == newData.isOutgoing
} else oldItem.notifyId == newItem.notifyId
}
override fun areContentsTheSame(
oldItem: EventLogData,
newItem: EventLogData
): Boolean {
return if (oldItem.type == EventLog.Type.ConferenceChatMessage &&
newItem.type == EventLog.Type.ConferenceChatMessage
) {
val oldData = (oldItem.data as ChatMessageData)
val newData = (newItem.data as ChatMessageData)
val previous = oldData.hasPreviousMessage == newData.hasPreviousMessage
val next = oldData.hasNextMessage == newData.hasNextMessage
val isDisplayed = newData.isDisplayed.value == true
isDisplayed && previous && next
} else {
oldItem.type != EventLog.Type.ConferenceChatMessage &&
newItem.type != EventLog.Type.ConferenceChatMessage
}
}
}

View File

@ -0,0 +1,120 @@
/*
* 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.data.ChatRoomData
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<ChatRoom, 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
notifyItemRangeChanged(0, itemCount)
}
inner class ViewHolder(
private val binding: ChatRoomListCellBinding
) : RecyclerView.ViewHolder(binding.root) {
fun bind(chatRoom: ChatRoom) {
with(binding) {
data = ChatRoomData(chatRoom)
lifecycleOwner = viewLifecycleOwner
// This is for item selection through ListTopBarFragment
selectionListViewModel = selectionViewModel
selectionViewModel.isEditionEnabled.observe(
viewLifecycleOwner
) {
position = bindingAdapterPosition
}
forwardPending = isForwardPending
setClickListener {
if (selectionViewModel.isEditionEnabled.value == true) {
selectionViewModel.onToggleSelect(bindingAdapterPosition)
} else {
selectedChatRoomEvent.value = Event(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<ChatRoom>() {
override fun areItemsTheSame(
oldItem: ChatRoom,
newItem: ChatRoom
): Boolean {
return oldItem == newItem
}
override fun areContentsTheSame(
oldItem: ChatRoom,
newItem: ChatRoom
): Boolean {
return false // To force redraw
}
}

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
notifyItemRangeChanged(0, itemCount)
}
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,124 @@
/*
* 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
}
else -> {}
}
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,37 @@
/*
* 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.utils.FileUtils
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)
fun delete() {
deleteCallback(this)
}
}

View File

@ -0,0 +1,491 @@
/*
* 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.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.io.BufferedReader
import java.io.FileReader
import java.lang.StringBuilder
import java.text.SimpleDateFormat
import java.util.*
import java.util.concurrent.TimeUnit
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.TimestampUtils
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 isPdf = MutableLiveData<Boolean>()
val isGenericFile = MutableLiveData<Boolean>()
val isVoiceRecording = MutableLiveData<Boolean>()
val isConferenceSchedule = MutableLiveData<Boolean>()
val isConferenceUpdated = MutableLiveData<Boolean>()
val isConferenceCancelled = MutableLiveData<Boolean>()
val fileName = MutableLiveData<String>()
val filePath = MutableLiveData<String>()
val downloadable = MutableLiveData<Boolean>()
val fileTransferProgress = MutableLiveData<Boolean>()
val fileTransferProgressInt = MutableLiveData<Int>()
val downloadLabel = MutableLiveData<Spannable>()
val voiceRecordDuration = MutableLiveData<Int>()
val formattedDuration = MutableLiveData<String>()
val voiceRecordPlayingPosition = MutableLiveData<Int>()
val isVoiceRecordPlaying = MutableLiveData<Boolean>()
val conferenceSubject = MutableLiveData<String>()
val conferenceDescription = MutableLiveData<String>()
val conferenceParticipantCount = MutableLiveData<String>()
val conferenceDate = MutableLiveData<String>()
val conferenceTime = MutableLiveData<String>()
val conferenceDuration = MutableLiveData<String>()
var conferenceAddress = MutableLiveData<String>()
val showDuration = MutableLiveData<Boolean>()
val isAlone: Boolean
get() {
var count = 0
for (content in chatMessage.contents) {
if (content.isFileTransfer || content.isFile) {
count += 1
}
}
return count == 1
}
private var isFileEncrypted: Boolean = false
private var voiceRecordAudioFocusRequest: AudioFocusRequestCompat? = null
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) {
if (fileTransferProgress.value == false) {
fileTransferProgress.value = true
}
val percent = ((offset * 100.0) / total).toInt() // Conversion from int to double and back to int is required
Log.d("[Content] Transfer progress is: $offset / $total -> $percent%")
fileTransferProgressInt.value = percent
}
}
override fun onMsgStateChanged(message: ChatMessage, state: ChatMessage.State) {
if (state == ChatMessage.State.FileTransferDone || state == ChatMessage.State.FileTransferError) {
fileTransferProgress.value = false
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
fileTransferProgress.value = false
fileTransferProgressInt.value = 0
updateContent()
chatMessage.addListener(chatMessageListener)
}
fun destroy() {
scope.cancel()
deletePlainFilePath()
chatMessage.removeListener(chatMessageListener)
if (this::voiceRecordingPlayer.isInitialized) {
Log.i("[Voice Recording] Destroying voice record")
stopVoiceRecording()
voiceRecordingPlayer.removeListener(playerListener)
}
}
fun download() {
if (chatMessage.isFileTransferInProgress) {
Log.w("[Content] Another FileTransfer content for this message is currently being downloaded, can't start another one for now")
listener?.onError(R.string.chat_message_download_already_in_progress)
return
}
val content = getContent()
val filePath = content.filePath
if (content.isFileTransfer) {
if (filePath == null || filePath.isEmpty()) {
val contentName = content.name
if (contentName != null) {
val file = FileUtils.getFileStoragePath(contentName)
content.filePath = file.path
Log.i("[Content] Started downloading $contentName into ${content.filePath}")
} else {
Log.e("[Content] Content name is null, can't download it!")
return
}
} else {
Log.w("[Content] File path already set [$filePath] using it (auto download that failed probably)")
}
if (!chatMessage.downloadContent(content)) {
Log.e("[Content] Failed to start content download!")
}
} else {
Log.e("[Content] Content is not a FileTransfer, can't download it!")
}
}
fun openFile() {
listener?.onContentClicked(getContent())
}
private fun deletePlainFilePath() {
val path = filePath.value.orEmpty()
if (path.isNotEmpty() && isFileEncrypted) {
Log.i("[Content] Deleting file used for preview: $path")
FileUtils.deleteFile(path)
filePath.value = ""
}
}
private fun updateContent() {
Log.i("[Content] Updating content")
deletePlainFilePath()
val content = getContent()
isFileEncrypted = content.isFileEncrypted
Log.i("[Content] Is ${if (content.isFile) "file" else "file transfer"} content encrypted ? $isFileEncrypted")
filePath.value = ""
fileName.value = if (content.name.isNullOrEmpty() && !content.filePath.isNullOrEmpty()) {
FileUtils.getNameFromFilePath(content.filePath!!)
} else {
content.name
}
// Display download size and underline text
val fileSize = AppUtils.bytesToDisplayableSize(content.fileSize.toLong())
val spannable = SpannableString("${AppUtils.getString(R.string.chat_message_download_file)} ($fileSize)")
spannable.setSpan(UnderlineSpan(), 0, spannable.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
downloadLabel.value = spannable
isImage.value = false
isVideo.value = false
isAudio.value = false
isPdf.value = false
isVoiceRecording.value = false
isConferenceSchedule.value = false
isConferenceUpdated.value = false
isConferenceCancelled.value = false
if (content.isFile || (content.isFileTransfer && chatMessage.isOutgoing)) {
val path = if (isFileEncrypted) {
Log.i("[Content] Content is encrypted, requesting plain file path")
content.exportPlainFile()
} else {
content.filePath ?: ""
}
downloadable.value = content.filePath.orEmpty().isEmpty()
val isVoiceRecord = content.isVoiceRecording
isVoiceRecording.value = isVoiceRecord
val isConferenceIcs = content.isIcalendar
isConferenceSchedule.value = isConferenceIcs
if (path.isNotEmpty()) {
Log.i("[Content] Found displayable content: $path")
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)
if (isVoiceRecord) {
val duration = content.fileDuration // duration is in ms
voiceRecordDuration.value = duration
formattedDuration.value = SimpleDateFormat("mm:ss", Locale.getDefault()).format(duration)
Log.i("[Content] Voice recording duration is ${voiceRecordDuration.value} ($duration)")
} else if (isConferenceIcs) {
parseConferenceInvite(content)
}
} else if (isConferenceIcs) {
Log.i("[Content] Found content with icalendar file")
parseConferenceInvite(content)
} else {
Log.w("[Content] Found ${if (content.isFile) "file" else "file transfer"} content with empty path...")
isImage.value = false
isVideo.value = false
isAudio.value = false
isPdf.value = false
isVoiceRecording.value = false
isConferenceSchedule.value = false
}
} else if (content.isFileTransfer) {
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
isConferenceSchedule.value = false
} else if (content.isIcalendar) {
Log.i("[Content] Found content with icalendar body")
isConferenceSchedule.value = true
parseConferenceInvite(content)
} else {
Log.w("[Content] Found content that's neither a file or a file transfer")
}
isGenericFile.value = !isPdf.value!! && !isAudio.value!! && !isVideo.value!! && !isImage.value!! && !isVoiceRecording.value!! && !isConferenceSchedule.value!!
}
private fun parseConferenceInvite(content: Content) {
val conferenceInfo = Factory.instance().createConferenceInfoFromIcalendarContent(content)
val conferenceUri = conferenceInfo?.uri?.asStringUriOnly()
if (conferenceInfo != null && conferenceUri != null) {
conferenceAddress.value = conferenceUri!!
Log.i("[Content] Created conference info from ICS with address ${conferenceAddress.value}")
conferenceSubject.value = conferenceInfo.subject
conferenceDescription.value = conferenceInfo.description
val state = conferenceInfo.state
isConferenceUpdated.value = state == ConferenceInfo.State.Updated
isConferenceCancelled.value = state == ConferenceInfo.State.Cancelled
conferenceDate.value = TimestampUtils.dateToString(conferenceInfo.dateTime)
conferenceTime.value = TimestampUtils.timeToString(conferenceInfo.dateTime)
val minutes = conferenceInfo.duration
val hours = TimeUnit.MINUTES.toHours(minutes.toLong())
val remainMinutes = minutes - TimeUnit.HOURS.toMinutes(hours).toInt()
conferenceDuration.value = TimestampUtils.durationToString(hours.toInt(), remainMinutes)
showDuration.value = minutes > 0
// Check if organizer is part of participants list
var participantsCount = conferenceInfo.participants.size
val organizer = conferenceInfo.organizer
var organizerFound = false
if (organizer != null) {
for (participant in conferenceInfo.participants) {
if (participant.weakEqual(organizer)) {
organizerFound = true
break
}
}
}
if (!organizerFound) participantsCount += 1 // +1 for organizer
conferenceParticipantCount.value = String.format(AppUtils.getString(R.string.conference_invite_participants_count), participantsCount)
} else if (conferenceInfo == null) {
if (content.filePath != null) {
try {
val br = BufferedReader(FileReader(content.filePath))
var line: String?
val textBuilder = StringBuilder()
while (br.readLine().also { line = it } != null) {
textBuilder.append(line)
textBuilder.append('\n')
}
br.close()
Log.e("[Content] Failed to create conference info from ICS file [${content.filePath}]: $textBuilder")
} catch (e: Exception) {
Log.e("[Content] Failed to read content of ICS file [${content.filePath}]: $e")
}
} else {
Log.e("[Content] Failed to create conference info from ICS: ${content.utf8Text}")
}
} else if (conferenceInfo.uri == null) {
Log.e("[Content] Failed to find the conference URI in conference info [$conferenceInfo]")
}
}
fun callConferenceAddress() {
val address = conferenceAddress.value
if (address == null) {
Log.e("[Content] Can't call null conference address!")
return
}
listener?.onCallConference(address, conferenceSubject.value)
}
/** 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 {
withContext(Dispatchers.Main) {
voiceRecordPlayingPosition.value = 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")
// In case no headphones/headset is connected, use speaker sound card to play recordings, otherwise use earpiece
// If none are available, default one will be used
var headphonesCard: String? = null
var speakerCard: String? = null
var earpieceCard: String? = null
for (device in coreContext.core.audioDevices) {
if (device.hasCapability(AudioDevice.Capabilities.CapabilityPlay)) {
when (device.type) {
AudioDevice.Type.Speaker -> {
speakerCard = device.id
}
AudioDevice.Type.Earpiece -> {
earpieceCard = device.id
}
AudioDevice.Type.Headphones, AudioDevice.Type.Headset -> {
headphonesCard = device.id
}
else -> {}
}
}
}
Log.i("[Voice Recording] Found headset/headphones sound card [$headphonesCard], speaker sound card [$speakerCard] and earpiece sound card [$earpieceCard]")
val localPlayer = coreContext.core.createLocalPlayer(headphonesCard ?: speakerCard ?: earpieceCard, null, null)
if (localPlayer != null) {
voiceRecordingPlayer = localPlayer
} else {
Log.e("[Voice Recording] Couldn't create local player!")
return
}
voiceRecordingPlayer.addListener(playerListener)
val path = filePath.value
voiceRecordingPlayer.open(path.orEmpty())
voiceRecordDuration.value = voiceRecordingPlayer.duration
formattedDuration.value = SimpleDateFormat("mm:ss", Locale.getDefault()).format(voiceRecordingPlayer.duration) // is already in milliseconds
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)
fun onSipAddressClicked(sipUri: String)
fun onWebUrlClicked(url: String)
fun onCallConference(address: String, subject: String?)
fun onError(messageId: Int)
}

View File

@ -0,0 +1,259 @@
/*
* 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.util.Patterns
import androidx.lifecycle.MutableLiveData
import java.util.regex.Pattern
import org.linphone.R
import org.linphone.contact.GenericContactData
import org.linphone.core.ChatMessage
import org.linphone.core.ChatMessageListenerStub
import org.linphone.core.tools.Log
import org.linphone.utils.AppUtils
import org.linphone.utils.PatternClickableSpan
import org.linphone.utils.TimestampUtils
class ChatMessageData(val chatMessage: ChatMessage) : GenericContactData(chatMessage.fromAddress) {
private var contentListener: OnContentClickedListener? = null
val sendInProgress = 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>()
val isDisplayed = MutableLiveData<Boolean>()
val isOutgoing = chatMessage.isOutgoing
var hasPreviousMessage = false
var hasNextMessage = false
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) {
hasPreviousMessage = hasPrevious
hasNextMessage = hasNext
hideTime.value = false
hideAvatar.value = false
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) {
sendInProgress.value = when (state) {
ChatMessage.State.InProgress, ChatMessage.State.FileTransferInProgress, ChatMessage.State.FileTransferDone -> true
else -> false
}
showImdn.value = when (state) {
ChatMessage.State.DeliveredToUser, ChatMessage.State.Displayed,
ChatMessage.State.NotDelivered, ChatMessage.State.FileTransferError -> true
else -> false
}
imdnIcon.value = when (state) {
ChatMessage.State.DeliveredToUser -> R.drawable.chat_delivered
ChatMessage.State.Displayed -> R.drawable.chat_read
ChatMessage.State.FileTransferError, ChatMessage.State.NotDelivered -> R.drawable.chat_error
else -> R.drawable.chat_error
}
isDisplayed.value = state == ChatMessage.State.Displayed
}
private fun updateContentsList() {
contents.value.orEmpty().forEach(ChatMessageContentData::destroy)
val list = arrayListOf<ChatMessageContentData>()
val contentsList = chatMessage.contents
for (index in contentsList.indices) {
val content = contentsList[index]
if (content.isFileTransfer || content.isFile || content.isIcalendar) {
val data = ChatMessageContentData(chatMessage, index)
data.listener = contentListener
list.add(data)
} else if (content.isText) {
val spannable = Spannable.Factory.getInstance().newSpannable(content.utf8Text?.trim())
text.value = PatternClickableSpan()
.add(
Pattern.compile("(?:<?sips?:)?[a-zA-Z0-9+_.\\-]+(?:@([a-zA-Z0-9+_.\\-;=]+))+(>)?"),
object : PatternClickableSpan.SpannableClickedListener {
override fun onSpanClicked(text: String) {
Log.i("[Chat Message Data] Clicked on SIP URI: $text")
contentListener?.onSipAddressClicked(text)
}
}
)
.add(
Patterns.WEB_URL,
object : PatternClickableSpan.SpannableClickedListener {
override fun onSpanClicked(text: String) {
Log.i("[Chat Message Data] Clicked on web URL: $text")
contentListener?.onWebUrlClicked(text)
}
}
)
.add(
Patterns.PHONE,
object : PatternClickableSpan.SpannableClickedListener {
override fun onSpanClicked(text: String) {
Log.i("[Chat Message Data] Clicked on phone number: $text")
contentListener?.onSipAddressClicked(text)
}
}
).build(spannable)
} else {
Log.e("[Chat Message Data] Unexpected content with type: ${content.type}/${content.subtype}")
}
}
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,183 @@
/*
* Copyright (c) 2010-2022 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.main.chat.data
import android.graphics.Typeface
import android.text.SpannableStringBuilder
import android.text.style.StyleSpan
import androidx.lifecycle.MutableLiveData
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.LinphoneApplication.Companion.corePreferences
import org.linphone.R
import org.linphone.contact.ContactDataInterface
import org.linphone.core.*
import org.linphone.core.tools.Log
import org.linphone.utils.AppUtils
import org.linphone.utils.LinphoneUtils
import org.linphone.utils.TimestampUtils
class ChatRoomData(private val chatRoom: ChatRoom) : ContactDataInterface {
override val contact: MutableLiveData<Friend> = MutableLiveData<Friend>()
override val displayName: MutableLiveData<String> = MutableLiveData<String>()
override val securityLevel: MutableLiveData<ChatRoomSecurityLevel> = MutableLiveData<ChatRoomSecurityLevel>()
override val showGroupChatAvatar: Boolean
get() = conferenceChatRoom && !oneToOneChatRoom
override val coroutineScope: CoroutineScope = coreContext.coroutineScope
val unreadMessagesCount = MutableLiveData<Int>()
val subject = MutableLiveData<String>()
val securityLevelIcon = MutableLiveData<Int>()
val securityLevelContentDescription = MutableLiveData<Int>()
val ephemeralEnabled = MutableLiveData<Boolean>()
val lastUpdate = MutableLiveData<String>()
val lastMessageText = MutableLiveData<SpannableStringBuilder>()
val notificationsMuted = MutableLiveData<Boolean>()
private val basicChatRoom: Boolean by lazy {
chatRoom.hasCapability(ChatRoomCapabilities.Basic.toInt())
}
val oneToOneChatRoom: Boolean by lazy {
chatRoom.hasCapability(ChatRoomCapabilities.OneToOne.toInt())
}
private val conferenceChatRoom: Boolean by lazy {
chatRoom.hasCapability(ChatRoomCapabilities.Conference.toInt())
}
val encryptedChatRoom: Boolean by lazy {
chatRoom.hasCapability(ChatRoomCapabilities.Encrypted.toInt())
}
init {
unreadMessagesCount.value = chatRoom.unreadMessagesCount
subject.value = chatRoom.subject
updateSecurityIcon()
ephemeralEnabled.value = chatRoom.isEphemeralEnabled
contactLookup()
formatLastMessage(chatRoom.lastMessageInHistory)
notificationsMuted.value = areNotificationsMuted()
}
private fun updateSecurityIcon() {
val level = chatRoom.securityLevel
securityLevel.value = level
securityLevelIcon.value = when (level) {
ChatRoomSecurityLevel.Safe -> R.drawable.security_2_indicator
ChatRoomSecurityLevel.Encrypted -> R.drawable.security_1_indicator
else -> R.drawable.security_alert_indicator
}
securityLevelContentDescription.value = when (level) {
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
}
}
private fun contactLookup() {
displayName.value = when {
basicChatRoom -> LinphoneUtils.getDisplayName(
chatRoom.peerAddress
)
oneToOneChatRoom -> LinphoneUtils.getDisplayName(
chatRoom.participants.firstOrNull()?.address ?: chatRoom.peerAddress
)
conferenceChatRoom -> chatRoom.subject.orEmpty()
else -> chatRoom.peerAddress.asStringUriOnly()
}
if (oneToOneChatRoom) {
searchMatchingContact()
}
}
private fun searchMatchingContact() {
val remoteAddress = if (basicChatRoom) {
chatRoom.peerAddress
} else {
if (chatRoom.participants.isNotEmpty()) {
chatRoom.participants[0].address
} else {
Log.e("[Chat Room] ${chatRoom.peerAddress} doesn't have any participant (state ${chatRoom.state})!")
null
}
}
if (remoteAddress != null) {
contact.value = coreContext.contactsManager.findContactByAddress(remoteAddress)
}
}
private fun formatLastMessage(msg: ChatMessage?) {
val lastUpdateTime = chatRoom.lastUpdateTime
lastUpdate.value = "00:00"
coroutineScope.launch {
withContext(Dispatchers.IO) {
lastUpdate.postValue(TimestampUtils.toString(lastUpdateTime, true))
}
}
val builder = SpannableStringBuilder()
if (msg == null) {
lastMessageText.value = builder
return
}
val sender: String =
coreContext.contactsManager.findContactByAddress(msg.fromAddress)?.name
?: LinphoneUtils.getDisplayName(msg.fromAddress)
builder.append(sender)
builder.append(": ")
for (content in msg.contents) {
if (content.isIcalendar) {
val body = AppUtils.getString(R.string.conference_invitation)
builder.append(body)
builder.setSpan(StyleSpan(Typeface.ITALIC), builder.length - body.length, builder.length, 0)
} else if (content.isFile || content.isFileTransfer) {
builder.append(content.name + " ")
} else if (content.isText) {
builder.append(content.utf8Text + " ")
}
}
builder.trim()
lastMessageText.value = builder
}
private fun areNotificationsMuted(): Boolean {
val id = LinphoneUtils.getChatRoomId(chatRoom.localAddress, chatRoom.peerAddress)
return corePreferences.chatRoomMuted(id)
}
}

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, forceZRTP = true)
}
}

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.main.chat.data
import androidx.lifecycle.MutableLiveData
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.R
import org.linphone.contact.GenericContactData
import org.linphone.core.ChatRoomSecurityLevel
import org.linphone.core.Participant
import org.linphone.utils.LinphoneUtils
class DevicesListGroupData(private val participant: Participant) : GenericContactData(participant.address) {
val securityLevelIcon: Int by lazy {
when (participant.securityLevel) {
ChatRoomSecurityLevel.Safe -> R.drawable.security_2_indicator
ChatRoomSecurityLevel.Encrypted -> R.drawable.security_1_indicator
else -> R.drawable.security_alert_indicator
}
}
val securityLevelContentDescription: Int by lazy {
when (participant.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
}
}
val sipUri: String get() = LinphoneUtils.getDisplayableAddress(participant.address)
val isExpanded = MutableLiveData<Boolean>()
val devices = MutableLiveData<ArrayList<DevicesListChildData>>()
init {
securityLevel.value = participant.securityLevel
isExpanded.value = false
val list = arrayListOf<DevicesListChildData>()
for (device in participant.devices) {
list.add(DevicesListChildData((device)))
}
devices.value = list
}
fun toggleExpanded() {
isExpanded.value = isExpanded.value != true
}
fun onClick() {
val device = if (participant.devices.isEmpty()) null else participant.devices.first()
if (device?.address != null) {
coreContext.startCall(device.address, forceZRTP = true)
} else {
coreContext.startCall(participant.address, forceZRTP = true)
}
}
}

View File

@ -0,0 +1,37 @@
/*
* 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
class EphemeralDurationData(
val textResource: Int,
selectedDuration: Long,
private val duration: Long,
private val listener: DurationItemClicked
) {
val selected: Boolean = selectedDuration == duration
fun setSelected() {
listener.onDurationValueChanged(duration)
}
}
interface DurationItemClicked {
fun onDurationValueChanged(duration: Long)
}

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.main.chat.data
import android.content.Context
import androidx.lifecycle.MutableLiveData
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.R
import org.linphone.contact.GenericContactData
import org.linphone.core.EventLog
class EventData(private val eventLog: EventLog) : GenericContactData(
if (eventLog.type == EventLog.Type.ConferenceSecurityEvent) {
eventLog.securityEventFaultyDeviceAddress!!
} else {
if (eventLog.participantAddress == null) {
eventLog.peerAddress!!
} else {
eventLog.participantAddress!!
}
}
) {
val text = MutableLiveData<String>()
val isSecurity: Boolean by lazy {
when (eventLog.type) {
EventLog.Type.ConferenceSecurityEvent -> true
else -> false
}
}
val isGroupLeft: Boolean by lazy {
when (eventLog.type) {
EventLog.Type.ConferenceTerminated -> true
else -> false
}
}
init {
updateEventText()
}
private fun getName(): String {
return contact.value?.name ?: displayName.value ?: ""
}
private fun updateEventText() {
val context: Context = coreContext.context
text.value = when (eventLog.type) {
EventLog.Type.ConferenceCreated -> context.getString(R.string.chat_event_conference_created)
EventLog.Type.ConferenceTerminated -> context.getString(R.string.chat_event_conference_destroyed)
EventLog.Type.ConferenceParticipantAdded -> context.getString(R.string.chat_event_participant_added).format(getName())
EventLog.Type.ConferenceParticipantRemoved -> context.getString(R.string.chat_event_participant_removed).format(getName())
EventLog.Type.ConferenceSubjectChanged -> context.getString(R.string.chat_event_subject_changed).format(eventLog.subject)
EventLog.Type.ConferenceParticipantSetAdmin -> context.getString(R.string.chat_event_admin_set).format(getName())
EventLog.Type.ConferenceParticipantUnsetAdmin -> context.getString(R.string.chat_event_admin_unset).format(getName())
EventLog.Type.ConferenceParticipantDeviceAdded -> context.getString(R.string.chat_event_device_added).format(getName())
EventLog.Type.ConferenceParticipantDeviceRemoved -> context.getString(R.string.chat_event_device_removed).format(getName())
EventLog.Type.ConferenceSecurityEvent -> {
val name = getName()
when (eventLog.securityEventType) {
EventLog.SecurityEventType.EncryptionIdentityKeyChanged -> context.getString(R.string.chat_security_event_lime_identity_key_changed).format(name)
EventLog.SecurityEventType.ManInTheMiddleDetected -> context.getString(R.string.chat_security_event_man_in_the_middle_detected).format(name)
EventLog.SecurityEventType.SecurityLevelDowngraded -> context.getString(R.string.chat_security_event_security_level_downgraded).format(name)
EventLog.SecurityEventType.ParticipantMaxDeviceCountExceeded -> context.getString(R.string.chat_security_event_participant_max_count_exceeded).format(name)
else -> "Unexpected security event for $name: ${eventLog.securityEventType}"
}
}
EventLog.Type.ConferenceEphemeralMessageDisabled -> context.getString(R.string.chat_event_ephemeral_disabled)
EventLog.Type.ConferenceEphemeralMessageEnabled -> context.getString(R.string.chat_event_ephemeral_enabled).format(formatEphemeralExpiration(context, eventLog.ephemeralMessageLifetime))
EventLog.Type.ConferenceEphemeralMessageLifetimeChanged -> context.getString(R.string.chat_event_ephemeral_lifetime_changed).format(formatEphemeralExpiration(context, eventLog.ephemeralMessageLifetime))
else -> "Unexpected event: ${eventLog.type}"
}
}
private fun formatEphemeralExpiration(context: Context, duration: Long): String {
return when (duration) {
0L -> context.getString(R.string.chat_room_ephemeral_message_disabled)
60L -> context.getString(R.string.chat_room_ephemeral_message_one_minute)
3600L -> context.getString(R.string.chat_room_ephemeral_message_one_hour)
86400L -> context.getString(R.string.chat_room_ephemeral_message_one_day)
259200L -> context.getString(R.string.chat_room_ephemeral_message_three_days)
604800L -> context.getString(R.string.chat_room_ephemeral_message_one_week)
else -> "Unexpected duration"
}
}
}

View File

@ -0,0 +1,39 @@
/*
* 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.main.chat.data
import org.linphone.contact.GenericContactData
import org.linphone.core.EventLog
class EventLogData(val eventLog: EventLog) {
val type: EventLog.Type = eventLog.type
val notifyId = eventLog.notifyId
val data: GenericContactData = if (type == EventLog.Type.ConferenceChatMessage) {
ChatMessageData(eventLog.chatMessage!!)
} else {
EventData(eventLog)
}
fun destroy() {
data.destroy()
}
}

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.main.chat.data
import androidx.lifecycle.MutableLiveData
import org.linphone.R
import org.linphone.activities.main.chat.GroupChatRoomMember
import org.linphone.contact.GenericContactData
import org.linphone.core.ChatRoomSecurityLevel
import org.linphone.utils.LinphoneUtils
class GroupInfoParticipantData(val participant: GroupChatRoomMember) : GenericContactData(participant.address) {
val sipUri: String get() = LinphoneUtils.getDisplayableAddress(participant.address)
val isAdmin = MutableLiveData<Boolean>()
val showAdminControls = MutableLiveData<Boolean>()
// A participant not yet added to a group can't be set admin at the same time it's added
val canBeSetAdmin = MutableLiveData<Boolean>()
val securityLevelIcon: Int by lazy {
when (participant.securityLevel) {
ChatRoomSecurityLevel.Safe -> R.drawable.security_2_indicator
ChatRoomSecurityLevel.Encrypted -> R.drawable.security_1_indicator
else -> R.drawable.security_alert_indicator
}
}
val securityLevelContentDescription: Int by lazy {
when (participant.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
}
}
init {
securityLevel.value = participant.securityLevel
isAdmin.value = participant.isAdmin
showAdminControls.value = false
canBeSetAdmin.value = participant.canBeSetAdmin
}
fun setAdmin() {
isAdmin.value = true
participant.isAdmin = true
}
fun unSetAdmin() {
isAdmin.value = false
participant.isAdmin = false
}
}

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.main.chat.data
import org.linphone.contact.GenericContactData
import org.linphone.core.ParticipantImdnState
import org.linphone.utils.TimestampUtils
class ImdnParticipantData(val imdnState: ParticipantImdnState) : GenericContactData(imdnState.participant.address) {
val sipUri: String = imdnState.participant.address.asStringUriOnly()
val time: String = TimestampUtils.toString(imdnState.stateChangeTime)
}

View File

@ -0,0 +1,190 @@
/*
* 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.fragments
import android.content.pm.PackageManager
import android.os.Bundle
import android.view.View
import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.LinearLayoutManager
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.LinphoneApplication.Companion.corePreferences
import org.linphone.R
import org.linphone.activities.main.MainActivity
import org.linphone.activities.main.chat.viewmodels.ChatRoomCreationViewModel
import org.linphone.activities.main.fragments.SecureFragment
import org.linphone.activities.navigateToChatRoom
import org.linphone.activities.navigateToGroupInfo
import org.linphone.contact.ContactsSelectionAdapter
import org.linphone.core.tools.Log
import org.linphone.databinding.ChatRoomCreationFragmentBinding
import org.linphone.utils.AppUtils
import org.linphone.utils.LinphoneUtils
import org.linphone.utils.PermissionHelper
class ChatRoomCreationFragment : SecureFragment<ChatRoomCreationFragmentBinding>() {
private lateinit var viewModel: ChatRoomCreationViewModel
private lateinit var adapter: ContactsSelectionAdapter
override fun getLayoutId(): Int = R.layout.chat_room_creation_fragment
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.lifecycleOwner = viewLifecycleOwner
useMaterialSharedAxisXForwardAnimation = sharedViewModel.isSlidingPaneSlideable.value == false
val createGroup = arguments?.getBoolean("createGroup") ?: false
viewModel = ViewModelProvider(this)[ChatRoomCreationViewModel::class.java]
viewModel.createGroupChat.value = createGroup
viewModel.isEncrypted.value = sharedViewModel.createEncryptedChatRoom
binding.viewModel = viewModel
adapter = ContactsSelectionAdapter(viewLifecycleOwner)
adapter.setGroupChatCapabilityRequired(viewModel.createGroupChat.value == true)
adapter.setLimeCapabilityRequired(viewModel.isEncrypted.value == true)
binding.contactsList.adapter = adapter
val layoutManager = LinearLayoutManager(requireContext())
binding.contactsList.layoutManager = layoutManager
// Divider between items
binding.contactsList.addItemDecoration(AppUtils.getDividerDecoration(requireContext(), layoutManager))
binding.back.visibility = if (resources.getBoolean(R.bool.isTablet)) View.INVISIBLE else View.VISIBLE
binding.setAllContactsToggleClickListener {
viewModel.sipContactsSelected.value = false
}
binding.setSipContactsToggleClickListener {
viewModel.sipContactsSelected.value = true
}
viewModel.contactsList.observe(
viewLifecycleOwner
) {
adapter.submitList(it)
}
viewModel.isEncrypted.observe(
viewLifecycleOwner
) {
adapter.setLimeCapabilityRequired(it)
}
viewModel.sipContactsSelected.observe(
viewLifecycleOwner
) {
viewModel.applyFilter()
}
viewModel.selectedAddresses.observe(
viewLifecycleOwner
) {
adapter.updateSelectedAddresses(it)
}
viewModel.chatRoomCreatedEvent.observe(
viewLifecycleOwner
) {
it.consume { chatRoom ->
sharedViewModel.selectedChatRoom.value = chatRoom
navigateToChatRoom(AppUtils.createBundleWithSharedTextAndFiles(sharedViewModel))
}
}
viewModel.filter.observe(
viewLifecycleOwner
) {
viewModel.applyFilter()
}
adapter.selectedContact.observe(
viewLifecycleOwner
) {
it.consume { searchResult ->
if (createGroup) {
viewModel.toggleSelectionForSearchResult(searchResult)
} else {
viewModel.createOneToOneChat(searchResult)
}
}
}
addParticipantsFromSharedViewModel()
// Next button is only used to go to group chat info fragment
binding.setNextClickListener {
sharedViewModel.createEncryptedChatRoom = viewModel.isEncrypted.value == true
sharedViewModel.chatRoomParticipants.value = viewModel.selectedAddresses.value
navigateToGroupInfo()
}
viewModel.onMessageToNotifyEvent.observe(
viewLifecycleOwner
) {
it.consume { messageResourceId ->
(activity as MainActivity).showSnackBar(messageResourceId)
}
}
if (corePreferences.enableNativeAddressBookIntegration) {
if (!PermissionHelper.get().hasReadContactsPermission()) {
Log.i("[Chat Room Creation] Asking for READ_CONTACTS permission")
requestPermissions(arrayOf(android.Manifest.permission.READ_CONTACTS), 0)
}
}
}
@Deprecated("Deprecated in Java")
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
if (requestCode == 0) {
val granted = grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED
if (granted) {
Log.i("[Chat Room Creation] READ_CONTACTS permission granted")
coreContext.fetchContacts()
} else {
Log.w("[Chat Room Creation] READ_CONTACTS permission denied")
}
}
}
override fun onResume() {
super.onResume()
viewModel.secureChatAvailable.value = LinphoneUtils.isEndToEndEncryptedChatAvailable()
}
private fun addParticipantsFromSharedViewModel() {
val participants = sharedViewModel.chatRoomParticipants.value
if (participants != null && participants.size > 0) {
viewModel.selectedAddresses.value = participants
}
}
}

View File

@ -0,0 +1,64 @@
/*
* 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.fragments
import android.os.Bundle
import android.view.View
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.fragment.findNavController
import org.linphone.R
import org.linphone.activities.main.chat.viewmodels.DevicesListViewModel
import org.linphone.activities.main.chat.viewmodels.DevicesListViewModelFactory
import org.linphone.activities.main.fragments.SecureFragment
import org.linphone.core.tools.Log
import org.linphone.databinding.ChatRoomDevicesFragmentBinding
class DevicesFragment : SecureFragment<ChatRoomDevicesFragmentBinding>() {
private lateinit var listViewModel: DevicesListViewModel
override fun getLayoutId(): Int = R.layout.chat_room_devices_fragment
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.lifecycleOwner = viewLifecycleOwner
val chatRoom = sharedViewModel.selectedChatRoom.value
if (chatRoom == null) {
Log.e("[Devices] Chat room is null, aborting!")
findNavController().navigateUp()
return
}
isSecure = chatRoom.currentParams.isEncryptionEnabled
listViewModel = ViewModelProvider(
this,
DevicesListViewModelFactory(chatRoom)
)[DevicesListViewModel::class.java]
binding.viewModel = listViewModel
}
override fun onResume() {
super.onResume()
listViewModel.updateParticipants()
}
}

View File

@ -0,0 +1,66 @@
/*
* 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.fragments
import android.os.Bundle
import android.view.View
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.fragment.findNavController
import org.linphone.R
import org.linphone.activities.main.chat.viewmodels.EphemeralViewModel
import org.linphone.activities.main.chat.viewmodels.EphemeralViewModelFactory
import org.linphone.activities.main.fragments.SecureFragment
import org.linphone.core.tools.Log
import org.linphone.databinding.ChatRoomEphemeralFragmentBinding
import org.linphone.utils.Event
class EphemeralFragment : SecureFragment<ChatRoomEphemeralFragmentBinding>() {
private lateinit var viewModel: EphemeralViewModel
override fun getLayoutId(): Int {
return R.layout.chat_room_ephemeral_fragment
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
isSecure = true
binding.lifecycleOwner = viewLifecycleOwner
val chatRoom = sharedViewModel.selectedChatRoom.value
if (chatRoom == null) {
Log.e("[Ephemeral] Chat room is null, aborting!")
findNavController().navigateUp()
return
}
viewModel = ViewModelProvider(
this,
EphemeralViewModelFactory(chatRoom)
)[EphemeralViewModel::class.java]
binding.viewModel = viewModel
binding.setValidClickListener {
viewModel.updateChatRoomEphemeralDuration()
sharedViewModel.refreshChatRoomInListEvent.value = Event(true)
goBack()
}
}
}

View File

@ -0,0 +1,231 @@
/*
* 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.fragments
import android.app.Dialog
import android.os.Bundle
import android.view.View
import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.LinearLayoutManager
import org.linphone.LinphoneApplication.Companion.corePreferences
import org.linphone.R
import org.linphone.activities.main.MainActivity
import org.linphone.activities.main.chat.GroupChatRoomMember
import org.linphone.activities.main.chat.adapters.GroupInfoParticipantsAdapter
import org.linphone.activities.main.chat.data.GroupInfoParticipantData
import org.linphone.activities.main.chat.viewmodels.GroupInfoViewModel
import org.linphone.activities.main.chat.viewmodels.GroupInfoViewModelFactory
import org.linphone.activities.main.fragments.SecureFragment
import org.linphone.activities.main.viewmodels.DialogViewModel
import org.linphone.activities.navigateToChatRoom
import org.linphone.activities.navigateToChatRoomCreation
import org.linphone.core.Address
import org.linphone.core.ChatRoom
import org.linphone.core.ChatRoomCapabilities
import org.linphone.databinding.ChatRoomGroupInfoFragmentBinding
import org.linphone.utils.AppUtils
import org.linphone.utils.DialogUtils
class GroupInfoFragment : SecureFragment<ChatRoomGroupInfoFragmentBinding>() {
private lateinit var viewModel: GroupInfoViewModel
private lateinit var adapter: GroupInfoParticipantsAdapter
private var meAdminStatusChangedDialog: Dialog? = null
override fun getLayoutId(): Int = R.layout.chat_room_group_info_fragment
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.lifecycleOwner = viewLifecycleOwner
val chatRoom: ChatRoom? = sharedViewModel.selectedGroupChatRoom.value
isSecure = chatRoom?.currentParams?.isEncryptionEnabled ?: false
viewModel = ViewModelProvider(
this,
GroupInfoViewModelFactory(chatRoom)
)[GroupInfoViewModel::class.java]
binding.viewModel = viewModel
viewModel.isEncrypted.value = sharedViewModel.createEncryptedChatRoom
adapter = GroupInfoParticipantsAdapter(
viewLifecycleOwner,
chatRoom?.hasCapability(ChatRoomCapabilities.Encrypted.toInt()) ?: (viewModel.isEncrypted.value == true)
)
binding.participants.adapter = adapter
val layoutManager = LinearLayoutManager(requireContext())
binding.participants.layoutManager = layoutManager
// Divider between items
binding.participants.addItemDecoration(AppUtils.getDividerDecoration(requireContext(), layoutManager))
viewModel.participants.observe(
viewLifecycleOwner
) {
adapter.submitList(it)
}
viewModel.isMeAdmin.observe(
viewLifecycleOwner
) { isMeAdmin ->
adapter.showAdminControls(isMeAdmin && chatRoom != null)
}
viewModel.meAdminChangedEvent.observe(
viewLifecycleOwner
) {
it.consume { isMeAdmin ->
showMeAdminStateChanged(isMeAdmin)
}
}
adapter.participantRemovedEvent.observe(
viewLifecycleOwner
) {
it.consume { participant ->
viewModel.removeParticipant(participant)
}
}
addParticipantsFromSharedViewModel()
viewModel.createdChatRoomEvent.observe(
viewLifecycleOwner
) {
it.consume { chatRoom ->
goToChatRoom(chatRoom, true)
}
}
viewModel.updatedChatRoomEvent.observe(
viewLifecycleOwner
) {
it.consume { chatRoom ->
goToChatRoom(chatRoom, false)
}
}
binding.setNextClickListener {
if (viewModel.chatRoom != null) {
viewModel.updateRoom()
} else {
viewModel.createChatRoom()
}
}
binding.setParticipantsClickListener {
sharedViewModel.createEncryptedChatRoom = corePreferences.forceEndToEndEncryptedChat || viewModel.isEncrypted.value == true
val list = arrayListOf<Address>()
for (participant in viewModel.participants.value.orEmpty()) {
list.add(participant.participant.address)
}
sharedViewModel.chatRoomParticipants.value = list
sharedViewModel.chatRoomSubject = viewModel.subject.value.orEmpty()
val args = Bundle()
args.putBoolean("createGroup", true)
navigateToChatRoomCreation(args)
}
binding.setLeaveClickListener {
val dialogViewModel = DialogViewModel(getString(R.string.chat_room_group_info_leave_dialog_message))
val dialog: Dialog = DialogUtils.getDialog(requireContext(), dialogViewModel)
dialogViewModel.showDeleteButton(
{
viewModel.leaveGroup()
dialog.dismiss()
},
getString(R.string.chat_room_group_info_leave_dialog_button)
)
dialogViewModel.showCancelButton {
dialog.dismiss()
}
dialog.show()
}
viewModel.onMessageToNotifyEvent.observe(
viewLifecycleOwner
) {
it.consume { messageResourceId ->
(activity as MainActivity).showSnackBar(messageResourceId)
}
}
}
private fun addParticipantsFromSharedViewModel() {
val participants = sharedViewModel.chatRoomParticipants.value
if (participants != null && participants.size > 0) {
val list = arrayListOf<GroupInfoParticipantData>()
for (address in participants) {
val exists = viewModel.participants.value?.find {
it.participant.address.weakEqual(address)
}
if (exists != null) {
list.add(exists)
} else {
list.add(
GroupInfoParticipantData(
GroupChatRoomMember(address, false, hasLimeX3DHCapability = viewModel.isEncrypted.value == true)
)
)
}
}
viewModel.participants.value = list
}
if (sharedViewModel.chatRoomSubject.isNotEmpty()) {
viewModel.subject.value = sharedViewModel.chatRoomSubject
sharedViewModel.chatRoomSubject = ""
}
}
private fun showMeAdminStateChanged(isMeAdmin: Boolean) {
meAdminStatusChangedDialog?.dismiss()
val message = if (isMeAdmin) {
getString(R.string.chat_room_group_info_you_are_now_admin)
} else {
getString(R.string.chat_room_group_info_you_are_no_longer_admin)
}
val dialogViewModel = DialogViewModel(message)
val dialog = DialogUtils.getDialog(requireContext(), dialogViewModel)
dialogViewModel.showOkButton({
dialog.dismiss()
})
dialog.show()
meAdminStatusChangedDialog = dialog
}
private fun goToChatRoom(chatRoom: ChatRoom, created: Boolean) {
sharedViewModel.selectedChatRoom.value = chatRoom
navigateToChatRoom(AppUtils.createBundleWithSharedTextAndFiles(sharedViewModel), created)
}
}

View File

@ -0,0 +1,99 @@
/*
* 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.fragments
import android.os.Bundle
import android.view.View
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.LinearLayoutManager
import org.linphone.R
import org.linphone.activities.main.chat.adapters.ImdnAdapter
import org.linphone.activities.main.chat.viewmodels.ImdnViewModel
import org.linphone.activities.main.chat.viewmodels.ImdnViewModelFactory
import org.linphone.activities.main.fragments.SecureFragment
import org.linphone.core.tools.Log
import org.linphone.databinding.ChatRoomImdnFragmentBinding
import org.linphone.utils.AppUtils
import org.linphone.utils.RecyclerViewHeaderDecoration
class ImdnFragment : SecureFragment<ChatRoomImdnFragmentBinding>() {
private lateinit var viewModel: ImdnViewModel
private lateinit var adapter: ImdnAdapter
override fun getLayoutId(): Int {
return R.layout.chat_room_imdn_fragment
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.lifecycleOwner = viewLifecycleOwner
val chatRoom = sharedViewModel.selectedChatRoom.value
if (chatRoom == null) {
Log.e("[IMDN] Chat room is null, aborting!")
findNavController().navigateUp()
return
}
isSecure = chatRoom.currentParams.isEncryptionEnabled
if (arguments != null) {
val messageId = arguments?.getString("MessageId")
val message = if (messageId != null) chatRoom.findMessage(messageId) else null
if (message != null) {
Log.i("[IMDN] Found message $message with id $messageId")
viewModel = ViewModelProvider(
this,
ImdnViewModelFactory(message)
)[ImdnViewModel::class.java]
binding.viewModel = viewModel
} else {
Log.e("[IMDN] Couldn't find message with id $messageId in chat room $chatRoom")
findNavController().popBackStack()
return
}
} else {
Log.e("[IMDN] Couldn't find message id in intent arguments")
findNavController().popBackStack()
return
}
adapter = ImdnAdapter(viewLifecycleOwner)
binding.participantsList.adapter = adapter
val layoutManager = LinearLayoutManager(requireContext())
binding.participantsList.layoutManager = layoutManager
// Divider between items
binding.participantsList.addItemDecoration(AppUtils.getDividerDecoration(requireContext(), layoutManager))
// Displays state header
val headerItemDecoration = RecyclerViewHeaderDecoration(requireContext(), adapter)
binding.participantsList.addItemDecoration(headerItemDecoration)
viewModel.participants.observe(
viewLifecycleOwner
) {
adapter.submitList(it)
}
}
}

View File

@ -0,0 +1,399 @@
/*
* 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.fragments
import android.app.Dialog
import android.content.res.Configuration
import android.os.Bundle
import android.view.View
import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.fragment.NavHostFragment
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.slidingpanelayout.widget.SlidingPaneLayout
import com.google.android.material.transition.MaterialSharedAxis
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.clearDisplayedChatRoom
import org.linphone.activities.main.MainActivity
import org.linphone.activities.main.chat.adapters.ChatRoomsListAdapter
import org.linphone.activities.main.chat.viewmodels.ChatRoomsListViewModel
import org.linphone.activities.main.fragments.MasterFragment
import org.linphone.activities.main.viewmodels.DialogViewModel
import org.linphone.activities.navigateToChatRoom
import org.linphone.activities.navigateToChatRoomCreation
import org.linphone.core.ChatRoom
import org.linphone.core.Factory
import org.linphone.core.tools.Log
import org.linphone.databinding.ChatRoomMasterFragmentBinding
import org.linphone.utils.*
class MasterChatRoomsFragment : MasterFragment<ChatRoomMasterFragmentBinding, ChatRoomsListAdapter>() {
override val dialogConfirmationMessageBeforeRemoval = R.plurals.chat_room_delete_dialog
private lateinit var listViewModel: ChatRoomsListViewModel
private val observer = object : RecyclerView.AdapterDataObserver() {
override fun onChanged() {
scrollToTop()
}
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
if (positionStart == 0 && itemCount == 1) {
scrollToTop()
}
}
override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) {
scrollToTop()
}
}
override fun getLayoutId(): Int = R.layout.chat_room_master_fragment
override fun onDestroyView() {
binding.chatList.adapter = null
adapter.unregisterAdapterDataObserver(observer)
super.onDestroyView()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
useMaterialSharedAxisXForwardAnimation = false
if (corePreferences.enableAnimations) {
val portraitOrientation = resources.configuration.orientation != Configuration.ORIENTATION_LANDSCAPE
val axis = if (portraitOrientation) MaterialSharedAxis.X else MaterialSharedAxis.Y
enterTransition = MaterialSharedAxis(axis, true)
reenterTransition = MaterialSharedAxis(axis, true)
returnTransition = MaterialSharedAxis(axis, false)
exitTransition = MaterialSharedAxis(axis, false)
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
isSecure = true
binding.lifecycleOwner = viewLifecycleOwner
listViewModel = requireActivity().run {
ViewModelProvider(this)[ChatRoomsListViewModel::class.java]
}
binding.viewModel = listViewModel
/* Shared view model & sliding pane related */
setUpSlidingPane(binding.slidingPane)
binding.slidingPane.addPanelSlideListener(object : SlidingPaneLayout.PanelSlideListener {
override fun onPanelSlide(panel: View, slideOffset: Float) { }
override fun onPanelOpened(panel: View) { }
override fun onPanelClosed(panel: View) {
// Conversation isn't visible anymore, any new message received in it will trigger a notification
coreContext.notificationsManager.currentlyDisplayedChatRoomAddress = null
}
})
// Chat room loading can take some time, so wait until it is ready before opening the pane
sharedViewModel.chatRoomFragmentOpenedEvent.observe(
viewLifecycleOwner
) {
it.consume {
binding.slidingPane.openPane()
}
}
sharedViewModel.layoutChangedEvent.observe(
viewLifecycleOwner
) {
it.consume {
sharedViewModel.isSlidingPaneSlideable.value = binding.slidingPane.isSlideable
if (binding.slidingPane.isSlideable) {
val navHostFragment =
childFragmentManager.findFragmentById(R.id.chat_nav_container) as NavHostFragment
if (navHostFragment.navController.currentDestination?.id == R.id.emptyChatFragment) {
Log.i("[Chat] Foldable device has been folded, closing side pane with empty fragment")
binding.slidingPane.closePane()
}
}
}
}
sharedViewModel.refreshChatRoomInListEvent.observe(
viewLifecycleOwner
) {
it.consume {
val chatRoom = sharedViewModel.selectedChatRoom.value
if (chatRoom != null) {
listViewModel.notifyChatRoomUpdate(chatRoom)
}
}
}
/* End of shared view model & sliding pane related */
_adapter = ChatRoomsListAdapter(listSelectionViewModel, viewLifecycleOwner)
// SubmitList is done on a background thread
// We need this adapter data observer to know when to scroll
adapter.registerAdapterDataObserver(observer)
binding.chatList.setHasFixedSize(true)
binding.chatList.adapter = adapter
val layoutManager = LinearLayoutManager(requireContext())
binding.chatList.layoutManager = layoutManager
// Swipe action
val swipeConfiguration = RecyclerViewSwipeConfiguration()
val white = ContextCompat.getColor(requireContext(), R.color.white_color)
swipeConfiguration.rightToLeftAction = RecyclerViewSwipeConfiguration.Action(
requireContext().getString(R.string.dialog_delete),
white,
ContextCompat.getColor(requireContext(), R.color.red_color)
)
swipeConfiguration.leftToRightAction = RecyclerViewSwipeConfiguration.Action(
requireContext().getString(R.string.received_chat_notification_mark_as_read_label),
white,
ContextCompat.getColor(requireContext(), R.color.imdn_read_color)
)
val swipeListener = object : RecyclerViewSwipeListener {
override fun onLeftToRightSwipe(viewHolder: RecyclerView.ViewHolder) {
val index = viewHolder.bindingAdapterPosition
if (index < 0 || index >= adapter.currentList.size) {
Log.e("[Chat] Index is out of bound, can't mark chat room as read")
} else {
val chatRoom = adapter.currentList[viewHolder.bindingAdapterPosition]
chatRoom.markAsRead()
adapter.notifyItemChanged(viewHolder.bindingAdapterPosition)
}
}
override fun onRightToLeftSwipe(viewHolder: RecyclerView.ViewHolder) {
val viewModel = DialogViewModel(getString(R.string.chat_room_delete_one_dialog))
val dialog: Dialog = DialogUtils.getDialog(requireContext(), viewModel)
val index = viewHolder.bindingAdapterPosition
if (index < 0 || index >= adapter.currentList.size) {
Log.e("[Chat] Index is out of bound, can't delete chat room")
} else {
viewModel.showCancelButton {
adapter.notifyItemChanged(viewHolder.bindingAdapterPosition)
dialog.dismiss()
}
viewModel.showDeleteButton(
{
val deletedChatRoom =
adapter.currentList[index]
listViewModel.deleteChatRoom(deletedChatRoom)
if (!binding.slidingPane.isSlideable &&
deletedChatRoom == sharedViewModel.selectedChatRoom.value
) {
Log.i("[Chat] Currently displayed chat room has been deleted, removing detail fragment")
clearDisplayedChatRoom()
}
dialog.dismiss()
},
getString(R.string.dialog_delete)
)
dialog.show()
}
}
}
RecyclerViewSwipeUtils(ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT, swipeConfiguration, swipeListener)
.attachToRecyclerView(binding.chatList)
// Divider between items
binding.chatList.addItemDecoration(AppUtils.getDividerDecoration(requireContext(), layoutManager))
listViewModel.chatRooms.observe(
viewLifecycleOwner
) { chatRooms ->
adapter.submitList(chatRooms)
}
listViewModel.chatRoomIndexUpdatedEvent.observe(
viewLifecycleOwner
) {
it.consume { index ->
adapter.notifyItemChanged(index)
}
}
adapter.selectedChatRoomEvent.observe(
viewLifecycleOwner
) {
it.consume { chatRoom ->
if ((requireActivity() as GenericActivity).isDestructionPending) {
Log.w("[Chat] Activity is pending destruction, don't start navigating now!")
sharedViewModel.destructionPendingChatRoom = chatRoom
} else {
if (chatRoom.peerAddress.asStringUriOnly() == coreContext.notificationsManager.currentlyDisplayedChatRoomAddress) {
if (!binding.slidingPane.isOpen) {
Log.w("[Chat] Chat room is displayed but sliding pane is closed...")
if (!binding.slidingPane.openPane()) {
Log.e("[Chat] Tried to open pane to workaround already displayed chat room issue, failed!")
}
} else {
Log.w("[Chat] This chat room is already displayed!")
}
} else {
sharedViewModel.selectedChatRoom.value = chatRoom
navigateToChatRoom(
AppUtils.createBundleWithSharedTextAndFiles(
sharedViewModel
)
)
}
}
}
}
binding.setEditClickListener {
listSelectionViewModel.isEditionEnabled.value = true
}
binding.setCancelForwardClickListener {
sharedViewModel.messageToForwardEvent.value?.consume {
Log.i("[Chat] Cancelling message forward")
}
sharedViewModel.isPendingMessageForward.value = false
}
binding.setCancelSharingClickListener {
Log.i("[Chat] Cancelling text/files sharing")
sharedViewModel.textToShare.value = ""
sharedViewModel.filesToShare.value = arrayListOf()
listViewModel.fileSharingPending.value = false
listViewModel.textSharingPending.value = false
}
binding.setNewOneToOneChatRoomClickListener {
sharedViewModel.chatRoomParticipants.value = arrayListOf()
navigateToChatRoomCreation(false, binding.slidingPane)
}
binding.setNewGroupChatRoomClickListener {
sharedViewModel.selectedGroupChatRoom.value = null
sharedViewModel.chatRoomParticipants.value = arrayListOf()
navigateToChatRoomCreation(true, binding.slidingPane)
}
val pendingDestructionChatRoom = sharedViewModel.destructionPendingChatRoom
if (pendingDestructionChatRoom != null) {
Log.w("[Chat] Found pending chat room from before activity was recreated")
sharedViewModel.destructionPendingChatRoom = null
sharedViewModel.selectedChatRoom.value = pendingDestructionChatRoom
navigateToChatRoom(AppUtils.createBundleWithSharedTextAndFiles(sharedViewModel))
}
val localSipUri = arguments?.getString("LocalSipUri")
val remoteSipUri = arguments?.getString("RemoteSipUri")
if (localSipUri != null && remoteSipUri != null) {
Log.i("[Chat] Found local [$localSipUri] & remote [$remoteSipUri] addresses in arguments")
arguments?.clear()
val localAddress = Factory.instance().createAddress(localSipUri)
val remoteSipAddress = Factory.instance().createAddress(remoteSipUri)
val chatRoom = coreContext.core.searchChatRoom(null, localAddress, remoteSipAddress, arrayOfNulls(0))
if (chatRoom != null) {
Log.i("[Chat] Found matching chat room $chatRoom")
adapter.selectedChatRoomEvent.value = Event(chatRoom)
}
} else {
sharedViewModel.textToShare.observe(
viewLifecycleOwner
) {
if (it.isNotEmpty()) {
Log.i("[Chat] Found text to share")
listViewModel.textSharingPending.value = true
clearDisplayedChatRoom()
} else {
if (sharedViewModel.filesToShare.value.isNullOrEmpty()) {
listViewModel.textSharingPending.value = false
}
}
}
sharedViewModel.filesToShare.observe(
viewLifecycleOwner
) {
if (it.isNotEmpty()) {
Log.i("[Chat] Found ${it.size} files to share")
listViewModel.fileSharingPending.value = true
clearDisplayedChatRoom()
} else {
if (sharedViewModel.textToShare.value.isNullOrEmpty()) {
listViewModel.fileSharingPending.value = false
}
}
}
sharedViewModel.isPendingMessageForward.observe(
viewLifecycleOwner
) {
listViewModel.forwardPending.value = it
adapter.forwardPending(it)
if (it) {
Log.i("[Chat] Found chat message to transfer")
}
}
listViewModel.onMessageToNotifyEvent.observe(
viewLifecycleOwner
) {
it.consume { messageResourceId ->
(activity as MainActivity).showSnackBar(messageResourceId)
}
}
}
}
override fun onResume() {
super.onResume()
listViewModel.groupChatAvailable.value = LinphoneUtils.isGroupChatAvailable()
}
override fun deleteItems(indexesOfItemToDelete: ArrayList<Int>) {
val list = ArrayList<ChatRoom>()
var closeSlidingPane = false
for (index in indexesOfItemToDelete) {
val chatRoom = adapter.currentList[index]
list.add(chatRoom)
if (chatRoom == sharedViewModel.selectedChatRoom.value) {
closeSlidingPane = true
}
}
listViewModel.deleteChatRooms(list)
if (!binding.slidingPane.isSlideable && closeSlidingPane) {
Log.i("[Chat] Currently displayed chat room has been deleted, removing detail fragment")
clearDisplayedChatRoom()
}
}
private fun scrollToTop() {
binding.chatList.scrollToPosition(0)
}
}

View File

@ -0,0 +1,50 @@
/*
* 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.main.chat.receivers
import android.content.ClipData
import android.net.Uri
import android.view.View
import androidx.core.util.component1
import androidx.core.util.component2
import androidx.core.view.ContentInfoCompat
import androidx.core.view.OnReceiveContentListener
import org.linphone.core.tools.Log
class RichContentReceiver(private val contentReceived: (uri: Uri) -> Unit) : OnReceiveContentListener {
companion object {
val MIME_TYPES = arrayOf("image/png", "image/gif", "image/jpeg")
}
override fun onReceiveContent(view: View, payload: ContentInfoCompat): ContentInfoCompat? {
val (uriContent, remaining) = payload.partition { item -> item.uri != null }
if (uriContent != null) {
val clip: ClipData = uriContent.clip
for (i in 0 until clip.itemCount) {
val uri: Uri = clip.getItemAt(i).uri
Log.i("[Content Receiver] Found URI: $uri")
contentReceived(uri)
}
}
// Return anything that your app didn't handle. This preserves the default platform
// behavior for text and anything else that you aren't implementing custom handling for.
return remaining
}
}

View File

@ -0,0 +1,524 @@
/*
* 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.viewmodels
import android.view.inputmethod.EditorInfo
import android.widget.Toast
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.media.AudioFocusRequestCompat
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
import kotlin.collections.ArrayList
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.LinphoneApplication.Companion.corePreferences
import org.linphone.R
import org.linphone.activities.main.chat.data.ChatMessageAttachmentData
import org.linphone.activities.main.chat.data.ChatMessageData
import org.linphone.compatibility.Compatibility
import org.linphone.core.*
import org.linphone.core.tools.Log
import org.linphone.utils.AppUtils
import org.linphone.utils.Event
import org.linphone.utils.FileUtils
import org.linphone.utils.PermissionHelper
class ChatMessageSendingViewModelFactory(private val chatRoom: ChatRoom) :
ViewModelProvider.NewInstanceFactory() {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return ChatMessageSendingViewModel(chatRoom) as T
}
}
class ChatMessageSendingViewModel(private val chatRoom: ChatRoom) : ViewModel() {
var temporaryFileUploadPath: File? = null
val attachments = MutableLiveData<ArrayList<ChatMessageAttachmentData>>()
val attachFileEnabled = MutableLiveData<Boolean>()
val sendMessageEnabled = MutableLiveData<Boolean>()
val isReadOnly = MutableLiveData<Boolean>()
var textToSend = MutableLiveData<String>()
val isPendingAnswer = MutableLiveData<Boolean>()
var pendingChatMessageToReplyTo = MutableLiveData<ChatMessageData>()
val requestRecordAudioPermissionEvent: MutableLiveData<Event<Boolean>> by lazy {
MutableLiveData<Event<Boolean>>()
}
val messageSentEvent: MutableLiveData<Event<Boolean>> by lazy {
MutableLiveData<Event<Boolean>>()
}
val voiceRecordingProgressBarMax = 10000
val isPendingVoiceRecord = MutableLiveData<Boolean>()
val isVoiceRecording = MutableLiveData<Boolean>()
val voiceRecordingDuration = MutableLiveData<Int>()
val formattedDuration = MutableLiveData<String>()
val isPlayingVoiceRecording = MutableLiveData<Boolean>()
val voiceRecordPlayingPosition = MutableLiveData<Int>()
val imeFlags: Int = if (chatRoom.hasCapability(ChatRoomCapabilities.Encrypted.toInt())) {
// IME_FLAG_NO_PERSONALIZED_LEARNING is only available on Android 8 and newer
Compatibility.getImeFlagsForSecureChatRoom()
} else {
EditorInfo.IME_FLAG_NO_EXTRACT_UI
}
private val recorder: Recorder
private var voiceRecordAudioFocusRequest: AudioFocusRequestCompat? = null
private lateinit var voiceRecordingPlayer: Player
private val playerListener = PlayerListener {
Log.i("[Chat Message Sending] End of file reached")
stopVoiceRecordPlayer()
}
private val chatRoomListener: ChatRoomListenerStub = object : ChatRoomListenerStub() {
override fun onStateChanged(chatRoom: ChatRoom, state: ChatRoom.State) {
updateChatRoomReadOnlyState()
}
override fun onConferenceJoined(chatRoom: ChatRoom, eventLog: EventLog) {
updateChatRoomReadOnlyState()
}
override fun onConferenceLeft(chatRoom: ChatRoom, eventLog: EventLog) {
updateChatRoomReadOnlyState()
}
}
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
init {
chatRoom.addListener(chatRoomListener)
attachments.value = arrayListOf()
attachFileEnabled.value = true
sendMessageEnabled.value = false
updateChatRoomReadOnlyState()
val recorderParams = coreContext.core.createRecorderParams()
if (corePreferences.voiceMessagesFormatMkv) {
recorderParams.fileFormat = RecorderFileFormat.Mkv
} else {
recorderParams.fileFormat = RecorderFileFormat.Wav
}
recorder = coreContext.core.createRecorder(recorderParams)
}
override fun onCleared() {
pendingChatMessageToReplyTo.value?.destroy()
if (recorder.state != RecorderState.Closed) {
recorder.close()
}
if (this::voiceRecordingPlayer.isInitialized) {
stopVoiceRecordPlayer()
voiceRecordingPlayer.removeListener(playerListener)
}
chatRoom.removeListener(chatRoomListener)
scope.cancel()
super.onCleared()
}
fun onTextToSendChanged(value: String) {
sendMessageEnabled.value = value.trim().isNotEmpty() || attachments.value?.isNotEmpty() == true || isPendingVoiceRecord.value == true
if (value.isNotEmpty()) {
if (attachFileEnabled.value == true && !corePreferences.allowMultipleFilesAndTextInSameMessage) {
attachFileEnabled.value = false
}
chatRoom.compose()
} else {
if (!corePreferences.allowMultipleFilesAndTextInSameMessage) {
attachFileEnabled.value = attachments.value?.isEmpty() ?: true
}
}
}
fun addAttachment(path: String) {
val list = arrayListOf<ChatMessageAttachmentData>()
list.addAll(attachments.value.orEmpty())
list.add(
ChatMessageAttachmentData(path) {
removeAttachment(it)
}
)
attachments.value = list
sendMessageEnabled.value = textToSend.value.orEmpty().trim().isNotEmpty() || list.isNotEmpty() || isPendingVoiceRecord.value == true
if (!corePreferences.allowMultipleFilesAndTextInSameMessage) {
attachFileEnabled.value = false
}
}
private fun removeAttachment(attachment: ChatMessageAttachmentData) {
val list = arrayListOf<ChatMessageAttachmentData>()
list.addAll(attachments.value.orEmpty())
list.remove(attachment)
attachments.value = list
sendMessageEnabled.value = textToSend.value.orEmpty().trim().isNotEmpty() || list.isNotEmpty() || isPendingVoiceRecord.value == true
if (!corePreferences.allowMultipleFilesAndTextInSameMessage) {
attachFileEnabled.value = list.isEmpty()
}
}
fun sendMessage() {
if (!isPlayerClosed()) {
stopVoiceRecordPlayer()
}
val pendingMessageToReplyTo = pendingChatMessageToReplyTo.value
val message: ChatMessage = if (isPendingAnswer.value == true && pendingMessageToReplyTo != null)
chatRoom.createReplyMessage(pendingMessageToReplyTo.chatMessage)
else
chatRoom.createEmptyMessage()
val isBasicChatRoom: Boolean = chatRoom.hasCapability(ChatRoomCapabilities.Basic.toInt())
var voiceRecord = false
if (isPendingVoiceRecord.value == true && recorder.file != null) {
val content = recorder.createContent()
if (content != null) {
Log.i("[Chat Message Sending] Voice recording content created, file name is ${content.name} and duration is ${content.fileDuration}")
message.addContent(content)
voiceRecord = true
} else {
Log.e("[Chat Message Sending] Voice recording content couldn't be created!")
}
isPendingVoiceRecord.value = false
isVoiceRecording.value = false
}
val toSend = textToSend.value.orEmpty().trim()
if (toSend.isNotEmpty()) {
if (voiceRecord && isBasicChatRoom) {
val textMessage: ChatMessage = chatRoom.createMessageFromUtf8(toSend)
textMessage.send()
} else {
message.addUtf8TextContent(toSend)
}
}
var fileContent = false
for (attachment in attachments.value.orEmpty()) {
val content = Factory.instance().createContent()
if (attachment.isImage) {
content.type = "image"
} else {
content.type = "file"
}
content.subtype = FileUtils.getExtensionFromFileName(attachment.fileName)
content.name = attachment.fileName
content.filePath = attachment.path // Let the file body handler take care of the upload
// Do not send file in the same message as the text in a BasicChatRoom
// and don't send multiple files in the same message if setting says so
if (isBasicChatRoom or (corePreferences.preventMoreThanOneFilePerMessage and (fileContent or voiceRecord))) {
val fileMessage: ChatMessage = chatRoom.createFileTransferMessage(content)
fileMessage.send()
} else {
message.addFileContent(content)
fileContent = true
}
}
if (message.contents.isNotEmpty()) {
message.send()
}
cancelReply()
attachments.value = arrayListOf()
textToSend.value = ""
messageSentEvent.value = Event(true)
}
fun transferMessage(chatMessage: ChatMessage) {
val message = chatRoom.createForwardMessage(chatMessage)
message.send()
}
fun cancelReply() {
pendingChatMessageToReplyTo.value?.destroy()
isPendingAnswer.value = false
}
private fun tickerFlowRecording() = flow {
while (recorder.state == RecorderState.Running) {
emit(Unit)
delay(100)
}
}
private fun tickerFlowPlaying() = flow {
while (voiceRecordingPlayer.state == Player.State.Playing) {
emit(Unit)
delay(100)
}
}
fun toggleVoiceRecording() {
if (corePreferences.holdToRecordVoiceMessage) {
// Disables click listener just in case, touch listener will be used instead
return
}
if (isVoiceRecording.value == true) {
stopVoiceRecording()
} else {
startVoiceRecording()
}
}
fun startVoiceRecording() {
if (!PermissionHelper.get().hasRecordAudioPermission()) {
requestRecordAudioPermissionEvent.value = Event(true)
return
}
if (voiceRecordAudioFocusRequest == null) {
voiceRecordAudioFocusRequest = AppUtils.acquireAudioFocusForVoiceRecordingOrPlayback(
coreContext.context
)
}
when (recorder.state) {
RecorderState.Running -> Log.w("[Chat Message Sending] Recorder is already recording")
RecorderState.Paused -> {
Log.w("[Chat Message Sending] Recorder isn't closed, resuming recording")
recorder.start()
}
RecorderState.Closed -> {
val extension = when (recorder.params.fileFormat) {
RecorderFileFormat.Mkv -> "mkv"
else -> "wav"
}
val tempFileName = "voice-recording-${System.currentTimeMillis()}.$extension"
val file = FileUtils.getFileStoragePath(tempFileName)
Log.w("[Chat Message Sending] Recorder is closed, starting recording in ${file.absoluteFile}")
recorder.open(file.absolutePath)
recorder.start()
}
else -> {}
}
val duration = recorder.duration
voiceRecordingDuration.value = duration
formattedDuration.value = SimpleDateFormat("mm:ss", Locale.getDefault()).format(duration) // duration is in ms
isPendingVoiceRecord.value = true
isVoiceRecording.value = true
sendMessageEnabled.value = true
tickerFlowRecording().onEach {
val duration = recorder.duration
voiceRecordingDuration.postValue(recorder.duration % voiceRecordingProgressBarMax)
formattedDuration.postValue(SimpleDateFormat("mm:ss", Locale.getDefault()).format(duration)) // duration is in ms
if (duration >= corePreferences.voiceRecordingMaxDuration) {
withContext(Dispatchers.Main) {
Log.w("[Chat Message Sending] Max duration for voice recording exceeded (${corePreferences.voiceRecordingMaxDuration}ms), stopping.")
stopVoiceRecording()
}
}
}.launchIn(scope)
}
fun cancelVoiceRecording() {
if (recorder.state != RecorderState.Closed) {
Log.i("[Chat Message Sending] Closing voice recorder")
recorder.close()
val path = recorder.file
if (path != null) {
Log.i("[Chat Message Sending] Deleting voice recording file: $path")
FileUtils.deleteFile(path)
}
}
val request = voiceRecordAudioFocusRequest
if (request != null) {
AppUtils.releaseAudioFocusForVoiceRecordingOrPlayback(coreContext.context, request)
voiceRecordAudioFocusRequest = null
}
isPendingVoiceRecord.value = false
isVoiceRecording.value = false
sendMessageEnabled.value = textToSend.value.orEmpty().trim().isNotEmpty() == true || attachments.value?.isNotEmpty() == true
if (!isPlayerClosed()) {
stopVoiceRecordPlayer()
}
}
fun stopVoiceRecording() {
if (recorder.state == RecorderState.Running) {
Log.i("[Chat Message Sending] Pausing / closing voice recorder")
recorder.pause()
recorder.close()
voiceRecordingDuration.value = recorder.duration
}
val request = voiceRecordAudioFocusRequest
if (request != null) {
AppUtils.releaseAudioFocusForVoiceRecordingOrPlayback(coreContext.context, request)
voiceRecordAudioFocusRequest = null
}
isVoiceRecording.value = false
if (corePreferences.sendVoiceRecordingRightAway) {
Log.i("[Chat Message Sending] Sending voice recording right away")
sendMessage()
}
}
fun playRecordedMessage() {
if (isPlayerClosed()) {
Log.w("[Chat Message Sending] 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()
isPlayingVoiceRecording.value = true
tickerFlowPlaying().onEach {
voiceRecordPlayingPosition.postValue(voiceRecordingPlayer.currentPosition)
}.launchIn(scope)
}
fun pauseRecordedMessage() {
Log.i("[Chat Message Sending] Pausing voice record")
if (!isPlayerClosed()) {
voiceRecordingPlayer.pause()
}
val request = voiceRecordAudioFocusRequest
if (request != null) {
AppUtils.releaseAudioFocusForVoiceRecordingOrPlayback(coreContext.context, request)
voiceRecordAudioFocusRequest = null
}
isPlayingVoiceRecording.value = false
}
private fun initVoiceRecordPlayer() {
Log.i("[Chat Message Sending] Creating player for voice record")
// In case no headphones/headset is connected, use speaker sound card to play recordings, otherwise use earpiece
// If none are available, default one will be used
var headphonesCard: String? = null
var speakerCard: String? = null
var earpieceCard: String? = null
for (device in coreContext.core.audioDevices) {
if (device.hasCapability(AudioDevice.Capabilities.CapabilityPlay)) {
when (device.type) {
AudioDevice.Type.Speaker -> {
speakerCard = device.id
}
AudioDevice.Type.Earpiece -> {
earpieceCard = device.id
}
AudioDevice.Type.Headphones, AudioDevice.Type.Headset -> {
headphonesCard = device.id
}
else -> {}
}
}
}
Log.i("[Chat Message Sending] Found headset/headphones sound card [$headphonesCard], speaker sound card [$speakerCard] and earpiece sound card [$earpieceCard]")
val localPlayer = coreContext.core.createLocalPlayer(headphonesCard ?: speakerCard ?: earpieceCard, null, null)
if (localPlayer != null) {
voiceRecordingPlayer = localPlayer
} else {
Log.e("[Chat Message Sending] Couldn't create local player!")
return
}
voiceRecordingPlayer.addListener(playerListener)
val path = recorder.file
if (path != null) {
voiceRecordingPlayer.open(path)
// Update recording duration using player value to ensure proper progress bar animation
voiceRecordingDuration.value = voiceRecordingPlayer.duration
}
}
private fun stopVoiceRecordPlayer() {
if (!isPlayerClosed()) {
Log.i("[Chat Message Sending] Stopping voice record")
voiceRecordingPlayer.pause()
voiceRecordingPlayer.seek(0)
voiceRecordPlayingPosition.value = 0
voiceRecordingPlayer.close()
}
val request = voiceRecordAudioFocusRequest
if (request != null) {
AppUtils.releaseAudioFocusForVoiceRecordingOrPlayback(coreContext.context, request)
voiceRecordAudioFocusRequest = null
}
isPlayingVoiceRecording.value = false
}
private fun isPlayerClosed(): Boolean {
return !this::voiceRecordingPlayer.isInitialized || voiceRecordingPlayer.state == Player.State.Closed
}
private fun updateChatRoomReadOnlyState() {
isReadOnly.value = chatRoom.isReadOnly || (chatRoom.hasCapability(ChatRoomCapabilities.Conference.toInt()) && chatRoom.participants.isEmpty())
}
}

View File

@ -0,0 +1,254 @@
/*
* 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.viewmodels
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import java.util.*
import kotlin.math.max
import org.linphone.activities.main.chat.data.EventLogData
import org.linphone.core.*
import org.linphone.core.tools.Log
import org.linphone.utils.Event
import org.linphone.utils.LinphoneUtils
import org.linphone.utils.PermissionHelper
class ChatMessagesListViewModelFactory(private val chatRoom: ChatRoom) :
ViewModelProvider.NewInstanceFactory() {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return ChatMessagesListViewModel(chatRoom) as T
}
}
class ChatMessagesListViewModel(private val chatRoom: ChatRoom) : ViewModel() {
companion object {
private const val MESSAGES_PER_PAGE = 20
}
val events = MutableLiveData<ArrayList<EventLogData>>()
val messageUpdatedEvent: MutableLiveData<Event<Int>> by lazy {
MutableLiveData<Event<Int>>()
}
val requestWriteExternalStoragePermissionEvent: MutableLiveData<Event<Boolean>> by lazy {
MutableLiveData<Event<Boolean>>()
}
private val chatRoomListener: ChatRoomListenerStub = object : ChatRoomListenerStub() {
override fun onChatMessagesReceived(chatRoom: ChatRoom, eventLogs: Array<out EventLog>) {
for (eventLog in eventLogs) {
addChatMessageEventLog(eventLog)
}
}
override fun onChatMessageSending(chatRoom: ChatRoom, eventLog: EventLog) {
val position = events.value.orEmpty().size
if (eventLog.type == EventLog.Type.ConferenceChatMessage) {
val chatMessage = eventLog.chatMessage
chatMessage ?: return
chatMessage.userData = position
}
addEvent(eventLog)
}
override fun onSecurityEvent(chatRoom: ChatRoom, eventLog: EventLog) {
addEvent(eventLog)
}
override fun onParticipantAdded(chatRoom: ChatRoom, eventLog: EventLog) {
addEvent(eventLog)
}
override fun onParticipantRemoved(chatRoom: ChatRoom, eventLog: EventLog) {
addEvent(eventLog)
}
override fun onParticipantAdminStatusChanged(chatRoom: ChatRoom, eventLog: EventLog) {
addEvent(eventLog)
}
override fun onSubjectChanged(chatRoom: ChatRoom, eventLog: EventLog) {
addEvent(eventLog)
}
override fun onConferenceJoined(chatRoom: ChatRoom, eventLog: EventLog) {
if (!chatRoom.hasCapability(ChatRoomCapabilities.OneToOne.toInt())) {
addEvent(eventLog)
}
}
override fun onConferenceLeft(chatRoom: ChatRoom, eventLog: EventLog) {
if (!chatRoom.hasCapability(ChatRoomCapabilities.OneToOne.toInt())) {
addEvent(eventLog)
}
}
override fun onEphemeralMessageDeleted(chatRoom: ChatRoom, eventLog: EventLog) {
Log.i("[Chat Messages] An ephemeral chat message has expired, removing it from event list")
deleteEvent(eventLog)
}
override fun onEphemeralEvent(chatRoom: ChatRoom, eventLog: EventLog) {
addEvent(eventLog)
}
}
init {
chatRoom.addListener(chatRoomListener)
events.value = getEvents()
}
override fun onCleared() {
events.value.orEmpty().forEach(EventLogData::destroy)
chatRoom.removeListener(chatRoomListener)
super.onCleared()
}
fun resendMessage(chatMessage: ChatMessage) {
val position: Int = chatMessage.userData as Int
chatMessage.send()
messageUpdatedEvent.value = Event(position)
}
fun deleteMessage(chatMessage: ChatMessage) {
LinphoneUtils.deleteFilesAttachedToChatMessage(chatMessage)
chatRoom.deleteMessage(chatMessage)
events.value.orEmpty().forEach(EventLogData::destroy)
events.value = getEvents()
}
fun deleteEventLogs(listToDelete: ArrayList<EventLogData>) {
for (eventLog in listToDelete) {
LinphoneUtils.deleteFilesAttachedToEventLog(eventLog.eventLog)
eventLog.eventLog.deleteFromDatabase()
}
events.value.orEmpty().forEach(EventLogData::destroy)
events.value = getEvents()
}
fun loadMoreData(totalItemsCount: Int) {
Log.i("[Chat Messages] Load more data, current total is $totalItemsCount")
val maxSize: Int = chatRoom.historyEventsSize
if (totalItemsCount < maxSize) {
var upperBound: Int = totalItemsCount + MESSAGES_PER_PAGE
if (upperBound > maxSize) {
upperBound = maxSize
}
val history: Array<EventLog> = chatRoom.getHistoryRangeEvents(totalItemsCount, upperBound)
val list = arrayListOf<EventLogData>()
for (eventLog in history) {
list.add(EventLogData(eventLog))
}
list.addAll(events.value.orEmpty())
events.value = list
}
}
private fun addEvent(eventLog: EventLog) {
val list = arrayListOf<EventLogData>()
list.addAll(events.value.orEmpty())
val found = list.find { data -> data.eventLog == eventLog }
if (found == null) {
list.add(EventLogData(eventLog))
}
events.value = list
}
private fun getEvents(): ArrayList<EventLogData> {
val list = arrayListOf<EventLogData>()
val unreadCount = chatRoom.unreadMessagesCount
var loadCount = max(MESSAGES_PER_PAGE, unreadCount)
Log.i("[Chat Messages] $unreadCount unread messages in this chat room, loading $loadCount from history")
val history = chatRoom.getHistoryEvents(loadCount)
var messageCount = 0
for (eventLog in history) {
list.add(EventLogData(eventLog))
if (eventLog.type == EventLog.Type.ConferenceChatMessage) {
messageCount += 1
}
}
// Load enough events to have at least all unread messages
while (unreadCount > 0 && messageCount < unreadCount) {
Log.w("[Chat Messages] There is only $messageCount messages in the last $loadCount events, loading $MESSAGES_PER_PAGE more")
val moreHistory = chatRoom.getHistoryRangeEvents(loadCount, loadCount + MESSAGES_PER_PAGE)
loadCount += MESSAGES_PER_PAGE
for (eventLog in moreHistory) {
list.add(EventLogData(eventLog))
if (eventLog.type == EventLog.Type.ConferenceChatMessage) {
messageCount += 1
}
}
}
return list
}
private fun deleteEvent(eventLog: EventLog) {
val chatMessage = eventLog.chatMessage
if (chatMessage != null) {
LinphoneUtils.deleteFilesAttachedToChatMessage(chatMessage)
chatRoom.deleteMessage(chatMessage)
}
events.value.orEmpty().forEach(EventLogData::destroy)
events.value = getEvents()
}
private fun addChatMessageEventLog(eventLog: EventLog) {
if (eventLog.type == EventLog.Type.ConferenceChatMessage) {
val chatMessage = eventLog.chatMessage
chatMessage ?: return
chatMessage.userData = events.value.orEmpty().size
val existingEvent = events.value.orEmpty().find { data ->
data.eventLog.type == EventLog.Type.ConferenceChatMessage && data.eventLog.chatMessage?.messageId == chatMessage.messageId
}
if (existingEvent != null) {
Log.w("[Chat Messages] Found already present chat message, don't add it it's probably the result of an auto download or an aggregated message received before but notified after the conversation was displayed")
return
}
if (!PermissionHelper.get().hasWriteExternalStoragePermission()) {
for (content in chatMessage.contents) {
if (content.isFileTransfer) {
Log.i("[Chat Messages] Android < 10 detected and WRITE_EXTERNAL_STORAGE permission isn't granted yet")
requestWriteExternalStoragePermissionEvent.value = Event(true)
}
}
}
}
addEvent(eventLog)
}
}

View File

@ -0,0 +1,139 @@
/*
* 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.viewmodels
import androidx.lifecycle.MutableLiveData
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.LinphoneApplication.Companion.corePreferences
import org.linphone.R
import org.linphone.contact.ContactsSelectionViewModel
import org.linphone.core.*
import org.linphone.core.tools.Log
import org.linphone.utils.AppUtils
import org.linphone.utils.Event
import org.linphone.utils.LinphoneUtils
class ChatRoomCreationViewModel : ContactsSelectionViewModel() {
val chatRoomCreatedEvent: MutableLiveData<Event<ChatRoom>> by lazy {
MutableLiveData<Event<ChatRoom>>()
}
val createGroupChat = MutableLiveData<Boolean>()
val isEncrypted = MutableLiveData<Boolean>()
val waitForChatRoomCreation = MutableLiveData<Boolean>()
val secureChatAvailable = MutableLiveData<Boolean>()
val secureChatMandatory: Boolean = corePreferences.forceEndToEndEncryptedChat
private val listener = object : ChatRoomListenerStub() {
override fun onStateChanged(room: ChatRoom, state: ChatRoom.State) {
if (state == ChatRoom.State.Created) {
waitForChatRoomCreation.value = false
Log.i("[Chat Room Creation] Chat room created")
chatRoomCreatedEvent.value = Event(room)
} else if (state == ChatRoom.State.CreationFailed) {
Log.e("[Chat Room Creation] Group chat room creation has failed !")
waitForChatRoomCreation.value = false
onMessageToNotifyEvent.value = Event(R.string.chat_room_creation_failed_snack)
}
}
}
init {
createGroupChat.value = false
isEncrypted.value = secureChatMandatory
waitForChatRoomCreation.value = false
secureChatAvailable.value = LinphoneUtils.isEndToEndEncryptedChatAvailable()
}
fun updateEncryption(encrypted: Boolean) {
if (!encrypted && secureChatMandatory) {
Log.w("[Chat Room Creation] Something tries to force plain text chat room even if secureChatMandatory is enabled!")
return
}
isEncrypted.value = encrypted
}
fun createOneToOneChat(searchResult: SearchResult) {
waitForChatRoomCreation.value = true
val defaultAccount = coreContext.core.defaultAccount
var room: ChatRoom?
val address = searchResult.address ?: coreContext.core.interpretUrl(searchResult.phoneNumber ?: "", LinphoneUtils.applyInternationalPrefix())
if (address == null) {
Log.e("[Chat Room Creation] Can't get a valid address from search result $searchResult")
onMessageToNotifyEvent.value = Event(R.string.chat_room_creation_failed_snack)
waitForChatRoomCreation.value = false
return
}
val encrypted = secureChatMandatory || isEncrypted.value == true
val params: ChatRoomParams = coreContext.core.createDefaultChatRoomParams()
params.backend = ChatRoomBackend.Basic
params.isGroupEnabled = false
if (encrypted) {
params.isEncryptionEnabled = true
params.backend = ChatRoomBackend.FlexisipChat
params.ephemeralMode = if (corePreferences.useEphemeralPerDeviceMode)
ChatRoomEphemeralMode.DeviceManaged
else
ChatRoomEphemeralMode.AdminManaged
params.ephemeralLifetime = 0 // Make sure ephemeral is disabled by default
Log.i("[Chat Room Creation] Ephemeral mode is ${params.ephemeralMode}, lifetime is ${params.ephemeralLifetime}")
params.subject = AppUtils.getString(R.string.chat_room_dummy_subject)
}
val participants = arrayOf(address)
val localAddress: Address? = defaultAccount?.params?.identityAddress
room = coreContext.core.searchChatRoom(params, localAddress, null, participants)
if (room == null) {
Log.w("[Chat Room Creation] Couldn't find existing 1-1 chat room with remote ${address.asStringUriOnly()}, encryption=$encrypted and local identity ${localAddress?.asStringUriOnly()}")
room = coreContext.core.createChatRoom(params, localAddress, participants)
if (room != null) {
if (encrypted) {
val state = room.state
if (state == ChatRoom.State.Created) {
Log.i("[Chat Room Creation] Found already created chat room, using it")
chatRoomCreatedEvent.value = Event(room)
waitForChatRoomCreation.value = false
} else {
Log.i("[Chat Room Creation] Chat room creation is pending [$state], waiting for Created state")
room.addListener(listener)
}
} else {
chatRoomCreatedEvent.value = Event(room)
waitForChatRoomCreation.value = false
}
} else {
Log.e("[Chat Room Creation] Couldn't create chat room with remote ${address.asStringUriOnly()} and local identity ${localAddress?.asStringUriOnly()}")
waitForChatRoomCreation.value = false
}
} else {
Log.i("[Chat Room Creation] Found existing 1-1 chat room with remote ${address.asStringUriOnly()}, encryption=$encrypted and local identity ${localAddress?.asStringUriOnly()}")
chatRoomCreatedEvent.value = Event(room)
waitForChatRoomCreation.value = false
}
}
}

View File

@ -0,0 +1,382 @@
/*
* 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.viewmodels
import android.animation.ValueAnimator
import android.view.animation.LinearInterpolator
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.*
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.LinphoneApplication.Companion.corePreferences
import org.linphone.R
import org.linphone.contact.ContactDataInterface
import org.linphone.contact.ContactsUpdatedListenerStub
import org.linphone.core.*
import org.linphone.core.tools.Log
import org.linphone.utils.AppUtils
import org.linphone.utils.LinphoneUtils
class ChatRoomViewModelFactory(private val chatRoom: ChatRoom) :
ViewModelProvider.NewInstanceFactory() {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return ChatRoomViewModel(chatRoom) as T
}
}
class ChatRoomViewModel(val chatRoom: ChatRoom) : ViewModel(), ContactDataInterface {
override val contact: MutableLiveData<Friend> = MutableLiveData<Friend>()
override val displayName: MutableLiveData<String> = MutableLiveData<String>()
override val securityLevel: MutableLiveData<ChatRoomSecurityLevel> = MutableLiveData<ChatRoomSecurityLevel>()
override val showGroupChatAvatar: Boolean
get() = conferenceChatRoom && !oneToOneChatRoom
override val coroutineScope: CoroutineScope = viewModelScope
val subject = MutableLiveData<String>()
val participants = MutableLiveData<String>()
val unreadMessagesCount = MutableLiveData<Int>()
val remoteIsComposing = MutableLiveData<Boolean>()
val composingList = MutableLiveData<String>()
val securityLevelIcon = MutableLiveData<Int>()
val securityLevelContentDescription = MutableLiveData<Int>()
val peerSipUri = MutableLiveData<String>()
val ephemeralEnabled = MutableLiveData<Boolean>()
val basicChatRoom: Boolean by lazy {
chatRoom.hasCapability(ChatRoomCapabilities.Basic.toInt())
}
val oneToOneChatRoom: Boolean by lazy {
chatRoom.hasCapability(ChatRoomCapabilities.OneToOne.toInt())
}
private val conferenceChatRoom: Boolean by lazy {
chatRoom.hasCapability(ChatRoomCapabilities.Conference.toInt())
}
val encryptedChatRoom: Boolean by lazy {
chatRoom.hasCapability(ChatRoomCapabilities.Encrypted.toInt())
}
val ephemeralChatRoom: Boolean by lazy {
chatRoom.hasCapability(ChatRoomCapabilities.Ephemeral.toInt())
}
val meAdmin: MutableLiveData<Boolean> by lazy {
MutableLiveData<Boolean>()
}
val isUserScrollingUp = MutableLiveData<Boolean>()
var oneParticipantOneDevice: Boolean = false
var onlyParticipantOnlyDeviceAddress: Address? = null
val chatUnreadCountTranslateY = MutableLiveData<Float>()
val groupCallAvailable: Boolean
get() = LinphoneUtils.isRemoteConferencingAvailable()
private var addressToCall: Address? = null
private val bounceAnimator: ValueAnimator by lazy {
ValueAnimator.ofFloat(AppUtils.getDimension(R.dimen.tabs_fragment_unread_count_bounce_offset), 0f).apply {
addUpdateListener {
val value = it.animatedValue as Float
chatUnreadCountTranslateY.value = value
}
interpolator = LinearInterpolator()
duration = 250
repeatMode = ValueAnimator.REVERSE
repeatCount = ValueAnimator.INFINITE
}
}
private val contactsUpdatedListener = object : ContactsUpdatedListenerStub() {
override fun onContactsUpdated() {
Log.d("[Chat Room] Contacts have changed")
contactLookup()
}
}
private val coreListener: CoreListenerStub = object : CoreListenerStub() {
override fun onChatRoomRead(core: Core, room: ChatRoom) {
if (room == chatRoom) {
updateUnreadMessageCount()
}
}
}
private val chatRoomListener: ChatRoomListenerStub = object : ChatRoomListenerStub() {
override fun onStateChanged(chatRoom: ChatRoom, state: ChatRoom.State) {
Log.i("[Chat Room] $chatRoom state changed: $state")
if (state == ChatRoom.State.Created) {
contactLookup()
updateSecurityIcon()
subject.value = chatRoom.subject
}
}
override fun onSubjectChanged(chatRoom: ChatRoom, eventLog: EventLog) {
subject.value = chatRoom.subject
}
override fun onChatMessagesReceived(chatRoom: ChatRoom, eventLogs: Array<out EventLog>) {
updateUnreadMessageCount()
}
override fun onParticipantAdded(chatRoom: ChatRoom, eventLog: EventLog) {
contactLookup()
updateSecurityIcon()
updateParticipants()
}
override fun onParticipantRemoved(chatRoom: ChatRoom, eventLog: EventLog) {
contactLookup()
updateSecurityIcon()
updateParticipants()
}
override fun onIsComposingReceived(
chatRoom: ChatRoom,
remoteAddr: Address,
isComposing: Boolean
) {
updateRemotesComposing()
}
override fun onConferenceJoined(chatRoom: ChatRoom, eventLog: EventLog) {
contactLookup()
updateSecurityIcon()
subject.value = chatRoom.subject
}
override fun onSecurityEvent(chatRoom: ChatRoom, eventLog: EventLog) {
updateSecurityIcon()
}
override fun onParticipantDeviceAdded(chatRoom: ChatRoom, eventLog: EventLog) {
updateSecurityIcon()
updateParticipants()
}
override fun onParticipantDeviceRemoved(chatRoom: ChatRoom, eventLog: EventLog) {
updateSecurityIcon()
updateParticipants()
}
override fun onEphemeralEvent(chatRoom: ChatRoom, eventLog: EventLog) {
ephemeralEnabled.value = chatRoom.isEphemeralEnabled
}
override fun onParticipantAdminStatusChanged(chatRoom: ChatRoom, eventLog: EventLog) {
meAdmin.value = chatRoom.me?.isAdmin ?: false
}
}
init {
chatRoom.core.addListener(coreListener)
chatRoom.addListener(chatRoomListener)
coreContext.contactsManager.addListener(contactsUpdatedListener)
updateUnreadMessageCount()
subject.value = chatRoom.subject
updateSecurityIcon()
meAdmin.value = chatRoom.me?.isAdmin ?: false
ephemeralEnabled.value = chatRoom.isEphemeralEnabled
contactLookup()
updateParticipants()
updateRemotesComposing()
}
override fun onCleared() {
coreContext.contactsManager.removeListener(contactsUpdatedListener)
chatRoom.removeListener(chatRoomListener)
chatRoom.core.removeListener(coreListener)
if (corePreferences.enableAnimations) bounceAnimator.end()
super.onCleared()
}
fun contactLookup() {
displayName.value = when {
basicChatRoom -> LinphoneUtils.getDisplayName(
chatRoom.peerAddress
)
oneToOneChatRoom -> LinphoneUtils.getDisplayName(
chatRoom.participants.firstOrNull()?.address ?: chatRoom.peerAddress
)
conferenceChatRoom -> chatRoom.subject.orEmpty()
else -> chatRoom.peerAddress.asStringUriOnly()
}
if (oneToOneChatRoom) {
searchMatchingContact()
} else {
getParticipantsNames()
}
}
fun startCall() {
val address = addressToCall
if (address != null) {
coreContext.startCall(address)
}
}
fun startGroupCall() {
val conferenceScheduler = coreContext.core.createConferenceScheduler()
val conferenceInfo = Factory.instance().createConferenceInfo()
val localAddress = chatRoom.localAddress.clone()
localAddress.clean() // Remove GRUU
val addresses = Array(chatRoom.participants.size) {
index ->
chatRoom.participants[index].address
}
val localAccount = coreContext.core.accountList.find {
account ->
account.params.identityAddress?.weakEqual(localAddress) ?: false
}
conferenceInfo.organizer = localAddress
conferenceInfo.subject = subject.value
conferenceInfo.setParticipants(addresses)
conferenceScheduler.account = localAccount
// Will trigger the conference creation/update automatically
conferenceScheduler.info = conferenceInfo
}
fun areNotificationsMuted(): Boolean {
val id = LinphoneUtils.getChatRoomId(chatRoom.localAddress, chatRoom.peerAddress)
return corePreferences.chatRoomMuted(id)
}
fun muteNotifications(mute: Boolean) {
val id = LinphoneUtils.getChatRoomId(chatRoom.localAddress, chatRoom.peerAddress)
corePreferences.muteChatRoom(id, mute)
}
fun getRemoteAddress(): Address? {
return if (basicChatRoom) {
chatRoom.peerAddress
} else {
if (chatRoom.participants.isNotEmpty()) {
chatRoom.participants[0].address
} else {
Log.e("[Chat Room] ${chatRoom.peerAddress} doesn't have any participant (state ${chatRoom.state})!")
null
}
}
}
private fun searchMatchingContact() {
val remoteAddress = getRemoteAddress()
if (remoteAddress != null) {
contact.value = coreContext.contactsManager.findContactByAddress(remoteAddress)
}
}
private fun getParticipantsNames() {
if (oneToOneChatRoom) return
var participantsList = ""
var index = 0
for (participant in chatRoom.participants) {
val contact = coreContext.contactsManager.findContactByAddress(participant.address)
participantsList += contact?.name ?: LinphoneUtils.getDisplayName(participant.address)
index++
if (index != chatRoom.nbParticipants) participantsList += ", "
}
participants.value = participantsList
}
private fun updateSecurityIcon() {
val level = chatRoom.securityLevel
securityLevel.value = level
securityLevelIcon.value = when (level) {
ChatRoomSecurityLevel.Safe -> R.drawable.security_2_indicator
ChatRoomSecurityLevel.Encrypted -> R.drawable.security_1_indicator
else -> R.drawable.security_alert_indicator
}
securityLevelContentDescription.value = when (level) {
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
}
}
private fun updateRemotesComposing() {
val isComposing = chatRoom.isRemoteComposing
remoteIsComposing.value = isComposing
if (!isComposing) return
var composing = ""
for (address in chatRoom.composingAddresses) {
val contact = coreContext.contactsManager.findContactByAddress(address)
composing += if (composing.isNotEmpty()) ", " else ""
composing += contact?.name ?: LinphoneUtils.getDisplayName(address)
}
composingList.value = AppUtils.getStringWithPlural(R.plurals.chat_room_remote_composing, chatRoom.composingAddresses.size, composing)
}
private fun updateParticipants() {
val participants = chatRoom.participants
peerSipUri.value = if (oneToOneChatRoom && !basicChatRoom) {
participants.firstOrNull()?.address?.asStringUriOnly()
?: chatRoom.peerAddress.asStringUriOnly()
} else {
chatRoom.peerAddress.asStringUriOnly()
}
oneParticipantOneDevice = oneToOneChatRoom &&
chatRoom.me?.devices?.size == 1 &&
participants.firstOrNull()?.devices?.size == 1
addressToCall = if (basicChatRoom)
chatRoom.peerAddress
else
participants.firstOrNull()?.address
onlyParticipantOnlyDeviceAddress = participants.firstOrNull()?.devices?.firstOrNull()?.address
}
private fun updateUnreadMessageCount() {
val count = chatRoom.unreadMessagesCount
unreadMessagesCount.value = count
if (count > 0 && corePreferences.enableAnimations) bounceAnimator.start()
else if (count == 0 && bounceAnimator.isStarted) bounceAnimator.end()
}
}

View File

@ -0,0 +1,176 @@
/*
* 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.viewmodels
import androidx.lifecycle.MutableLiveData
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.R
import org.linphone.activities.main.viewmodels.MessageNotifierViewModel
import org.linphone.compatibility.Compatibility
import org.linphone.contact.ContactsUpdatedListenerStub
import org.linphone.core.*
import org.linphone.core.tools.Log
import org.linphone.utils.Event
import org.linphone.utils.LinphoneUtils
class ChatRoomsListViewModel : MessageNotifierViewModel() {
val chatRooms = MutableLiveData<ArrayList<ChatRoom>>()
val fileSharingPending = MutableLiveData<Boolean>()
val textSharingPending = MutableLiveData<Boolean>()
val forwardPending = MutableLiveData<Boolean>()
val groupChatAvailable = MutableLiveData<Boolean>()
val chatRoomIndexUpdatedEvent: MutableLiveData<Event<Int>> by lazy {
MutableLiveData<Event<Int>>()
}
private val listener: CoreListenerStub = object : CoreListenerStub() {
override fun onChatRoomStateChanged(core: Core, chatRoom: ChatRoom, state: ChatRoom.State) {
if (state == ChatRoom.State.Created) {
updateChatRooms()
} else if (state == ChatRoom.State.TerminationFailed) {
Log.e("[Chat Rooms] Group chat room removal for address ${chatRoom.peerAddress.asStringUriOnly()} has failed !")
onMessageToNotifyEvent.value = Event(R.string.chat_room_removal_failed_snack)
}
}
override fun onMessageSent(core: Core, chatRoom: ChatRoom, message: ChatMessage) {
onChatRoomMessageEvent(chatRoom)
}
override fun onMessagesReceived(
core: Core,
chatRoom: ChatRoom,
messages: Array<out ChatMessage>
) {
onChatRoomMessageEvent(chatRoom)
}
override fun onChatRoomRead(core: Core, chatRoom: ChatRoom) {
notifyChatRoomUpdate(chatRoom)
}
override fun onChatRoomEphemeralMessageDeleted(core: Core, chatRoom: ChatRoom) {
notifyChatRoomUpdate(chatRoom)
}
override fun onChatRoomSubjectChanged(core: Core, chatRoom: ChatRoom) {
notifyChatRoomUpdate(chatRoom)
}
}
private val chatRoomListener = object : ChatRoomListenerStub() {
override fun onStateChanged(chatRoom: ChatRoom, newState: ChatRoom.State) {
if (newState == ChatRoom.State.Deleted) {
updateChatRooms()
}
}
}
private val contactsListener = object : ContactsUpdatedListenerStub() {
override fun onContactsUpdated() {
updateChatRooms()
}
}
private var chatRoomsToDeleteCount = 0
init {
groupChatAvailable.value = LinphoneUtils.isGroupChatAvailable()
updateChatRooms()
coreContext.core.addListener(listener)
coreContext.contactsManager.addListener(contactsListener)
}
override fun onCleared() {
coreContext.contactsManager.removeListener(contactsListener)
coreContext.core.removeListener(listener)
super.onCleared()
}
fun deleteChatRoom(chatRoom: ChatRoom?) {
for (eventLog in chatRoom?.getHistoryMessageEvents(0).orEmpty()) {
LinphoneUtils.deleteFilesAttachedToEventLog(eventLog)
}
chatRoomsToDeleteCount = 1
if (chatRoom != null) {
coreContext.notificationsManager.dismissChatNotification(chatRoom)
Compatibility.removeChatRoomShortcut(coreContext.context, chatRoom)
chatRoom.addListener(chatRoomListener)
coreContext.core.deleteChatRoom(chatRoom)
}
}
fun deleteChatRooms(chatRooms: ArrayList<ChatRoom>) {
chatRoomsToDeleteCount = chatRooms.size
for (chatRoom in chatRooms) {
for (eventLog in chatRoom.getHistoryMessageEvents(0)) {
LinphoneUtils.deleteFilesAttachedToEventLog(eventLog)
}
coreContext.notificationsManager.dismissChatNotification(chatRoom)
Compatibility.removeChatRoomShortcut(coreContext.context, chatRoom)
chatRoom.addListener(chatRoomListener)
chatRoom.core.deleteChatRoom(chatRoom)
}
}
fun updateChatRooms() {
val list = arrayListOf<ChatRoom>()
list.addAll(coreContext.core.chatRooms)
chatRooms.value = list
}
fun notifyChatRoomUpdate(chatRoom: ChatRoom) {
val index = findChatRoomIndex(chatRoom)
if (index == -1) updateChatRooms()
else chatRoomIndexUpdatedEvent.value = Event(index)
}
private fun reorderChatRooms() {
val list = arrayListOf<ChatRoom>()
list.addAll(chatRooms.value.orEmpty())
list.sortByDescending { chatRoom -> chatRoom.lastUpdateTime }
chatRooms.value = list
}
private fun findChatRoomIndex(chatRoom: ChatRoom): Int {
for ((index, cr) in chatRooms.value.orEmpty().withIndex()) {
if (LinphoneUtils.areChatRoomsTheSame(cr, chatRoom)) {
return index
}
}
return -1
}
private fun onChatRoomMessageEvent(chatRoom: ChatRoom) {
when (findChatRoomIndex(chatRoom)) {
-1 -> updateChatRooms()
0 -> chatRoomIndexUpdatedEvent.value = Event(0)
else -> reorderChatRooms()
}
}
}

View File

@ -0,0 +1,83 @@
/*
* 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.viewmodels
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import org.linphone.activities.main.chat.data.DevicesListGroupData
import org.linphone.core.ChatRoom
import org.linphone.core.ChatRoomListenerStub
import org.linphone.core.EventLog
class DevicesListViewModelFactory(private val chatRoom: ChatRoom) :
ViewModelProvider.NewInstanceFactory() {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return DevicesListViewModel(chatRoom) as T
}
}
class DevicesListViewModel(private val chatRoom: ChatRoom) : ViewModel() {
val participants = MutableLiveData<ArrayList<DevicesListGroupData>>()
private val listener = object : ChatRoomListenerStub() {
override fun onParticipantDeviceAdded(chatRoom: ChatRoom, eventLog: EventLog) {
updateParticipants()
}
override fun onParticipantDeviceRemoved(chatRoom: ChatRoom, eventLog: EventLog) {
updateParticipants()
}
override fun onParticipantAdded(chatRoom: ChatRoom, eventLog: EventLog) {
updateParticipants()
}
override fun onParticipantRemoved(chatRoom: ChatRoom, eventLog: EventLog) {
updateParticipants()
}
}
init {
chatRoom.addListener(listener)
}
override fun onCleared() {
participants.value.orEmpty().forEach(DevicesListGroupData::destroy)
chatRoom.removeListener(listener)
super.onCleared()
}
fun updateParticipants() {
participants.value.orEmpty().forEach(DevicesListGroupData::destroy)
val list = arrayListOf<DevicesListGroupData>()
val me = chatRoom.me
if (me != null) list.add(DevicesListGroupData(me))
for (participant in chatRoom.participants) {
list.add(DevicesListGroupData(participant))
}
participants.value = list
}
}

View File

@ -0,0 +1,88 @@
/*
* 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.viewmodels
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import org.linphone.R
import org.linphone.activities.main.chat.data.DurationItemClicked
import org.linphone.activities.main.chat.data.EphemeralDurationData
import org.linphone.core.ChatRoom
import org.linphone.core.tools.Log
class EphemeralViewModelFactory(private val chatRoom: ChatRoom) :
ViewModelProvider.NewInstanceFactory() {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return EphemeralViewModel(chatRoom) as T
}
}
class EphemeralViewModel(private val chatRoom: ChatRoom) : ViewModel() {
val durationsList = MutableLiveData<ArrayList<EphemeralDurationData>>()
var currentSelectedDuration: Long = 0
private val listener = object : DurationItemClicked {
override fun onDurationValueChanged(duration: Long) {
currentSelectedDuration = duration
computeEphemeralDurationValues()
}
}
init {
Log.i("[Ephemeral Messages] Current lifetime is ${chatRoom.ephemeralLifetime}, ephemeral enabled? ${chatRoom.isEphemeralEnabled}")
currentSelectedDuration = if (chatRoom.isEphemeralEnabled) chatRoom.ephemeralLifetime else 0
computeEphemeralDurationValues()
}
fun updateChatRoomEphemeralDuration() {
Log.i("[Ephemeral Messages] Selected value is $currentSelectedDuration")
if (currentSelectedDuration > 0) {
if (chatRoom.ephemeralLifetime != currentSelectedDuration) {
Log.i("[Ephemeral Messages] Setting new lifetime for ephemeral messages to $currentSelectedDuration")
chatRoom.ephemeralLifetime = currentSelectedDuration
} else {
Log.i("[Ephemeral Messages] Configured lifetime for ephemeral messages was already $currentSelectedDuration")
}
if (!chatRoom.isEphemeralEnabled) {
Log.i("[Ephemeral Messages] Ephemeral messages were disabled, enable them")
chatRoom.isEphemeralEnabled = true
}
} else if (chatRoom.isEphemeralEnabled) {
Log.i("[Ephemeral Messages] Ephemeral messages were enabled, disable them")
chatRoom.isEphemeralEnabled = false
}
}
private fun computeEphemeralDurationValues() {
val list = arrayListOf<EphemeralDurationData>()
list.add(EphemeralDurationData(R.string.chat_room_ephemeral_message_disabled, currentSelectedDuration, 0, listener))
list.add(EphemeralDurationData(R.string.chat_room_ephemeral_message_one_minute, currentSelectedDuration, 60, listener))
list.add(EphemeralDurationData(R.string.chat_room_ephemeral_message_one_hour, currentSelectedDuration, 3600, listener))
list.add(EphemeralDurationData(R.string.chat_room_ephemeral_message_one_day, currentSelectedDuration, 86400, listener))
list.add(EphemeralDurationData(R.string.chat_room_ephemeral_message_three_days, currentSelectedDuration, 259200, listener))
list.add(EphemeralDurationData(R.string.chat_room_ephemeral_message_one_week, currentSelectedDuration, 604800, listener))
durationsList.value = list
}
}

View File

@ -0,0 +1,234 @@
/*
* 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.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.R
import org.linphone.activities.main.chat.GroupChatRoomMember
import org.linphone.activities.main.chat.data.GroupInfoParticipantData
import org.linphone.activities.main.viewmodels.MessageNotifierViewModel
import org.linphone.core.*
import org.linphone.core.tools.Log
import org.linphone.utils.Event
class GroupInfoViewModelFactory(private val chatRoom: ChatRoom?) :
ViewModelProvider.NewInstanceFactory() {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return GroupInfoViewModel(chatRoom) as T
}
}
class GroupInfoViewModel(val chatRoom: ChatRoom?) : MessageNotifierViewModel() {
val createdChatRoomEvent = MutableLiveData<Event<ChatRoom>>()
val updatedChatRoomEvent = MutableLiveData<Event<ChatRoom>>()
val subject = MutableLiveData<String>()
val participants = MutableLiveData<ArrayList<GroupInfoParticipantData>>()
val isEncrypted = MutableLiveData<Boolean>()
val isMeAdmin = MutableLiveData<Boolean>()
val canLeaveGroup = MutableLiveData<Boolean>()
val waitForChatRoomCreation = MutableLiveData<Boolean>()
val meAdminChangedEvent: MutableLiveData<Event<Boolean>> by lazy {
MutableLiveData<Event<Boolean>>()
}
private val listener = object : ChatRoomListenerStub() {
override fun onStateChanged(chatRoom: ChatRoom, state: ChatRoom.State) {
if (state == ChatRoom.State.Created) {
waitForChatRoomCreation.value = false
createdChatRoomEvent.value = Event(chatRoom) // To trigger going to the chat room
} else if (state == ChatRoom.State.CreationFailed) {
Log.e("[Chat Room Group Info] Group chat room creation has failed !")
waitForChatRoomCreation.value = false
onMessageToNotifyEvent.value = Event(R.string.chat_room_creation_failed_snack)
}
}
override fun onSubjectChanged(chatRoom: ChatRoom, eventLog: EventLog) {
subject.value = chatRoom.subject
}
override fun onParticipantAdded(chatRoom: ChatRoom, eventLog: EventLog) {
updateParticipants()
}
override fun onParticipantRemoved(chatRoom: ChatRoom, eventLog: EventLog) {
updateParticipants()
}
override fun onParticipantAdminStatusChanged(chatRoom: ChatRoom, eventLog: EventLog) {
val admin = chatRoom.me?.isAdmin ?: false
if (admin != isMeAdmin.value) {
isMeAdmin.value = admin
meAdminChangedEvent.value = Event(admin)
}
updateParticipants()
}
}
init {
subject.value = chatRoom?.subject
isMeAdmin.value = chatRoom == null || (chatRoom.me?.isAdmin == true && !chatRoom.isReadOnly)
canLeaveGroup.value = chatRoom != null && !chatRoom.isReadOnly
isEncrypted.value = corePreferences.forceEndToEndEncryptedChat || chatRoom?.hasCapability(ChatRoomCapabilities.Encrypted.toInt()) == true
if (chatRoom != null) updateParticipants()
chatRoom?.addListener(listener)
waitForChatRoomCreation.value = false
}
override fun onCleared() {
participants.value.orEmpty().forEach(GroupInfoParticipantData::destroy)
chatRoom?.removeListener(listener)
super.onCleared()
}
fun createChatRoom() {
waitForChatRoomCreation.value = true
val params: ChatRoomParams = coreContext.core.createDefaultChatRoomParams()
params.isEncryptionEnabled = corePreferences.forceEndToEndEncryptedChat || isEncrypted.value == true
params.isGroupEnabled = true
if (params.isEncryptionEnabled) {
params.ephemeralMode = if (corePreferences.useEphemeralPerDeviceMode)
ChatRoomEphemeralMode.DeviceManaged
else
ChatRoomEphemeralMode.AdminManaged
}
params.ephemeralLifetime = 0 // Make sure ephemeral is disabled by default
Log.i("[Chat Room Group Info] Ephemeral mode is ${params.ephemeralMode}, lifetime is ${params.ephemeralLifetime}")
params.subject = subject.value
val addresses = arrayOfNulls<Address>(participants.value.orEmpty().size)
var index = 0
for (participant in participants.value.orEmpty()) {
addresses[index] = participant.participant.address
Log.i("[Chat Room Group Info] Participant ${participant.sipUri} will be added to group")
index += 1
}
val chatRoom: ChatRoom? = coreContext.core.createChatRoom(params, coreContext.core.defaultAccount?.params?.identityAddress, addresses)
chatRoom?.addListener(listener)
if (chatRoom == null) {
Log.e("[Chat Room Group Info] Couldn't create chat room!")
waitForChatRoomCreation.value = false
onMessageToNotifyEvent.value = Event(R.string.chat_room_creation_failed_snack)
}
}
fun updateRoom() {
if (chatRoom != null) {
// Subject
val newSubject = subject.value.orEmpty()
if (newSubject.isNotEmpty() && newSubject != chatRoom.subject) {
Log.i("[Chat Room Group Info] Subject changed to $newSubject")
chatRoom.subject = newSubject
}
// Removed participants
val participantsToRemove = arrayListOf<Participant>()
for (participant in chatRoom.participants) {
val member = participants.value.orEmpty().find { member ->
participant.address.weakEqual(member.participant.address)
}
if (member == null) {
Log.w("[Chat Room Group Info] Participant ${participant.address.asStringUriOnly()} will be removed from group")
participantsToRemove.add(participant)
}
}
val toRemove = arrayOfNulls<Participant>(participantsToRemove.size)
participantsToRemove.toArray(toRemove)
chatRoom.removeParticipants(toRemove)
// Added participants & new admins
val participantsToAdd = arrayListOf<Address>()
for (member in participants.value.orEmpty()) {
val participant = chatRoom.participants.find { participant ->
participant.address.weakEqual(member.participant.address)
}
if (participant != null) {
// Participant found, check if admin status needs to be updated
if (member.participant.isAdmin != participant.isAdmin) {
if (chatRoom.me?.isAdmin == true) {
Log.i("[Chat Room Group Info] Participant ${member.sipUri} will be admin? ${member.isAdmin}")
chatRoom.setParticipantAdminStatus(participant, member.participant.isAdmin)
}
}
} else {
Log.i("[Chat Room Group Info] Participant ${member.sipUri} will be added to group")
participantsToAdd.add(member.participant.address)
}
}
val toAdd = arrayOfNulls<Address>(participantsToAdd.size)
participantsToAdd.toArray(toAdd)
chatRoom.addParticipants(toAdd)
// Go back to chat room
updatedChatRoomEvent.value = Event(chatRoom)
}
}
fun leaveGroup() {
if (chatRoom != null) {
Log.w("[Chat Room Group Info] Leaving group")
chatRoom.leave()
updatedChatRoomEvent.value = Event(chatRoom)
}
}
fun removeParticipant(participant: GroupChatRoomMember) {
val list = arrayListOf<GroupInfoParticipantData>()
for (data in participants.value.orEmpty()) {
if (!data.participant.address.weakEqual(participant.address)) {
list.add(data)
}
}
participants.value = list
}
private fun updateParticipants() {
val list = arrayListOf<GroupInfoParticipantData>()
if (chatRoom != null) {
for (participant in chatRoom.participants) {
list.add(
GroupInfoParticipantData(
GroupChatRoomMember(participant.address, participant.isAdmin, participant.securityLevel, canBeSetAdmin = true)
)
)
}
}
participants.value = list
}
}

View File

@ -0,0 +1,83 @@
/*
* 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.viewmodels
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import org.linphone.activities.main.chat.data.ChatMessageData
import org.linphone.activities.main.chat.data.ImdnParticipantData
import org.linphone.core.ChatMessage
import org.linphone.core.ChatMessageListenerStub
import org.linphone.core.ParticipantImdnState
class ImdnViewModelFactory(private val chatMessage: ChatMessage) :
ViewModelProvider.NewInstanceFactory() {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return ImdnViewModel(chatMessage) as T
}
}
class ImdnViewModel(private val chatMessage: ChatMessage) : ViewModel() {
val participants = MutableLiveData<ArrayList<ImdnParticipantData>>()
val chatMessageViewModel = ChatMessageData(chatMessage)
private val listener = object : ChatMessageListenerStub() {
override fun onParticipantImdnStateChanged(
message: ChatMessage,
state: ParticipantImdnState
) {
updateParticipantsLists()
}
}
init {
chatMessage.addListener(listener)
updateParticipantsLists()
}
override fun onCleared() {
participants.value.orEmpty().forEach(ImdnParticipantData::destroy)
chatMessage.removeListener(listener)
super.onCleared()
}
private fun updateParticipantsLists() {
val list = arrayListOf<ImdnParticipantData>()
for (participant in chatMessage.getParticipantsByImdnState(ChatMessage.State.Displayed)) {
list.add(ImdnParticipantData(participant))
}
for (participant in chatMessage.getParticipantsByImdnState(ChatMessage.State.DeliveredToUser)) {
list.add(ImdnParticipantData(participant))
}
for (participant in chatMessage.getParticipantsByImdnState(ChatMessage.State.Delivered)) {
list.add(ImdnParticipantData(participant))
}
for (participant in chatMessage.getParticipantsByImdnState(ChatMessage.State.NotDelivered)) {
list.add(ImdnParticipantData(participant))
}
participants.value = list
}
}

View File

@ -0,0 +1,72 @@
/*
* 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.views
import android.content.Context
import android.text.Layout
import android.text.method.LinkMovementMethod
import android.util.AttributeSet
import androidx.appcompat.widget.AppCompatTextView
import kotlin.math.ceil
/**
* The purpose of this class is to have a TextView declared with wrap_content as width that won't
* fill it's parent if it is multi line.
*/
class MultiLineWrapContentWidthTextView : AppCompatTextView {
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
constructor(
context: Context,
attrs: AttributeSet?,
defStyleAttr: Int
) : super(context, attrs, defStyleAttr)
override fun setText(text: CharSequence?, type: BufferType?) {
super.setText(text, type)
// Required for PatternClickableSpan
movementMethod = LinkMovementMethod.getInstance()
}
override fun onMeasure(widthSpec: Int, heightSpec: Int) {
super.onMeasure(widthSpec, heightSpec)
if (layout != null && layout.lineCount >= 2) {
val maxLineWidth = ceil(getMaxLineWidth(layout)).toInt()
val uselessPaddingWidth = layout.width - maxLineWidth
val width = measuredWidth - uselessPaddingWidth
val height = measuredHeight
setMeasuredDimension(width, height)
}
}
private fun getMaxLineWidth(layout: Layout): Float {
var maxWidth = 0.0f
val lines = layout.lineCount
for (i in 0 until lines) {
if (layout.getLineWidth(i) > maxWidth) {
maxWidth = layout.getLineWidth(i)
}
}
return maxWidth
}
}

View File

@ -0,0 +1,96 @@
/*
* 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.views
import android.app.Activity
import android.content.Context
import android.util.AttributeSet
import android.view.KeyEvent
import androidx.appcompat.widget.AppCompatEditText
import androidx.core.view.ViewCompat
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelStoreOwner
import org.linphone.activities.main.chat.receivers.RichContentReceiver
import org.linphone.activities.main.viewmodels.SharedMainViewModel
import org.linphone.core.tools.Log
import org.linphone.utils.Event
/**
* Allows for image input inside an EditText, usefull for keyboards with gif support for example.
*/
class RichEditText : AppCompatEditText {
private var controlPressed = false
private var sendListener: RichEditTextSendListener? = null
constructor(context: Context) : super(context) {
initReceiveContentListener()
}
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
initReceiveContentListener()
}
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(
context,
attrs,
defStyleAttr
) {
initReceiveContentListener()
}
fun setControlEnterListener(listener: RichEditTextSendListener) {
sendListener = listener
}
private fun initReceiveContentListener() {
ViewCompat.setOnReceiveContentListener(
this, RichContentReceiver.MIME_TYPES,
RichContentReceiver { uri ->
Log.i("[Rich Edit Text] Received URI: $uri")
val activity = context as Activity
val sharedViewModel = activity.run {
ViewModelProvider(activity as ViewModelStoreOwner)[SharedMainViewModel::class.java]
}
sharedViewModel.richContentUri.value = Event(uri)
}
)
setOnKeyListener { _, keyCode, event ->
if (keyCode == KeyEvent.KEYCODE_CTRL_LEFT) {
if (event.action == KeyEvent.ACTION_DOWN) {
controlPressed = true
} else if (event.action == KeyEvent.ACTION_UP) {
controlPressed = false
}
false
} else if (keyCode == KeyEvent.KEYCODE_ENTER && event.action == KeyEvent.ACTION_UP && controlPressed) {
sendListener?.onControlEnterPressedAndReleased()
true
} else {
false
}
}
}
}
interface RichEditTextSendListener {
fun onControlEnterPressedAndReleased()
}

View File

@ -0,0 +1,181 @@
/*
* 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.main.conference.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.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.conference.data.ScheduledConferenceData
import org.linphone.activities.main.viewmodels.ListTopBarViewModel
import org.linphone.databinding.ConferenceScheduleCellBinding
import org.linphone.databinding.ConferenceScheduleListHeaderBinding
import org.linphone.utils.Event
import org.linphone.utils.HeaderAdapter
import org.linphone.utils.TimestampUtils
class ScheduledConferencesAdapter(
selectionVM: ListTopBarViewModel,
private val viewLifecycleOwner: LifecycleOwner
) : SelectionListAdapter<ScheduledConferenceData, RecyclerView.ViewHolder>(selectionVM, ConferenceInfoDiffCallback()),
HeaderAdapter {
val copyAddressToClipboardEvent: MutableLiveData<Event<String>> by lazy {
MutableLiveData<Event<String>>()
}
val joinConferenceEvent: MutableLiveData<Event<Pair<String, String?>>> by lazy {
MutableLiveData<Event<Pair<String, String?>>>()
}
val editConferenceEvent: MutableLiveData<Event<String>> by lazy {
MutableLiveData<Event<String>>()
}
val deleteConferenceInfoEvent: MutableLiveData<Event<ScheduledConferenceData>> by lazy {
MutableLiveData<Event<ScheduledConferenceData>>()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ScheduledConferencesAdapter.ViewHolder {
val binding: ConferenceScheduleCellBinding = DataBindingUtil.inflate(
LayoutInflater.from(parent.context),
R.layout.conference_schedule_cell, parent, false
)
return ViewHolder(binding)
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
(holder as ScheduledConferencesAdapter.ViewHolder).bind(getItem(position))
}
override fun displayHeaderForPosition(position: Int): Boolean {
if (position >= itemCount) return false
val conferenceInfo = getItem(position)
val previousPosition = position - 1
return if (previousPosition >= 0) {
val previousItem = getItem(previousPosition)
!TimestampUtils.isSameDay(previousItem.conferenceInfo.dateTime, conferenceInfo.conferenceInfo.dateTime)
} else true
}
override fun getHeaderViewForPosition(context: Context, position: Int): View {
val data = getItem(position)
val binding: ConferenceScheduleListHeaderBinding = DataBindingUtil.inflate(
LayoutInflater.from(context),
R.layout.conference_schedule_list_header, null, false
)
binding.title = formatDate(context, data.conferenceInfo.dateTime)
binding.executePendingBindings()
return binding.root
}
private fun formatDate(context: Context, date: Long): String {
if (TimestampUtils.isToday(date)) {
return context.getString(R.string.today)
}
return TimestampUtils.toString(date, onlyDate = true, shortDate = false, hideYear = false)
}
inner class ViewHolder(
val binding: ConferenceScheduleCellBinding
) : RecyclerView.ViewHolder(binding.root) {
fun bind(conferenceData: ScheduledConferenceData) {
with(binding) {
data = conferenceData
lifecycleOwner = viewLifecycleOwner
// This is for item selection through ListTopBarFragment
selectionListViewModel = selectionViewModel
selectionViewModel.isEditionEnabled.observe(
viewLifecycleOwner
) {
position = bindingAdapterPosition
}
setClickListener {
if (selectionViewModel.isEditionEnabled.value == true) {
selectionViewModel.onToggleSelect(bindingAdapterPosition)
} else {
conferenceData.toggleExpand()
}
}
setLongClickListener {
if (selectionViewModel.isEditionEnabled.value == false) {
selectionViewModel.isEditionEnabled.value = true
// Selection will be handled by click listener
true
}
false
}
setCopyAddressClickListener {
val address = conferenceData.getAddressAsString()
if (address.isNotEmpty()) {
copyAddressToClipboardEvent.value = Event(address)
}
}
setJoinConferenceClickListener {
val address = conferenceData.conferenceInfo.uri
if (address != null) {
joinConferenceEvent.value = Event(Pair(address.asStringUriOnly(), conferenceData.conferenceInfo.subject))
}
}
setEditConferenceClickListener {
val address = conferenceData.conferenceInfo.uri
if (address != null) {
editConferenceEvent.value = Event(address.asStringUriOnly())
}
}
setDeleteConferenceClickListener {
deleteConferenceInfoEvent.value = Event(conferenceData)
}
executePendingBindings()
}
}
}
}
private class ConferenceInfoDiffCallback : DiffUtil.ItemCallback<ScheduledConferenceData>() {
override fun areItemsTheSame(
oldItem: ScheduledConferenceData,
newItem: ScheduledConferenceData
): Boolean {
return oldItem.conferenceInfo == newItem.conferenceInfo
}
override fun areContentsTheSame(
oldItem: ScheduledConferenceData,
newItem: ScheduledConferenceData
): Boolean {
return false
}
}

View File

@ -0,0 +1,33 @@
/*
* 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.main.conference.data
import org.linphone.contact.GenericContactData
import org.linphone.core.Address
import org.linphone.utils.LinphoneUtils
class ConferenceSchedulingParticipantData(
private val sipAddress: Address,
val showLimeBadge: Boolean = false,
val showDivider: Boolean = true
) :
GenericContactData(sipAddress) {
val sipUri: String get() = LinphoneUtils.getDisplayableAddress(sipAddress)
}

View File

@ -0,0 +1,30 @@
/*
* 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.main.conference.data
class Duration(val value: Int, val display: String) : Comparable<Duration> {
override fun toString(): String {
return display
}
override fun compareTo(other: Duration): Int {
return value.compareTo(other.value)
}
}

View File

@ -0,0 +1,148 @@
/*
* 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.main.conference.data
import androidx.lifecycle.MutableLiveData
import java.util.concurrent.TimeUnit
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.R
import org.linphone.core.ConferenceInfo
import org.linphone.core.ConferenceInfo.State
import org.linphone.core.tools.Log
import org.linphone.utils.LinphoneUtils
import org.linphone.utils.TimestampUtils
class ScheduledConferenceData(val conferenceInfo: ConferenceInfo, private val isFinished: Boolean) {
val expanded = MutableLiveData<Boolean>()
val backgroundResId = MutableLiveData<Int>()
val address = MutableLiveData<String>()
val subject = MutableLiveData<String>()
val description = MutableLiveData<String>()
val time = MutableLiveData<String>()
val date = MutableLiveData<String>()
val duration = MutableLiveData<String>()
val organizer = MutableLiveData<String>()
val canEdit = MutableLiveData<Boolean>()
val participantsShort = MutableLiveData<String>()
val participantsExpanded = MutableLiveData<String>()
val showDuration = MutableLiveData<Boolean>()
val isConferenceCancelled = MutableLiveData<Boolean>()
init {
expanded.value = false
address.value = conferenceInfo.uri?.asStringUriOnly()
subject.value = conferenceInfo.subject
description.value = conferenceInfo.description
time.value = TimestampUtils.timeToString(conferenceInfo.dateTime)
date.value = TimestampUtils.toString(conferenceInfo.dateTime, onlyDate = true, shortDate = false, hideYear = false)
isConferenceCancelled.value = conferenceInfo.state == State.Cancelled
val minutes = conferenceInfo.duration
val hours = TimeUnit.MINUTES.toHours(minutes.toLong())
val remainMinutes = minutes - TimeUnit.HOURS.toMinutes(hours).toInt()
duration.value = TimestampUtils.durationToString(hours.toInt(), remainMinutes)
showDuration.value = minutes > 0
val organizerAddress = conferenceInfo.organizer
if (organizerAddress != null) {
val localAccount = coreContext.core.accountList.find { account ->
val address = account.params.identityAddress
address != null && organizerAddress.weakEqual(address)
}
canEdit.value = localAccount != null
val contact = coreContext.contactsManager.findContactByAddress(organizerAddress)
organizer.value = if (contact != null)
contact.name
else
LinphoneUtils.getDisplayName(conferenceInfo.organizer)
} else {
canEdit.value = false
Log.e("[Scheduled Conference] No organizer SIP URI found for: ${conferenceInfo.uri?.asStringUriOnly()}")
}
computeBackgroundResId()
computeParticipantsLists()
}
fun destroy() {}
fun delete() {
Log.w("[Scheduled Conference] Deleting conference info with URI: ${conferenceInfo.uri?.asStringUriOnly()}")
coreContext.core.deleteConferenceInformation(conferenceInfo)
}
fun toggleExpand() {
expanded.value = expanded.value == false
computeBackgroundResId()
}
fun getAddressAsString(): String {
val address = conferenceInfo.uri?.clone()
if (address != null) {
address.displayName = conferenceInfo.subject
return address.asString()
}
return ""
}
private fun computeBackgroundResId() {
backgroundResId.value = if (conferenceInfo.state == State.Cancelled) {
if (expanded.value == true) {
R.drawable.shape_round_red_background_with_orange_border
} else {
R.drawable.shape_round_red_background
}
} else if (isFinished) {
if (expanded.value == true) {
R.drawable.shape_round_dark_gray_background_with_orange_border
} else {
R.drawable.shape_round_dark_gray_background
}
} else {
if (expanded.value == true) {
R.drawable.shape_round_gray_background_with_orange_border
} else {
R.drawable.shape_round_gray_background
}
}
}
private fun computeParticipantsLists() {
var participantsListShort = ""
var participantsListExpanded = ""
for (participant in conferenceInfo.participants) {
val contact = coreContext.contactsManager.findContactByAddress(participant)
val name = if (contact != null) contact.name else LinphoneUtils.getDisplayName(participant)
val address = participant.asStringUriOnly()
participantsListShort += "$name, "
participantsListExpanded += "$name ($address)\n"
}
participantsListShort = participantsListShort.dropLast(2)
participantsListExpanded = participantsListExpanded.dropLast(1)
participantsShort.value = participantsListShort
participantsExpanded.value = participantsListExpanded
}
}

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