mirror of
https://gitlab.linphone.org/BC/public/linphone-android.git
synced 2026-01-19 20:48:08 +00:00
Compare commits
408 commits
6.1.0-alph
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6f897cb39 | ||
|
|
97606578a4 | ||
|
|
48c9b4d7af | ||
|
|
7d0d1a22ee | ||
|
|
d467699388 | ||
|
|
5ad7b5da14 | ||
|
|
50c922b581 | ||
|
|
b574bf420c | ||
|
|
3df3c8d741 | ||
|
|
4039da9c8a | ||
|
|
3cec19126d | ||
|
|
a816a956b8 | ||
|
|
cec3639b73 | ||
|
|
07cae7eb12 | ||
|
|
3b561275a4 | ||
|
|
3f868e02fe | ||
|
|
57644a34de | ||
|
|
e306c8c7fc | ||
|
|
dd9190df07 | ||
|
|
d90861b5f3 | ||
|
|
9151898a4d | ||
|
|
fe788caf0e | ||
|
|
7e0353cc91 | ||
|
|
a897c127e5 | ||
|
|
50aa053c19 | ||
|
|
b88b6a8093 | ||
|
|
24d808b1a7 | ||
|
|
6f09853424 | ||
|
|
a7593e07fc | ||
|
|
844b182df2 | ||
|
|
d299b0b129 | ||
|
|
965b159139 | ||
|
|
00b8e59ade | ||
|
|
be47deeb40 | ||
|
|
e8c67fdd6f | ||
|
|
ff98c15840 | ||
|
|
3711fd749e | ||
|
|
dce7095f74 | ||
|
|
d2b12159af | ||
|
|
618be9ee7c | ||
|
|
e1abcc6dca | ||
|
|
2a9ef440b7 | ||
|
|
77b933c5a8 | ||
|
|
6b56165d4f | ||
|
|
61c79a86f7 | ||
|
|
40d195e06b | ||
|
|
e173e402c2 | ||
|
|
7817e6603c | ||
|
|
bf4b5a51f5 | ||
|
|
3ffda24b82 | ||
|
|
c99acbb5e1 | ||
|
|
cc1cc7d929 | ||
|
|
6bcce4ddbf | ||
|
|
696a593cbc | ||
|
|
88e474533e | ||
|
|
8e76c60a38 | ||
|
|
85aa50d8d8 | ||
|
|
c496545023 | ||
|
|
1183a9e1c2 | ||
|
|
170cd6fccc | ||
|
|
bc7ac8be64 | ||
|
|
5ed68e0171 | ||
|
|
41e6776b32 | ||
|
|
e290a8c4ea | ||
|
|
93e26f6c10 | ||
|
|
3f22a596db | ||
|
|
d5c836b8b5 | ||
|
|
9afcb6db15 | ||
|
|
a6f568497d | ||
|
|
209c0df091 | ||
|
|
7b0de4185c | ||
|
|
89458ed826 | ||
|
|
a3f86fbac0 | ||
|
|
28cee7f539 | ||
|
|
daa2f10f7b | ||
|
|
e14ea0ac68 | ||
|
|
c3ad96cd1f | ||
|
|
c6a0f25041 | ||
|
|
7ab7136a5b | ||
|
|
3698e1673e | ||
|
|
bdd5c8766b | ||
|
|
ce2b794936 | ||
|
|
e267f46fd7 | ||
|
|
ab6911dd11 | ||
|
|
b0283043ee | ||
|
|
0e71a726c1 | ||
|
|
d74ccb523e | ||
|
|
4dc1b9a903 | ||
|
|
45c756cfd6 | ||
|
|
069997d780 | ||
|
|
e2c9e1196f | ||
|
|
e8c642b9c6 | ||
|
|
d75c48cd34 | ||
|
|
d9ab840570 | ||
|
|
5ee3ba4ea9 | ||
|
|
d694789d4b | ||
|
|
b71249ea36 | ||
|
|
7855d4e1db | ||
|
|
7e2527c46c | ||
|
|
d16dbcf0fd | ||
|
|
1d28ce1846 | ||
|
|
2ea38abdfe | ||
|
|
416cc6ea7f | ||
|
|
6dc4790597 | ||
|
|
f8556aa46b | ||
|
|
df09bcad76 | ||
|
|
0ca4eba63b | ||
|
|
c556d14fb0 | ||
|
|
6cb78c8c59 | ||
|
|
61517461dd | ||
|
|
1fdc2bcc58 | ||
|
|
8f3415f6fa | ||
|
|
ae7a3c5bce | ||
|
|
31e15ddfca | ||
|
|
808dc92cd7 | ||
|
|
99936e8f75 | ||
|
|
2ce07b5e89 | ||
|
|
9d3ef9e8a5 | ||
|
|
5f17dd8534 | ||
|
|
719b28f0ab | ||
|
|
6f1439756e | ||
|
|
7fdbaf5fd6 | ||
|
|
4639e054bb | ||
|
|
504f6e2a2c | ||
|
|
1e6f501dee | ||
|
|
633aee829a | ||
|
|
38ffac31b4 | ||
|
|
6e40e3f75f | ||
|
|
7d7900e081 | ||
|
|
f0e899bb95 | ||
|
|
db7ca6793b | ||
|
|
ac521557ce | ||
|
|
b5babae39a | ||
|
|
ce13d4c7d4 | ||
|
|
e9cc03891b | ||
|
|
98bf3daed8 | ||
|
|
a5cee98a57 | ||
|
|
26e391cbf8 | ||
|
|
0add60c628 | ||
|
|
fc90a95e94 | ||
|
|
c153b2d928 | ||
|
|
881e2c217b | ||
|
|
77d744d020 | ||
|
|
5a16761fdf | ||
|
|
e0ff593f3d | ||
|
|
71bb569fde | ||
|
|
9096225b45 | ||
|
|
ad0037fe4c | ||
|
|
7eed9c06d3 | ||
|
|
e2dabf5448 | ||
|
|
08c72dbb8c | ||
|
|
f99b51d572 | ||
|
|
8f0f6581b2 | ||
|
|
fac6e42c22 | ||
|
|
e8d3c8750a | ||
|
|
dee932da42 | ||
|
|
2f4fd3da18 | ||
|
|
22ae4e372f | ||
|
|
95bd14bdd4 | ||
|
|
ffabd02f31 | ||
|
|
0bb7761db9 | ||
|
|
50418f5dbb | ||
|
|
e800249445 | ||
|
|
ec5b6e5707 | ||
|
|
97c6c0b553 | ||
|
|
95ce77e0e4 | ||
|
|
243a6d8cb2 | ||
|
|
e98318a23d | ||
|
|
ace0a3f61e | ||
|
|
b3ac16052f | ||
|
|
865216d717 | ||
|
|
670eecf0d6 | ||
|
|
8dcb18d059 | ||
|
|
92672bde0a | ||
|
|
ada6f35d92 | ||
|
|
332828dc7c | ||
|
|
595ff96d50 | ||
|
|
6cdcdec373 | ||
|
|
3098c3e68e | ||
|
|
2c9d627794 | ||
|
|
70df098ee4 | ||
|
|
c32bac7b07 | ||
|
|
3d41a4d221 | ||
|
|
dfa87e4088 | ||
|
|
d6b43c474b | ||
|
|
9d0f2cafc9 | ||
|
|
98cc173d2e | ||
|
|
4ae046a166 | ||
|
|
62180140b7 | ||
|
|
899129d4bc | ||
|
|
c4618702ab | ||
|
|
8c7c7b40c3 | ||
|
|
856f3e7f94 | ||
|
|
f7be887984 | ||
|
|
028ece407c | ||
|
|
bb81957aab | ||
|
|
da581c3737 | ||
|
|
461537aa9c | ||
|
|
1a54746a80 | ||
|
|
be24224f4c | ||
|
|
f2cdb92858 | ||
|
|
aa0255bcfd | ||
|
|
ff425089c7 | ||
|
|
b4c2a52bf7 | ||
|
|
f397456879 | ||
|
|
5337ab6413 | ||
|
|
998f969c0f | ||
|
|
58410ee112 | ||
|
|
6c97ee9176 | ||
|
|
187946bf34 | ||
|
|
3c40bf3d6f | ||
|
|
b7a9f4ba8e | ||
|
|
4a1c5304b1 | ||
|
|
67e3c51a84 | ||
|
|
4d8ab32da7 | ||
|
|
62ff36e7a7 | ||
|
|
6f80409086 | ||
|
|
fd3f746e3d | ||
|
|
42fbbc51fd | ||
|
|
60c74ee5b2 | ||
|
|
79212a8757 | ||
|
|
1307ec5471 | ||
|
|
c62f549521 | ||
|
|
9ba2684f31 | ||
|
|
4b631a19ef | ||
|
|
496279d724 | ||
|
|
c25ed404dc | ||
|
|
a81973e4cf | ||
|
|
654e790a6d | ||
|
|
6602c7692b | ||
|
|
cc57244b56 | ||
|
|
19df3b07dc | ||
|
|
4ef9a2bdf3 | ||
|
|
61be1d21d5 | ||
|
|
8148354901 | ||
|
|
ae39d79420 | ||
|
|
2bd0de4af1 | ||
|
|
cb27b35984 | ||
|
|
316bc6698a | ||
|
|
fea1dbe5ca | ||
|
|
85679dcc43 | ||
|
|
32060f6830 | ||
|
|
dfdc26a575 | ||
|
|
9e1d358f4e | ||
|
|
90922568b5 | ||
|
|
d212b7b06e | ||
|
|
4cb83980ba | ||
|
|
def52f69ad | ||
|
|
17ce34aba7 | ||
|
|
1833b1985d | ||
|
|
5256ee79c6 | ||
|
|
27e59a5f8b | ||
|
|
cea2d49778 | ||
|
|
c0f67d01fe | ||
|
|
b6279b03c0 | ||
|
|
21398c7b37 | ||
|
|
4cb7ea1965 | ||
|
|
7aae03f1f9 | ||
|
|
25d13f44c7 | ||
|
|
81d0da4241 | ||
|
|
1556abc79e | ||
|
|
28b6bd7e90 | ||
|
|
1c3173b871 | ||
|
|
17588de5a9 | ||
|
|
502c6413ee | ||
|
|
02cbb45de9 | ||
|
|
f5852a7b3e | ||
|
|
cfec621787 | ||
|
|
6847227f1a | ||
|
|
f1fdb186ec | ||
|
|
d822cbc827 | ||
|
|
627f881364 | ||
|
|
244061c0b1 | ||
|
|
dc2b94ca4d | ||
|
|
7c78b021db | ||
|
|
d113797dfb | ||
|
|
926b8d4dc1 | ||
|
|
85e24e25bf | ||
|
|
1c1729f3f0 | ||
|
|
bcce9a9ba1 | ||
|
|
a496e2bf56 | ||
|
|
73237ee335 | ||
|
|
b293bf7f2f | ||
|
|
4689b7c7da | ||
|
|
e38040428b | ||
|
|
b740409642 | ||
|
|
99a5ed23f6 | ||
|
|
c9a3a01733 | ||
|
|
966f713f19 | ||
|
|
344afdfcfa | ||
|
|
2634945b8d | ||
|
|
2713c82ca3 | ||
|
|
1a813ee11e | ||
|
|
e5cec2d45c | ||
|
|
6e9c6d1b33 | ||
|
|
056abd629f | ||
|
|
6c86af747b | ||
|
|
f7790fbed7 | ||
|
|
90524da610 | ||
|
|
616b7bb70f | ||
|
|
985a304df9 | ||
|
|
cd35f213c1 | ||
|
|
dcbc837106 | ||
|
|
5ef7eab0c5 | ||
|
|
c64bd5bc1c | ||
|
|
afa041baf6 | ||
|
|
94b6db6a08 | ||
|
|
6ba8760be7 | ||
|
|
b1b1ab0d8a | ||
|
|
518ecc1823 | ||
|
|
e2dfd95857 | ||
|
|
51d725c757 | ||
|
|
af3b1fa418 | ||
|
|
bc9a6581b1 | ||
|
|
26df085df3 | ||
|
|
c08157b659 | ||
|
|
910527ef1b | ||
|
|
8577571e67 | ||
|
|
dbca62bea9 | ||
|
|
5e9be7d10b | ||
|
|
836deaae99 | ||
|
|
a2680028ce | ||
|
|
c8ff7262d4 | ||
|
|
06d8e903fc | ||
|
|
a5872ef8de | ||
|
|
9255830fe2 | ||
|
|
80eaf08fbf | ||
|
|
903aaad6fe | ||
|
|
bdb2615300 | ||
|
|
bab2acb75c | ||
|
|
bd52960749 | ||
|
|
23810e41e5 | ||
|
|
3f3a229844 | ||
|
|
0eb659b633 | ||
|
|
c35a44b1a0 | ||
|
|
1cccf7d26b | ||
|
|
0fab732e89 | ||
|
|
317a7c4417 | ||
|
|
689665c475 | ||
|
|
18e15b60a4 | ||
|
|
7bead679ad | ||
|
|
f0ad67fb29 | ||
|
|
2621eb306e | ||
|
|
90bf20e50e | ||
|
|
1f45ba8bd0 | ||
|
|
c528f0cdb8 | ||
|
|
a0108776dd | ||
|
|
a503ef06ee | ||
|
|
fbc19c7053 | ||
|
|
9c8c5f309e | ||
|
|
b40fbcad77 | ||
|
|
8dda38a925 | ||
|
|
d150027c24 | ||
|
|
7018cd3442 | ||
|
|
d6494cd27c | ||
|
|
10f2d7cd78 | ||
|
|
6767bc09f9 | ||
|
|
b22ab7024e | ||
|
|
c6fa645f94 | ||
|
|
fb3feb0bc3 | ||
|
|
77f61c1cfa | ||
|
|
9ce803667b | ||
|
|
50bd8f67d5 | ||
|
|
faac4111d9 | ||
|
|
6121cb41bf | ||
|
|
2aed404167 | ||
|
|
2f9eb2f0ab | ||
|
|
1255d626af | ||
|
|
a83f9d4424 | ||
|
|
fecf067b50 | ||
|
|
b194272f91 | ||
|
|
cad90752db | ||
|
|
2abad0ab9a | ||
|
|
a0d74c8036 | ||
|
|
2eb376fd2d | ||
|
|
1942ee8f85 | ||
|
|
488a0fd98c | ||
|
|
08412ef99a | ||
|
|
e16e767d5a | ||
|
|
886be9e038 | ||
|
|
2a5b5d368c | ||
|
|
be5428aa08 | ||
|
|
d6c6de2b5e | ||
|
|
915a847083 | ||
|
|
0d8397b914 | ||
|
|
b5a1e21f40 | ||
|
|
9837a834d4 | ||
|
|
8a4956e7c1 | ||
|
|
052d7cc522 | ||
|
|
6c6fb9eff3 | ||
|
|
b23f52adec | ||
|
|
8769a47ed0 | ||
|
|
ebb7201701 | ||
|
|
6e83b794b3 | ||
|
|
3045378eb0 | ||
|
|
dc4619a7d7 | ||
|
|
87b6c2deef | ||
|
|
614ac7f9cf | ||
|
|
71e1734ca0 | ||
|
|
71b1cf8e7a | ||
|
|
dee684b364 | ||
|
|
0b6805a73c | ||
|
|
11795cded8 | ||
|
|
cc5bfcf14d | ||
|
|
b3ab9601b2 | ||
|
|
0e6d91a467 | ||
|
|
fbf68db2dd | ||
|
|
0bf50f1495 |
491 changed files with 23608 additions and 7798 deletions
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
|
|
@ -39,7 +39,7 @@ If you are using a SDK that isn't the latest release, please update first as it'
|
|||
|
||||
5. **SDK logs** (mandatory)
|
||||
|
||||
Enable debug logs in advanced section of the settings, restart the app, reproduce the issue and then go back to advanced settings, click on "Send logs" and copy/paste the link here.
|
||||
Click on "Share logs" in Help -> Troubleshooting view and copy/paste the link here.
|
||||
|
||||
It's also explained [in the README](https://github.com/BelledonneCommunications/linphone-android#behavior-issue).
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,20 @@
|
|||
job-android-upload:
|
||||
|
||||
stage: deploy
|
||||
tags: [ "deploy" ]
|
||||
tags: [ "docker-deploy" ]
|
||||
|
||||
only:
|
||||
- schedules
|
||||
dependencies:
|
||||
- job-android
|
||||
|
||||
before_script:
|
||||
- if ! [ -z ${SCP_PRIVATE_KEY+x} ] && ! [ -z ${DEPLOY_SERVER_HOST_KEYS+x} ]; then eval $(ssh-agent -s); fi
|
||||
- if ! [ -z ${SCP_PRIVATE_KEY+x} ]; then echo "$SCP_PRIVATE_KEY" | tr -d '\r' | ssh-add - > /dev/null; fi
|
||||
- if ! [ -z ${DEPLOY_SERVER_HOST_KEYS+x} ]; then mkdir -p ~/.ssh && chmod 700 ~/.ssh; fi
|
||||
- if ! [ -z ${DEPLOY_SERVER_HOST_KEYS+x} ]; then echo "$DEPLOY_SERVER_HOST_KEYS" >> ~/.ssh/known_hosts; fi
|
||||
|
||||
script:
|
||||
- cd app/build/outputs/apk/ && rsync ./release/*.apk $DEPLOY_SERVER:$ANDROID_DEPLOY_DIRECTORY
|
||||
# Launches rsync in partial mode, which means that we are using a temp_dir in case of a transfer issue
|
||||
# Upon a job relaunch, the files in temp_dir would then be re-used, and deleted if the transfer succeeds
|
||||
- cd app/build/outputs/apk/ && rsync --partial --partial-dir=$CI_PIPELINE_ID_$CI_JOB_NAME ./release/*.apk $DEPLOY_SERVER:$ANDROID_DEPLOY_DIRECTORY
|
||||
2
.idea/compiler.xml
generated
2
.idea/compiler.xml
generated
|
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CompilerConfiguration">
|
||||
<bytecodeTargetLevel target="21" />
|
||||
<bytecodeTargetLevel target="17" />
|
||||
</component>
|
||||
</project>
|
||||
3
.idea/misc.xml
generated
3
.idea/misc.xml
generated
|
|
@ -1,7 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="temurin-17" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||
</component>
|
||||
<component name="ProjectType">
|
||||
|
|
|
|||
361
CHANGELOG.md
361
CHANGELOG.md
|
|
@ -10,6 +10,367 @@ Group changes to describe their impact on the project, as follows:
|
|||
Fixed for any bug fixes.
|
||||
Security to invite users to upgrade in case of vulnerabilities.
|
||||
|
||||
## [6.1.0] - Unreleased
|
||||
|
||||
### Added
|
||||
- Added the ability to edit/delete chat messages sent less than 24 hours ago.
|
||||
- Added keyboard shortcuts on IncomingCallFragment: Ctrl + Shift + A to answer the call, Ctrl + Shift + D to decline it
|
||||
- Added seeking feature to recordings & media player within app
|
||||
- Added PDF preview in conversation (message bubble & documents list)
|
||||
- Added hover effect when using a mouse (useful for tablets or devices with desktop mode)
|
||||
- Support right click on some items to open bottom sheet/menu
|
||||
- Added toggle speaker action in active call notification
|
||||
- Increased text size for chat messages that only contains emoji(s)
|
||||
- Use user-input to filter participants list after typing "@" in conversation send area
|
||||
- Handle read-only CardDAV address books, disable edit/delete menus for contacts in read-only FriendList
|
||||
- Added swipe/pull to refresh on contacts list of a CardDAV addressbook has been configured to force the synchronization
|
||||
- Show information to user when filtering contacts doesn't show them all and user may have to refine it's search
|
||||
- Show Android notification when an account goes to failed registration state (only when background mode is enabled)
|
||||
- New settings:
|
||||
- one for user to choose whether to sort contacts by first name or last name
|
||||
- one to hide contacts that have neither a SIP address nor a phone number
|
||||
- one to let app auto-answer call with video sending already enabled
|
||||
- one to let edit native contacts Linphone copy in-app instead of opening native addressbook third party app
|
||||
- Added a vu meter for recording & playback volumes (must be enabled in developer settings)
|
||||
- Added support for HDMI audio devices
|
||||
|
||||
### Changed
|
||||
- No longer follow TelecomManager audio endpoint during calls, using our own routing policy
|
||||
- Join a conference using default layout instead of audio only when clicking on a meeting SIP URI
|
||||
- Removing an account will also remove all related data in the local database (auth info, call logs, conversations, meetings, etc...)
|
||||
- Hide SIP address/phone number picker dialog if contact has exactly one SIP address matching both the app default domain & the currently selected account domain
|
||||
- Hide SIP address associated to phone number through presence mecanism in contact details & editor views.
|
||||
- Improved UI on tablets with screen sw600dp and higher, will look more like our desktop app
|
||||
- Improved navigation within app when using a keyboard
|
||||
- Now loading media/documents contents in conversation by chunks (instead of all of them at once)
|
||||
- Simplified audio device name in settings
|
||||
- Reworked some settings (moved calls related ones from advanced settings to advanced calls settings)
|
||||
- Increased shared media preview size in chat
|
||||
- Un-encrypted conversation warning will be more visible for accounts that support end-to-end encrypted conversations
|
||||
- Made numpad buttons larger by changing their shape
|
||||
- All LDAP fields are mandatory now
|
||||
- Improved how Android shortcuts are created
|
||||
- Permission fragment will only show missing ones
|
||||
- Added more info into StartupListener logs
|
||||
- Updated password forgotten procedure, will use online account manager platform
|
||||
|
||||
### Fixed
|
||||
- Copy raw message content instead of modified one when it contains a participant mention ("@username")
|
||||
|
||||
## [6.0.21] - 2025-12-16
|
||||
|
||||
### Added
|
||||
- Allow linphone-config: scheme URIs in in-app QR code scanner
|
||||
|
||||
### Changed
|
||||
- Workaround for audio focus & audio manager mode on devices that do not support TelecomManager APIs
|
||||
- Set front camera as default after using back camera when scanning a QR code
|
||||
- Added back largeHeap flag in AndroidManifest.xml
|
||||
|
||||
### Fixed
|
||||
- Fixed call recording indicator not showing local record in progress in case UPDATE isn't answered
|
||||
- Fixed native addressbook reload when a contact is updated in the OS default app
|
||||
- Fixed issue with linphone-config scheme URIs if scheme is followed by "//"
|
||||
- Fixed Job & Company contact field not updated if field content was removed
|
||||
- Fixed local avatar not displayed when calling ourselves
|
||||
- Prevent crashes due to some ActivityNotFound exceptions
|
||||
- Prevent crash due to empty clipboard on some devices
|
||||
|
||||
## [6.0.20] - 2025-11-21
|
||||
|
||||
### Changed
|
||||
- Added shrink resources to release config in gradle
|
||||
|
||||
### Fixed
|
||||
- Remove AuthInfo when configuring a CardDAV friend list if synchronization fails
|
||||
- Added missing toast when starting a group call or meeting if there's an issue
|
||||
- Fixed crash in RecordingPlayerFragment due to used lateinit property before it's initialized
|
||||
|
||||
## [6.0.19] - 2025-10-16
|
||||
|
||||
### Added
|
||||
- Spanish and Slovakian translations thanks to Weblate contributors
|
||||
|
||||
### Changed
|
||||
- SIP addresses domain hidden in Suggestions if it matches the currently selected account SIP identity domain
|
||||
- Start proximity sensor when an incoming call is answered from the notification (disabling screen when device is near)
|
||||
|
||||
### Fixed
|
||||
- Black screen when trying to scan a QR Code right after granting CAMERA permission (only happened on some devices)
|
||||
- Possible crash due to ConcurrentModificationException
|
||||
- Camera preview in conference that was black sometimes after switching layout
|
||||
- Possibly wrong screen sharing participant name in conference
|
||||
- Presence SUBSCRIBE that was only sent for sip.linphone.org accounts
|
||||
- Keyboard suggestions in participant picker textfield
|
||||
- Account labelled as Disabled instead of Disconnected when network isn't reachable
|
||||
- Suggestions generated avatar if username starts with '+'
|
||||
- Two LDAP fields label where swapped
|
||||
|
||||
## [6.0.18] - 2025-09-15
|
||||
|
||||
### Added
|
||||
- Added menu icon next to currently selected account avatar to make the drawer menu easier to understand
|
||||
- Added missing dialpad floating action button in the call transfer fragment
|
||||
|
||||
### Changed
|
||||
- Improved bodyless friendlist presence process when it's received
|
||||
|
||||
### Fixed
|
||||
- Fixed "End-to-end encrypted call" label while in conference, the call may be end-to-end encrypted but only to the conference server, not to all participants
|
||||
- Fixed missing meeting subject when calling the conference SIP URI if the conference info doesn't exist yet
|
||||
- Finish CallActivity if no call is found when trying to answer/decline a call from the IncomingCallFragment
|
||||
- Prevent empty screen when rotating the device and clicking on the empty part next to the list while in landscape and then rotating the device back to portrait
|
||||
|
||||
## [6.0.17] - 2025-09-02
|
||||
|
||||
### Changed
|
||||
- Portuguese translation updated from Weblate (still not complete)
|
||||
|
||||
### Fixed
|
||||
- Vibrator not stopped when call is terminated sometimes (SDK fix)
|
||||
- Chat conversation not visible sometimes (SDK fix)
|
||||
|
||||
## [6.0.16] - 2025-08-25
|
||||
|
||||
## Added
|
||||
- Access to Help/Troubleshooting pages from Assistant
|
||||
|
||||
## Fixed
|
||||
- Some Core methods being called from UI thread causing either a crash or a deadlock sometimes
|
||||
- Scrolling issue when doing a search in a conversation with only one result
|
||||
- Contacts not updated after body less presence notify was received
|
||||
- VFS issue due to encrypted.pref file being backed up by Android OS
|
||||
|
||||
## [6.0.15] - 2025-08-11
|
||||
|
||||
### Fixed
|
||||
- Crash due to changes in SDK triggering fatal error if linphone_core_stop() is called from linphone_core_iterate() loop (which was done when scanning QR code)
|
||||
|
||||
### Changed
|
||||
- Prevent leaving assistant after doing a remote provisioning if there is still no account after it (if there was no account before and no account was provided in downloaded config)
|
||||
|
||||
## [6.0.14] - 2025-08-06
|
||||
|
||||
### Fixed
|
||||
- Fixed ANR due to deadlock caused by method being called from wrong thread
|
||||
- Fixed microphone not always recording audio while app in background or if screen is turned off
|
||||
- Fixed missing favorites in start call / create conversation views
|
||||
- Fixed outgoing call view in full screen
|
||||
- Fixed generated avatar for SIP URIs without username
|
||||
|
||||
## [6.0.13] - 2025-07-31
|
||||
|
||||
### Fixed
|
||||
- Missing favourites if contacts list size exceeds magic search max results setting
|
||||
- Muted call on some devices due to Telecom Manager quickly muting/unmuting call
|
||||
- Full screen without video during outgoing early media call if video has been declined by remote end
|
||||
- Removed duplicated week label if "no meeting today" is the first entry for current week
|
||||
- Prevent crash during file export if no app on the device can handle it
|
||||
- Prevent crash that could happen with chat message notification if sender name (or group chat room subject) is empty
|
||||
|
||||
### Changed
|
||||
- Back gesture / navigation button will close the numpad bottom sheet if it's open instead of leaving the page directly
|
||||
- Updated bell and bell_slash icons
|
||||
|
||||
## [6.0.12] - 2025-07-18
|
||||
|
||||
### Fixed
|
||||
- Reactions list in bottom sheet update while opened
|
||||
- Crashes due to late init properties being used before initialized
|
||||
|
||||
## [6.0.11] - 2025-07-11
|
||||
|
||||
### Added
|
||||
- Added toggle in LDAP configuration to allow to quickly enable/disable it
|
||||
|
||||
### Changed
|
||||
- Reduced maximum number of contacts displayed in contacts list, new call/conversation, meeting participant selection etc...
|
||||
- Updated translations
|
||||
|
||||
### Fixed
|
||||
- Calls top bar wrong notification label when going from two calls to one.
|
||||
|
||||
## [6.0.10] - 2025-06-27
|
||||
|
||||
### Added
|
||||
- Added a new top bar alert area for pending file/text sharing.
|
||||
|
||||
### Changed
|
||||
- Reworked in-app top bar alerts, now can show both an account alert and an active call alert.
|
||||
- Hide SIP address/phone number picker dialog if contact has exactly one SIP address matching the default domain and currently default account domain.
|
||||
|
||||
### Fixed
|
||||
- Bluetooth not being used automatically when device is connected during a call.
|
||||
- Call encryption status label stuck in "Waiting for encryption".
|
||||
- Group chat room creation if LIME server URL isn't set.
|
||||
- Participant mention if more than one in the same chat message.
|
||||
- Force default account in call params when starting one.
|
||||
|
||||
## [6.0.9] - 2025-06-06
|
||||
|
||||
### Added
|
||||
- German translation (88% complete)
|
||||
- Link to user guide in Help section
|
||||
- Missing scroll views for help & debug layouts
|
||||
|
||||
### Changed
|
||||
- Prevent port from being set in the SIP identity address in third party account login + remove port (if any) from SIP identity for existing accounts
|
||||
- Show last message timestamp instead of conversation last updated timestamp in conversations list
|
||||
|
||||
### Fixed
|
||||
- Prevent blinking in conversations list when removing message from chat room
|
||||
- Prevent empty (can even lead to crash) display name in call notification (using all identification fields from vCard)
|
||||
|
||||
## [6.0.8] - 2025-05-23
|
||||
|
||||
### Added
|
||||
- Ukrainian & simplified Chinese translations from Weblate
|
||||
- Sliding answer/decline button in incoming call fragment if device is locked (will help prevent calls from being unintentionally picked up or hung up while the device is being removed from a pocket)
|
||||
|
||||
### Changed
|
||||
- Show files with square design when more than one (as it is for media files)
|
||||
- Outgoing chat bubbles will now display the sent file size (as it is for received messages)
|
||||
|
||||
### Fixed
|
||||
- Fixed issue with bluetooth hearing aids
|
||||
- Fixed audio call being answered on speakerphone
|
||||
- Fixed events related to joined/left conversation being briefly visible sometimes for 1-1 conversations
|
||||
- Fixed files/media grid in chat bubble using more than 3 columns in landscape
|
||||
- Fixed logs upload server URL setting
|
||||
|
||||
## [6.0.7] - 2025-05-16
|
||||
|
||||
### Added
|
||||
- CS, NL and RU translations from Weblate
|
||||
|
||||
### Changed
|
||||
- Improved find contact performances
|
||||
- Make sure speaker audio device is used for playing the ringtone during early media
|
||||
- Reworked bottom navigation bar in portrait and unread count indicators
|
||||
- No longer delete conversations when deleting account (for now); causes user to leave group which is an issue when using multiple devices
|
||||
|
||||
### Fixed
|
||||
- Fixed no default account after remote provisioning
|
||||
- Prevent lists from refreshing too many times when using LDAP or remote CardDAV contact directories
|
||||
- Fixed black miniatures in conference if bundle mode is disabled in account params
|
||||
- Fixed long press on a chat message containing a SIP URI triggering call
|
||||
- Disable IMDN bottom sheet for incoming messages in groups instead of showing it empty
|
||||
- Refresh conversations list after clearing conversation history
|
||||
- Fixed another race condition issue related to foreground call service
|
||||
|
||||
## [6.0.6] - 2025-05-02
|
||||
|
||||
### Added
|
||||
- Added recover phone account when clicking on "Forgotten password" in the assistant
|
||||
- Improved message when contacts list is empty depending on the currently selected filter and added a button to open the filter popup menu for users that didn't notice the icon on the top right corner of the screen when contacts list is empty and "SIP contacts only" filter is set.
|
||||
- Added "Logs collection sharing server URL" setting in developper area
|
||||
- Added "Disable sending logs to Crashlytics" advanced setting.
|
||||
|
||||
### Changed
|
||||
- Improved VFS message in confirmation dialog
|
||||
- Moved "Print logs in logcat" and "File sharing server URL" settings to developper area
|
||||
|
||||
### Fixed
|
||||
- Fixed crash when opening a password protected PDF
|
||||
- Fixed chat room lookup while in 1-1 call, using SDK method for getting chat room from conference
|
||||
- Fixed newly created contact not being visible in contacts list without reloading it
|
||||
- Fixed missing event icon for group conversations
|
||||
- Another attempts at preventing crashes due to In-Call service not being started as foreground before being stopped
|
||||
|
||||
## [6.0.5] - 2025-04-18
|
||||
|
||||
### Changed
|
||||
- When calling a SIP URI that looks like a phone number in the username and an IP in the domain, replace the domain with the one of the currently selected account to workaround issue with PBXs using IPs instead of domains in From header
|
||||
- Improved account creation page UI when push notifications aren't available
|
||||
- Improved called account display on incoming call screen when more than one account configured
|
||||
- Updated telecom package from beta to release candidate
|
||||
|
||||
### Fixed
|
||||
- Fixed transfer call view numpad button starting a new call instead of forwarding the current one
|
||||
- Fixed incoming call not displayed in call history depending on how the From & To headers are formatted (SDK fix)
|
||||
- Fixed crashes related to foreground service not being started
|
||||
- Fixed crash due to lateinit property not being initialized before used
|
||||
|
||||
## [6.0.4] - 2025-04-11
|
||||
|
||||
### Changed
|
||||
- Third party SIP accounts push notifications will be disabled and setting will be hidden unless if list of supported domains (to prevent issues, specifically when used with UDP transport protocol causing bigger packets getting lost)
|
||||
|
||||
### Fixed
|
||||
- Prevent refresh of views due to contacts changes to happen to frequently at startup
|
||||
- Prevent crash in Help view if app is built without Firebase
|
||||
|
||||
## [6.0.3] - 2025-04-04
|
||||
|
||||
### Added
|
||||
- Show alert when default account is disabled
|
||||
- Refesh list details when going back from background after one hour or more (when keep app alive using service is enabled)
|
||||
- Click to copy SIP URI in call history shortcut
|
||||
- Added developper settings, must click 8 times on version (in Help) to make it appear (E2E encryption for meetings & group calls setting was moved there)
|
||||
- Circular indicator while search is in progress in contacts lists
|
||||
|
||||
### Changed
|
||||
- Force some default values on notifications channels
|
||||
- Contacts list filter is now applied to new call / conversation & other contact pickers
|
||||
- Attach file icon stays visible while typing message in conversation instead of emoji picker icon
|
||||
|
||||
### Fixed
|
||||
- No default account being selected if the default one is removed
|
||||
- Navigation bar turning orange when opening search bar
|
||||
- Incoming call showed as video even if video is disabled locally
|
||||
- Concurrent modification crash in Contacts loader
|
||||
- Meetings list not properly sorted when CCMP is used
|
||||
- POST_NOTIFICATIONS permission check on old Android devices
|
||||
|
||||
## [6.0.2] - 2025-03-28
|
||||
|
||||
### Added
|
||||
- Show on top bar if FULL_SCREEN_INTENT permission isn't granted, clicking on it sends to the matching settings so user can fix it easily, without it incoming call screen won't be displayed if screen is off
|
||||
- Ring during incoming early media call setting added back
|
||||
- Added a floating action button to open dialpad during outgoing early media call
|
||||
|
||||
### Changed
|
||||
- Delete all related call history / conversations / meetings when removing an account
|
||||
- Delay / use a separated thread for heavy contacts related tasks to ensure call is correctly handled and foreground service is started quickly enough
|
||||
- Newly created account in app will be kept disabled until SMS code validation is done
|
||||
- Keep app alive foreground service notification no shows a content message to ease clicking on it to open the app & workaround a crash on some devices
|
||||
- Automatically show dialpad setting will now also work on new / transfer call while in call as well
|
||||
|
||||
### Fixed
|
||||
- Improved POST_NOTIFICATIONS permission check on Android 13 and newer, should prevent crashes
|
||||
- Fixed contact lookup if phone number starts by "00" instead of "+"
|
||||
- Fixed "delete all call history" sometimes not removing all call logs
|
||||
- Fixed LDAP / remote CardDAV contacts sometimes not displayed in contacts list when doing a search
|
||||
- Fixed issue where contact filter could be set to only show sip.linphone.org contacts even when third party account was being selected
|
||||
- Fixed sometimes wrong displayed SIP URI in detailed call history
|
||||
- Fixed invisible meeting icon in status bar
|
||||
- Fixed missed call count indicator behavior with some third party providers
|
||||
- Prevent today indicator & meeting icon in bottom nav bar from blinking / briefly appearing
|
||||
- Fixed bottom nav bar sometimes being hidden
|
||||
- Fixed missing share logs server URL when migrating from 5.2 if that value was removed back then
|
||||
- Other crashes fixed
|
||||
|
||||
## [6.0.1] - 2025-03-21
|
||||
|
||||
### Added
|
||||
- Start at boot & auto answer settings added back
|
||||
- Interface setting to have dialpad automatically opened in start call view
|
||||
- Replace "+" by "00" and do not apply prefix for calls & chat account settings
|
||||
- Setting to let user choose whether to record calls using MKV or SMFF format (the later allows to record H265/AV1 video but is a proprietary file format that can't be read outside of Linphone)
|
||||
|
||||
### Changed
|
||||
- Reverted the way of playing incoming call ringone (you may have to configure your own ringtone again), was causing various issues depending on devices/firmwares
|
||||
- Show all call history entries if only one account is configured (workaround for missing history for now until a proper fix will be done in SDK)
|
||||
|
||||
### Fixed
|
||||
- Issue preventing bluetooth Hearing Aids from working properly (and fixed earpiece/hearing aids icon)
|
||||
- Prevent Qr Code scanner to use static picture camera
|
||||
- Prevent user from connecting the same account multiple times
|
||||
- Quit menu visibility not updated when changing Keep Alive setting
|
||||
- Participant selection in group when typing "@"
|
||||
- Recordings order has been reversed to have newest ones at top
|
||||
- Improved message when network is not reachable due to "Wifi only mode" being enabled
|
||||
- Various crash & bug fixes
|
||||
|
||||
## [6.0.0] - 2025-03-11
|
||||
|
||||
|
|
|
|||
17
README.md
17
README.md
|
|
@ -1,5 +1,6 @@
|
|||
|
||||
[](https://gitlab.linphone.org/BC/public/linphone-android/commits/master) [](https://weblate.linphone.org/engage/linphone/?utm_source=widget)
|
||||
[](https://gitlab.linphone.org/BC/public/linphone-android/commits/master)
|
||||
[](https://weblate.linphone.org/engage/linphone/)
|
||||
|
||||
Linphone is an open source softphone for voice and video over IP calling and instant messaging.
|
||||
|
||||
|
|
@ -25,7 +26,7 @@ Linphone is dual licensed, and is available either :
|
|||
|
||||
### Documentation
|
||||
|
||||
- Supported features and RFCs : https://linphone.org/technical-corner/linphone/features
|
||||
- Supported features and RFCs : https://www.linphone.org/linphone-softphone/#linphone-fonctionnalites
|
||||
|
||||
- Linphone public wiki : https://wiki.linphone.org/xwiki/wiki/public/view/Linphone/
|
||||
|
||||
|
|
@ -35,7 +36,7 @@ Linphone is dual licensed, and is available either :
|
|||
|
||||
6.0.0 release is a completely new version, designed with UX/UI experts and marks a turning point in design, features, and user experience. The improvements make this version smoother and simpler for both developers and users.
|
||||
|
||||
You can take a look at the [CHANGELOG.md](CHANGELOG.md) file for a non-exhaustive list of changes of this new version and of the newly added features, the most exciting ones being the improved fluidity, a real multi-accounts support and asymetrical video in calls.
|
||||
You can take a look at the [CHANGELOG.md](CHANGELOG.md) file for a non-exhaustive list of changes of this new version and of the newly added features, the most exciting ones being the improved fluidity, a real multi-accounts support and asymmetrical video in calls.
|
||||
|
||||
This release only works on Android OS 9.0 and newer.
|
||||
|
||||
|
|
@ -161,6 +162,16 @@ 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.
|
||||
|
||||
<a href="https://weblate.linphone.org/engage/linphone/">
|
||||
<img src="https://weblate.linphone.org/widget/linphone/linphone-android-6-0/multi-auto.svg" alt="Translation status" />
|
||||
</a>
|
||||
|
||||
# CONTRIBUTIONS
|
||||
|
||||
In order to submit a patch for inclusion in linphone's source code:
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import com.android.build.gradle.internal.tasks.factory.dependsOn
|
||||
import com.google.firebase.crashlytics.buildtools.gradle.CrashlyticsExtension
|
||||
import com.google.gms.googleservices.GoogleServicesPlugin
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.BufferedReader
|
||||
import java.io.FileInputStream
|
||||
import java.util.Properties
|
||||
|
||||
|
|
@ -31,57 +31,57 @@ if (firebaseCloudMessagingAvailable) {
|
|||
println("google-services.json not found, disabling CloudMessaging feature")
|
||||
}
|
||||
|
||||
var gitBranch = ByteArrayOutputStream()
|
||||
var gitVersion = "6.0.0"
|
||||
var gitVersion = "6.1.0-alpha"
|
||||
var gitBranch = ""
|
||||
try {
|
||||
val gitDescribe = ProcessBuilder()
|
||||
.command("git", "describe", "--abbrev=0")
|
||||
.directory(project.rootDir)
|
||||
.start()
|
||||
.inputStream.bufferedReader().use(BufferedReader::readText)
|
||||
.trim()
|
||||
println("Git describe: $gitDescribe")
|
||||
|
||||
task("getGitVersion") {
|
||||
val gitVersionStream = ByteArrayOutputStream()
|
||||
val gitCommitsCount = ByteArrayOutputStream()
|
||||
val gitCommitHash = ByteArrayOutputStream()
|
||||
val gitCommitsCount = ProcessBuilder()
|
||||
.command("git", "rev-list", "$gitDescribe..HEAD", "--count")
|
||||
.directory(project.rootDir)
|
||||
.start()
|
||||
.inputStream.bufferedReader().use(BufferedReader::readText)
|
||||
.trim()
|
||||
println("Git commits count: $gitCommitsCount")
|
||||
|
||||
try {
|
||||
exec {
|
||||
commandLine("git", "describe", "--abbrev=0")
|
||||
standardOutput = gitVersionStream
|
||||
}
|
||||
exec {
|
||||
commandLine(
|
||||
"git",
|
||||
"rev-list",
|
||||
gitVersionStream.toString().trim() + "..HEAD",
|
||||
"--count",
|
||||
)
|
||||
standardOutput = gitCommitsCount
|
||||
}
|
||||
exec {
|
||||
commandLine("git", "rev-parse", "--short", "HEAD")
|
||||
standardOutput = gitCommitHash
|
||||
}
|
||||
exec {
|
||||
commandLine("git", "name-rev", "--name-only", "HEAD")
|
||||
standardOutput = gitBranch
|
||||
}
|
||||
val gitCommitHash = ProcessBuilder()
|
||||
.command("git", "rev-parse", "--short", "HEAD")
|
||||
.directory(project.rootDir)
|
||||
.start()
|
||||
.inputStream.bufferedReader().use(BufferedReader::readText)
|
||||
.trim()
|
||||
println("Git commit hash: $gitCommitHash")
|
||||
|
||||
gitVersion =
|
||||
if (gitCommitsCount.toString().trim().toInt() == 0) {
|
||||
gitVersionStream.toString().trim()
|
||||
} else {
|
||||
gitVersionStream.toString().trim() + "." +
|
||||
gitCommitsCount.toString()
|
||||
.trim() + "+" + gitCommitHash.toString().trim()
|
||||
}
|
||||
println("Git version: $gitVersion")
|
||||
} catch (e: Exception) {
|
||||
println("Git not found [$e], using $gitVersion")
|
||||
}
|
||||
project.version = gitVersion
|
||||
gitBranch = ProcessBuilder()
|
||||
.command("git", "name-rev", "--name-only", "HEAD")
|
||||
.directory(project.rootDir)
|
||||
.start()
|
||||
.inputStream.bufferedReader().use(BufferedReader::readText)
|
||||
.trim()
|
||||
println("Git branch name: $gitBranch")
|
||||
|
||||
gitVersion =
|
||||
if (gitCommitsCount.toInt() == 0) {
|
||||
gitDescribe
|
||||
} else {
|
||||
"$gitDescribe.$gitCommitsCount+$gitCommitHash"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("Git not found [$e], using $gitVersion")
|
||||
}
|
||||
project.tasks.preBuild.dependsOn("getGitVersion")
|
||||
println("Computed git version: $gitVersion")
|
||||
|
||||
configurations {
|
||||
implementation { isCanBeResolved = true }
|
||||
}
|
||||
task("linphoneSdkSource") {
|
||||
|
||||
tasks.register("linphoneSdkSource") {
|
||||
doLast {
|
||||
configurations.implementation.get().incoming.resolutionResult.allComponents.forEach {
|
||||
if (it.id.displayName.contains("linphone-sdk-android")) {
|
||||
|
|
@ -94,14 +94,14 @@ project.tasks.preBuild.dependsOn("linphoneSdkSource")
|
|||
|
||||
android {
|
||||
namespace = "org.linphone"
|
||||
compileSdk = 35
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = packageName
|
||||
minSdk = 28
|
||||
targetSdk = 35
|
||||
versionCode = 600000 // 6.00.000
|
||||
versionName = "6.0.0"
|
||||
targetSdk = 36
|
||||
versionCode = 601002 // 6.01.002
|
||||
versionName = "6.1.0-alpha"
|
||||
|
||||
manifestPlaceholders["appAuthRedirectScheme"] = packageName
|
||||
|
||||
|
|
@ -148,13 +148,16 @@ android {
|
|||
isDebuggable = true
|
||||
isJniDebuggable = true
|
||||
|
||||
val appVersion = gitVersion
|
||||
val appBranch = gitBranch
|
||||
println("Setting app version [$appVersion] app branch [$appBranch]")
|
||||
resValue("string", "linphone_app_version", appVersion)
|
||||
resValue("string", "linphone_app_branch", appBranch)
|
||||
if (useDifferentPackageNameForDebugBuild) {
|
||||
resValue("string", "file_provider", "$packageName.debug.fileprovider")
|
||||
} else {
|
||||
resValue("string", "file_provider", "$packageName.fileprovider")
|
||||
}
|
||||
resValue("string", "linphone_app_version", gitVersion.trim())
|
||||
resValue("string", "linphone_app_branch", gitBranch.toString().trim())
|
||||
resValue("string", "linphone_openid_callback_scheme", packageName)
|
||||
|
||||
if (crashlyticsAvailable) {
|
||||
|
|
@ -169,15 +172,19 @@ android {
|
|||
|
||||
getByName("release") {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
|
||||
val appVersion = gitVersion
|
||||
val appBranch = gitBranch
|
||||
println("Setting app version [$appVersion] app branch [$appBranch]")
|
||||
resValue("string", "linphone_app_version", appVersion)
|
||||
resValue("string", "linphone_app_branch", appBranch)
|
||||
resValue("string", "file_provider", "$packageName.fileprovider")
|
||||
resValue("string", "linphone_app_version", gitVersion.trim())
|
||||
resValue("string", "linphone_app_branch", gitBranch.toString().trim())
|
||||
resValue("string", "linphone_openid_callback_scheme", packageName)
|
||||
|
||||
if (crashlyticsAvailable) {
|
||||
|
|
@ -196,10 +203,6 @@ android {
|
|||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
dataBinding = true
|
||||
buildConfig = true
|
||||
|
|
@ -212,7 +215,6 @@ android {
|
|||
|
||||
dependencies {
|
||||
implementation(libs.androidx.annotations)
|
||||
implementation(libs.androidx.activity)
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.androidx.constraint.layout)
|
||||
implementation(libs.androidx.core.ktx)
|
||||
|
|
@ -220,6 +222,7 @@ dependencies {
|
|||
implementation(libs.androidx.telecom)
|
||||
implementation(libs.androidx.media)
|
||||
implementation(libs.androidx.recyclerview)
|
||||
implementation(libs.androidx.swiperefreshlayout)
|
||||
implementation(libs.androidx.slidingpanelayout)
|
||||
implementation(libs.androidx.window)
|
||||
implementation(libs.androidx.gridlayout)
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@
|
|||
android:localeConfig="@xml/locales_config"
|
||||
android:theme="@style/Theme.Linphone"
|
||||
android:appCategory="social"
|
||||
android:largeHeap="true"
|
||||
tools:targetApi="35">
|
||||
|
||||
<!-- Required for chat message & call notifications to be displayed in Android auto -->
|
||||
|
|
@ -151,7 +152,7 @@
|
|||
android:name=".ui.call.CallActivity"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:theme="@style/Theme.LinphoneInCall"
|
||||
android:launchMode="singleTask"
|
||||
android:launchMode="singleInstance"
|
||||
android:turnScreenOn="true"
|
||||
android:showWhenLocked="true"
|
||||
android:resizeableActivity="true"
|
||||
|
|
|
|||
|
|
@ -32,4 +32,7 @@
|
|||
<entry name="media_encryption">srtp</entry>
|
||||
<entry name="media_encryption_mandatory" overwrite="true">0</entry>
|
||||
</section>
|
||||
<section name="ui">
|
||||
<entry name="automatically_show_dialpad" overwrite="true">1</entry>
|
||||
</section>
|
||||
</config>
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ update_presence_model_timestamp_before_publish_expires_refresh=1
|
|||
[sound]
|
||||
#remove this property for any application that is not Linphone public version itself
|
||||
ec_calibrator_cool_tones=1
|
||||
disable_ringing=1
|
||||
disable_ringing=0
|
||||
|
||||
[audio]
|
||||
android_disable_audio_focus_requests=1
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
package org.linphone.compatibility
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.InetAddresses.isNumericAddress
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
|
|
@ -62,5 +63,9 @@ class Api29Compatibility {
|
|||
session.contentCaptureContext = ContentCaptureContext.forLocusId(conversationId)
|
||||
}
|
||||
}
|
||||
|
||||
fun isIpAddress(string: String): Boolean {
|
||||
return isNumericAddress(string)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,7 +47,9 @@ class Api31Compatibility {
|
|||
.build()
|
||||
)
|
||||
Log.i("$TAG PiP auto enter has been [${if (enable) "enabled" else "disabled"}]")
|
||||
} catch (ise: IllegalArgumentException) {
|
||||
} catch (iae: IllegalArgumentException) {
|
||||
Log.e("$TAG Can't set PiP params: $iae")
|
||||
} catch (ise: IllegalStateException) {
|
||||
Log.e("$TAG Can't set PiP params: $ise")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@
|
|||
package org.linphone.compatibility
|
||||
|
||||
import android.Manifest
|
||||
import android.app.ActivityOptions
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
|
||||
|
|
@ -34,5 +37,17 @@ class Api33Compatibility {
|
|||
Manifest.permission.CAMERA
|
||||
)
|
||||
}
|
||||
|
||||
fun isPostNotificationsPermissionGranted(context: Context): Boolean {
|
||||
return context.checkSelfPermission(
|
||||
Manifest.permission.POST_NOTIFICATIONS
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
fun getPendingIntentActivityOptions(): ActivityOptions {
|
||||
val options = ActivityOptions.makeBasic()
|
||||
options.isPendingIntentBackgroundActivityLaunchAllowed = true
|
||||
return options
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,12 +19,16 @@
|
|||
*/
|
||||
package org.linphone.compatibility
|
||||
|
||||
import android.app.ActivityOptions
|
||||
import android.app.Notification
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import androidx.annotation.RequiresApi
|
||||
import org.linphone.core.tools.Log
|
||||
|
|
@ -71,7 +75,27 @@ class Api34Compatibility {
|
|||
intent.data = "package:${context.packageName}".toUri()
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY)
|
||||
Log.i("$TAG Starting ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT")
|
||||
context.startActivity(intent, null)
|
||||
try {
|
||||
context.startActivity(intent, null)
|
||||
} catch (anfe: ActivityNotFoundException) {
|
||||
Log.e("$TAG Failed to start intent for granting full screen intent permission: $anfe")
|
||||
}
|
||||
}
|
||||
|
||||
fun sendPendingIntent(pendingIntent: PendingIntent, bundle: Bundle) {
|
||||
pendingIntent.send(bundle)
|
||||
}
|
||||
|
||||
fun getPendingIntentActivityOptions(creator: Boolean): ActivityOptions {
|
||||
val options = ActivityOptions.makeBasic()
|
||||
if (creator) {
|
||||
options.pendingIntentCreatorBackgroundActivityStartMode =
|
||||
ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED
|
||||
} else {
|
||||
options.pendingIntentBackgroundActivityStartMode =
|
||||
ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED
|
||||
}
|
||||
return options
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,18 +39,42 @@ class Api35Compatibility {
|
|||
Executors.newSingleThreadExecutor()
|
||||
) { info ->
|
||||
Log.i("==== Current startup information dump ====")
|
||||
Log.i("TYPE = ${startupTypeToString(info.startType)}")
|
||||
Log.i("STATE = ${startupStateToString(info.startupState)}")
|
||||
Log.i("REASON = ${startupReasonToString(info.reason)}")
|
||||
Log.i("FORCE STOPPED = ${if (info.wasForceStopped()) "yes" else "no"}")
|
||||
Log.i("PROCESS NAME = ${info.processName}")
|
||||
Log.i("=========================================")
|
||||
logAppStartupInfo(info)
|
||||
}
|
||||
|
||||
Log.i("==== Fetching last three startup reasons if available ====")
|
||||
val lastStartupInfo = activityManager.getHistoricalProcessStartReasons(3)
|
||||
for (info in lastStartupInfo) {
|
||||
Log.i("==== Previous startup information dump ====")
|
||||
logAppStartupInfo(info)
|
||||
}
|
||||
} catch (iae: IllegalArgumentException) {
|
||||
Log.e("$TAG Can't add application start info completion listener: $iae")
|
||||
}
|
||||
}
|
||||
|
||||
private fun logAppStartupInfo(info: ApplicationStartInfo) {
|
||||
Log.i("TYPE = ${startupTypeToString(info.startType)}")
|
||||
Log.i("STATE = ${startupStateToString(info.startupState)}")
|
||||
Log.i("REASON = ${startupReasonToString(info.reason)}")
|
||||
Log.i("START COMPONENT = ${startComponentToString(info.launchMode)}")
|
||||
Log.i("INTENT = ${info.intent}")
|
||||
Log.i("FORCE STOPPED = ${if (info.wasForceStopped()) "yes" else "no"}")
|
||||
Log.i("PROCESS NAME = ${info.processName}")
|
||||
Log.i("=========================================")
|
||||
}
|
||||
|
||||
private fun startComponentToString(component: Int): String {
|
||||
return when (component) {
|
||||
ApplicationStartInfo.START_COMPONENT_ACTIVITY -> "Activity"
|
||||
ApplicationStartInfo.START_COMPONENT_BROADCAST -> "Broadcast"
|
||||
ApplicationStartInfo.START_COMPONENT_CONTENT_PROVIDER -> "Content Provider"
|
||||
ApplicationStartInfo.START_COMPONENT_SERVICE -> "Service"
|
||||
ApplicationStartInfo.START_COMPONENT_OTHER -> "Other"
|
||||
else -> "Unexpected ($component)"
|
||||
}
|
||||
}
|
||||
|
||||
private fun startupTypeToString(type: Int): String {
|
||||
return when (type) {
|
||||
ApplicationStartInfo.START_TYPE_COLD -> "Cold"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2025 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.compatibility
|
||||
|
||||
import android.app.ActivityOptions
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.BAKLAVA)
|
||||
class Api36Compatibility {
|
||||
companion object {
|
||||
private const val TAG = "[API 36 Compatibility]"
|
||||
|
||||
fun getPendingIntentActivityOptions(creator: Boolean): ActivityOptions {
|
||||
val options = ActivityOptions.makeBasic()
|
||||
if (creator) {
|
||||
options.pendingIntentCreatorBackgroundActivityStartMode =
|
||||
ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOW_ALWAYS
|
||||
} else {
|
||||
options.pendingIntentBackgroundActivityStartMode =
|
||||
ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOW_ALWAYS
|
||||
}
|
||||
return options
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -22,12 +22,16 @@ package org.linphone.compatibility
|
|||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.app.ActivityOptions
|
||||
import android.app.Notification
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.Environment
|
||||
import android.util.Patterns
|
||||
import android.view.View
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import org.linphone.core.tools.Log
|
||||
|
|
@ -111,6 +115,13 @@ class Compatibility {
|
|||
return false
|
||||
}
|
||||
|
||||
fun isPostNotificationsPermissionGranted(context: Context): Boolean {
|
||||
if (Version.sdkAboveOrEqual(Version.API33_ANDROID_13_TIRAMISU)) {
|
||||
return Api33Compatibility.isPostNotificationsPermissionGranted(context)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun enterPipMode(activity: Activity): Boolean {
|
||||
if (Version.sdkStrictlyBelow(Version.API31_ANDROID_12)) {
|
||||
return Api28Compatibility.enterPipMode(activity)
|
||||
|
|
@ -179,5 +190,31 @@ class Compatibility {
|
|||
Api35Compatibility.setupAppStartupListener(context)
|
||||
}
|
||||
}
|
||||
|
||||
fun isIpAddress(string: String): Boolean {
|
||||
if (Version.sdkAboveOrEqual(Version.API29_ANDROID_10)) {
|
||||
return Api29Compatibility.isIpAddress(string)
|
||||
}
|
||||
return Patterns.IP_ADDRESS.matcher(string).matches()
|
||||
}
|
||||
|
||||
fun sendPendingIntent(pendingIntent: PendingIntent, bundle: Bundle) {
|
||||
if (Version.sdkAboveOrEqual(Version.API34_ANDROID_14_UPSIDE_DOWN_CAKE)) {
|
||||
return Api34Compatibility.sendPendingIntent(pendingIntent, bundle)
|
||||
}
|
||||
pendingIntent.send()
|
||||
}
|
||||
|
||||
fun getPendingIntentActivityOptions(creator: Boolean): ActivityOptions {
|
||||
if (Version.sdkAboveOrEqual(Version.API36_ANDROID_16_BAKLAVA)) {
|
||||
return Api36Compatibility.getPendingIntentActivityOptions(creator)
|
||||
} else if (Version.sdkAboveOrEqual(Version.API34_ANDROID_14_UPSIDE_DOWN_CAKE)) {
|
||||
return Api34Compatibility.getPendingIntentActivityOptions(creator)
|
||||
} else if (Version.sdkAboveOrEqual(Version.API33_ANDROID_13_TIRAMISU)) {
|
||||
return Api33Compatibility.getPendingIntentActivityOptions()
|
||||
}
|
||||
|
||||
return ActivityOptions.makeBasic()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ import android.graphics.Canvas
|
|||
import android.graphics.Paint
|
||||
import android.graphics.Rect
|
||||
import android.graphics.RectF
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.text.TextPaint
|
||||
import android.util.TypedValue
|
||||
import androidx.core.content.ContextCompat
|
||||
|
|
@ -34,7 +33,6 @@ import androidx.core.graphics.drawable.IconCompat
|
|||
import org.linphone.R
|
||||
import org.linphone.utils.AppUtils
|
||||
import androidx.core.graphics.createBitmap
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
|
||||
class AvatarGenerator(private val context: Context) {
|
||||
private var textSize: Float = AppUtils.getDimension(R.dimen.avatar_initials_text_size)
|
||||
|
|
@ -92,10 +90,6 @@ class AvatarGenerator(private val context: Context) {
|
|||
return bitmap
|
||||
}
|
||||
|
||||
fun buildDrawable(): BitmapDrawable {
|
||||
return buildBitmap(true).toDrawable(context.resources)
|
||||
}
|
||||
|
||||
fun buildIcon(): IconCompat {
|
||||
return IconCompat.createWithAdaptiveBitmap(buildBitmap(false))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,8 +29,14 @@ import androidx.annotation.WorkerThread
|
|||
import androidx.loader.app.LoaderManager
|
||||
import androidx.loader.content.CursorLoader
|
||||
import androidx.loader.content.Loader
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import java.lang.Exception
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
import org.linphone.core.Core
|
||||
import org.linphone.core.Factory
|
||||
import org.linphone.core.Friend
|
||||
import org.linphone.core.FriendList
|
||||
|
|
@ -61,7 +67,7 @@ class ContactLoader : LoaderManager.LoaderCallbacks<Cursor> {
|
|||
private const val MIN_INTERVAL_TO_WAIT_BEFORE_REFRESH = 300000L // 5 minutes
|
||||
}
|
||||
|
||||
private val friends = HashMap<String, Friend>()
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
@MainThread
|
||||
override fun onCreateLoader(id: Int, args: Bundle?): Loader<Cursor> {
|
||||
|
|
@ -93,8 +99,10 @@ class ContactLoader : LoaderManager.LoaderCallbacks<Cursor> {
|
|||
ContactsContract.Data.CONTACT_ID + " ASC"
|
||||
)
|
||||
|
||||
// Update at most once every X (see variable value for actual duration)
|
||||
loader.setUpdateThrottle(MIN_INTERVAL_TO_WAIT_BEFORE_REFRESH)
|
||||
// WARNING: this doesn't prevent to be called again in onLoadFinished,
|
||||
// it will only have for effect that the notified cursor will be the same as before
|
||||
// instead of a new one with updated content!
|
||||
// loader.setUpdateThrottle(MIN_INTERVAL_TO_WAIT_BEFORE_REFRESH)
|
||||
|
||||
return loader
|
||||
}
|
||||
|
|
@ -104,29 +112,38 @@ class ContactLoader : LoaderManager.LoaderCallbacks<Cursor> {
|
|||
if (cursor == null) {
|
||||
Log.e("$TAG Cursor is null!")
|
||||
return
|
||||
} else if (cursor.isClosed) {
|
||||
Log.e("$TAG Cursor is closed!")
|
||||
return
|
||||
}
|
||||
|
||||
Log.i("$TAG Load finished, found ${cursor.count} entries in cursor")
|
||||
if (cursor.isAfterLast) {
|
||||
Log.w("$TAG Cursor position is after last, it was probably already used, nothing to do")
|
||||
return
|
||||
}
|
||||
|
||||
coreContext.postOnCoreThread {
|
||||
parseFriends(cursor)
|
||||
val core = coreContext.core
|
||||
val state = core.globalState
|
||||
if (state == GlobalState.Shutdown || state == GlobalState.Off) {
|
||||
Log.w("$TAG Core is being stopped or already destroyed, abort")
|
||||
} else {
|
||||
scope.launch {
|
||||
parseFriends(core, cursor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainThread
|
||||
override fun onLoaderReset(loader: Loader<Cursor>) {
|
||||
Log.i("$TAG Loader reset")
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun parseFriends(cursor: Cursor) {
|
||||
val core = coreContext.core
|
||||
|
||||
val state = core.globalState
|
||||
if (state == GlobalState.Shutdown || state == GlobalState.Off) {
|
||||
Log.w("$TAG Core is being stopped or already destroyed, abort")
|
||||
return
|
||||
}
|
||||
|
||||
private fun parseFriends(core: Core, cursor: Cursor) {
|
||||
try {
|
||||
val contactIdColumn = cursor.getColumnIndexOrThrow(ContactsContract.Data.CONTACT_ID)
|
||||
val mimetypeColumn = cursor.getColumnIndexOrThrow(ContactsContract.Data.MIMETYPE)
|
||||
|
|
@ -164,6 +181,8 @@ class ContactLoader : LoaderManager.LoaderCallbacks<Cursor> {
|
|||
val familyNameColumn = cursor.getColumnIndexOrThrow(
|
||||
ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME
|
||||
)
|
||||
|
||||
val friends = HashMap<String, Friend>()
|
||||
while (!cursor.isClosed && cursor.moveToNext()) {
|
||||
try {
|
||||
val id: String = cursor.getString(contactIdColumn)
|
||||
|
|
@ -219,14 +238,9 @@ class ContactLoader : LoaderManager.LoaderCallbacks<Cursor> {
|
|||
}
|
||||
|
||||
if (!number.isNullOrEmpty()) {
|
||||
if (friend.phoneNumbersWithLabel.find {
|
||||
PhoneNumberUtils.arePhoneNumberWeakEqual(it.phoneNumber, number)
|
||||
} == null
|
||||
) {
|
||||
val phoneNumber = Factory.instance()
|
||||
.createFriendPhoneNumber(number, label)
|
||||
friend.addPhoneNumberWithLabel(phoneNumber)
|
||||
}
|
||||
val phoneNumber = Factory.instance()
|
||||
.createFriendPhoneNumber(number, label)
|
||||
friend.addPhoneNumberWithLabel(phoneNumber)
|
||||
}
|
||||
}
|
||||
ContactsContract.CommonDataKinds.SipAddress.CONTENT_ITEM_TYPE -> {
|
||||
|
|
@ -250,17 +264,14 @@ class ContactLoader : LoaderManager.LoaderCallbacks<Cursor> {
|
|||
}
|
||||
}
|
||||
ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE -> {
|
||||
val vCard = friend.vcard
|
||||
if (vCard != null) {
|
||||
val givenName: String? = cursor.getString(givenNameColumn)
|
||||
if (!givenName.isNullOrEmpty()) {
|
||||
vCard.givenName = givenName
|
||||
}
|
||||
val givenName: String? = cursor.getString(givenNameColumn)
|
||||
if (!givenName.isNullOrEmpty()) {
|
||||
friend.firstName = givenName
|
||||
}
|
||||
|
||||
val familyName: String? = cursor.getString(familyNameColumn)
|
||||
if (!familyName.isNullOrEmpty()) {
|
||||
vCard.familyName = familyName
|
||||
}
|
||||
val familyName: String? = cursor.getString(familyNameColumn)
|
||||
if (!familyName.isNullOrEmpty()) {
|
||||
friend.lastName = familyName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -273,9 +284,9 @@ class ContactLoader : LoaderManager.LoaderCallbacks<Cursor> {
|
|||
|
||||
Log.i("$TAG Contacts parsed, posting another task to handle adding them (or not)")
|
||||
// Re-post another task to allow other tasks on Core thread
|
||||
coreContext.postOnCoreThread {
|
||||
addFriendsIfNeeded()
|
||||
}
|
||||
coreContext.postOnCoreThreadWhenAvailableForHeavyTask({
|
||||
addFriendsIfNeeded(friends)
|
||||
}, "add friends to Core")
|
||||
} catch (sde: StaleDataException) {
|
||||
Log.e("$TAG State Data Exception: $sde")
|
||||
} catch (ise: IllegalStateException) {
|
||||
|
|
@ -286,12 +297,12 @@ class ContactLoader : LoaderManager.LoaderCallbacks<Cursor> {
|
|||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun addFriendsIfNeeded() {
|
||||
private fun addFriendsIfNeeded(friends: HashMap<String, Friend>) {
|
||||
val core = coreContext.core
|
||||
|
||||
if (core.globalState == GlobalState.Shutdown || core.globalState == GlobalState.Off) {
|
||||
Log.w("$TAG Core is being stopped or already destroyed, abort")
|
||||
} else if (friends.isEmpty) {
|
||||
} else if (friends.isEmpty()) {
|
||||
Log.w("$TAG No friend created!")
|
||||
} else {
|
||||
Log.i("$TAG ${friends.size} friends fetched")
|
||||
|
|
@ -313,83 +324,16 @@ class ContactLoader : LoaderManager.LoaderCallbacks<Cursor> {
|
|||
}
|
||||
Log.i("$TAG Friends added")
|
||||
} else {
|
||||
val friendsArray = friends.values.toTypedArray()
|
||||
Log.i(
|
||||
"$TAG Friend list [$NATIVE_ADDRESS_BOOK_FRIEND_LIST] found, synchronizing existing friends with new ones"
|
||||
)
|
||||
for (localFriend in friendsList.friends) {
|
||||
val newlyFetchedFriend = friends[localFriend.refKey]
|
||||
if (newlyFetchedFriend != null) {
|
||||
friends.remove(localFriend.refKey)
|
||||
localFriend.nativeUri =
|
||||
newlyFetchedFriend.nativeUri // Native URI isn't stored in linphone database, needs to be updated
|
||||
if (newlyFetchedFriend.vcard?.asVcard4String() == localFriend.vcard?.asVcard4String()) continue
|
||||
|
||||
localFriend.edit()
|
||||
// Update basic fields that may have changed
|
||||
localFriend.name = newlyFetchedFriend.name
|
||||
localFriend.organization = newlyFetchedFriend.organization
|
||||
localFriend.jobTitle = newlyFetchedFriend.jobTitle
|
||||
localFriend.photo = newlyFetchedFriend.photo
|
||||
|
||||
// Clear local friend phone numbers & add all newly fetched one ones
|
||||
var atLeastAPhoneNumberWasRemoved = false
|
||||
for (phoneNumber in localFriend.phoneNumbersWithLabel) {
|
||||
val found = newlyFetchedFriend.phoneNumbers.find {
|
||||
it == phoneNumber.phoneNumber
|
||||
}
|
||||
if (found == null) {
|
||||
atLeastAPhoneNumberWasRemoved = true
|
||||
}
|
||||
localFriend.removePhoneNumberWithLabel(phoneNumber)
|
||||
}
|
||||
for (phoneNumber in newlyFetchedFriend.phoneNumbersWithLabel) {
|
||||
localFriend.addPhoneNumberWithLabel(phoneNumber)
|
||||
}
|
||||
|
||||
// If at least a phone number was removed, remove all SIP address from local friend before adding all from newly fetched one.
|
||||
// If none was removed, simply add SIP addresses from fetched contact that aren't already in the local friend.
|
||||
if (atLeastAPhoneNumberWasRemoved) {
|
||||
Log.w(
|
||||
"$TAG At least a phone number was removed from native contact [${localFriend.name}], clearing all SIP addresses from local friend before adding back the ones that still exists"
|
||||
)
|
||||
for (sipAddress in localFriend.addresses) {
|
||||
localFriend.removeAddress(sipAddress)
|
||||
}
|
||||
}
|
||||
|
||||
// Adding only newly added SIP address(es) in native contact if any
|
||||
for (sipAddress in newlyFetchedFriend.addresses) {
|
||||
localFriend.addAddress(sipAddress)
|
||||
}
|
||||
localFriend.done()
|
||||
} else {
|
||||
Log.i(
|
||||
"$TAG Friend [${localFriend.name}] with ref key [${localFriend.refKey}] not found in newly fetched batch, removing it"
|
||||
)
|
||||
friendsList.removeFriend(localFriend)
|
||||
}
|
||||
val changes = friendsList.synchronizeFriendsWith(friendsArray)
|
||||
if (changes) {
|
||||
Log.i("$TAG Locally stored friends synchronized with native address book")
|
||||
} else {
|
||||
Log.i("$TAG No changes detected between native address book and local friends storage")
|
||||
}
|
||||
|
||||
// Check for newly created friends since last sync
|
||||
val localFriends = friendsList.friends
|
||||
for ((key, newFriend) in friends.entries) {
|
||||
val found = localFriends.find {
|
||||
it.refKey == key
|
||||
}
|
||||
if (found == null) {
|
||||
if (newFriend.refKey == null) {
|
||||
Log.w(
|
||||
"$TAG Found friend [${newFriend.name}] with no refKey, using ID [$key]"
|
||||
)
|
||||
newFriend.refKey = key
|
||||
}
|
||||
Log.i(
|
||||
"$TAG Friend [${newFriend.name}] with ref key [${newFriend.refKey}] not found in currently stored list, adding it"
|
||||
)
|
||||
friendsList.addLocalFriend(newFriend)
|
||||
}
|
||||
}
|
||||
Log.i("$TAG Friends synchronized")
|
||||
}
|
||||
friends.clear()
|
||||
|
||||
|
|
|
|||
|
|
@ -19,9 +19,7 @@
|
|||
*/
|
||||
package org.linphone.contacts
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ContentUris
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
|
|
@ -29,9 +27,9 @@ import android.provider.ContactsContract
|
|||
import androidx.annotation.MainThread
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.app.Person
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.core.text.isDigitsOnly
|
||||
import androidx.loader.app.LoaderManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
|
@ -65,6 +63,7 @@ import org.linphone.utils.ImageUtils
|
|||
import org.linphone.utils.LinphoneUtils
|
||||
import org.linphone.utils.PhoneNumberUtils
|
||||
import org.linphone.utils.ShortcutUtils
|
||||
import java.io.FileNotFoundException
|
||||
|
||||
class ContactsManager
|
||||
@UiThread
|
||||
|
|
@ -73,7 +72,7 @@ class ContactsManager
|
|||
private const val TAG = "[Contacts Manager]"
|
||||
|
||||
private const val DELAY_BEFORE_RELOADING_CONTACTS_AFTER_PRESENCE_RECEIVED = 1000L // 1 second
|
||||
private const val FRIEND_LIST_TEMPORARY_STORED_NATIVE = "TempNativeContacts"
|
||||
private const val DELAY_BEFORE_RELOADING_CONTACTS_AFTER_MAGIC_SEARCH_RESULT = 1000L // 1 second
|
||||
private const val FRIEND_LIST_TEMPORARY_STORED_REMOTE_DIRECTORY = "TempRemoteDirectoryContacts"
|
||||
}
|
||||
|
||||
|
|
@ -89,53 +88,72 @@ class ContactsManager
|
|||
private val unknownRemoteContactDirectoriesContactsMap = arrayListOf<String>()
|
||||
|
||||
private val coroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private var reloadContactsJob: Job? = null
|
||||
private var reloadPresenceContactsJob: Job? = null
|
||||
private var reloadRemoteContactsJob: Job? = null
|
||||
|
||||
private var loadContactsOnlyFromDefaultDirectory = true
|
||||
|
||||
private val magicSearchListener = object : MagicSearchListenerStub() {
|
||||
@WorkerThread
|
||||
override fun onSearchResultsReceived(magicSearch: MagicSearch) {
|
||||
var queriedSipUri = ""
|
||||
for ((key, value) in magicSearchMap.entries) {
|
||||
if (value == magicSearch) {
|
||||
queriedSipUri = key
|
||||
}
|
||||
}
|
||||
|
||||
val results = magicSearch.lastSearch
|
||||
Log.i("$TAG [${results.size}] magic search results available")
|
||||
Log.i(
|
||||
"$TAG [${results.size}] magic search results available for query upon SIP URI [$queriedSipUri]"
|
||||
)
|
||||
|
||||
var found = false
|
||||
if (results.isNotEmpty()) {
|
||||
val result = results.first {
|
||||
it.friend != null
|
||||
}
|
||||
val result = results.first { it.friend != null }
|
||||
if (result != null) {
|
||||
val friend = result.friend!!
|
||||
Log.i("$TAG Found matching friend in source [${result.sourceFlags}]")
|
||||
found = true
|
||||
val address = result.address?.asStringUriOnly().orEmpty()
|
||||
if (address.isEmpty() || (queriedSipUri.isNotEmpty() && queriedSipUri != address)) {
|
||||
Log.w("$TAG Received friend [${friend.name}] with SIP URI [$address] doesn't match queried SIP URI [$queriedSipUri]")
|
||||
} else {
|
||||
found = true
|
||||
reloadRemoteContactsJob?.cancel()
|
||||
|
||||
// Store friend in app's cache to be re-used in call history, conversations, etc...
|
||||
val temporaryFriendList = getTemporaryFriendList(native = false)
|
||||
temporaryFriendList.addFriend(friend)
|
||||
newContactAdded(friend)
|
||||
Log.i(
|
||||
"$TAG Stored discovered friend [${friend.name}] in temporary friend list, for later use"
|
||||
)
|
||||
// Store friend in app's cache to be re-used in call history, conversations, etc...
|
||||
val temporaryFriendList = getRemoteContactDirectoriesCacheFriendList()
|
||||
temporaryFriendList.addFriend(friend)
|
||||
newContactAdded(friend)
|
||||
Log.i(
|
||||
"$TAG Stored discovered friend [${friend.name}] in temporary friend list, for later use"
|
||||
)
|
||||
|
||||
for (listener in listeners) {
|
||||
listener.onContactFoundInRemoteDirectory(friend)
|
||||
for (listener in listeners) {
|
||||
listener.onContactFoundInRemoteDirectory(friend)
|
||||
}
|
||||
|
||||
reloadRemoteContactsJob = coroutineScope.launch {
|
||||
delay(DELAY_BEFORE_RELOADING_CONTACTS_AFTER_MAGIC_SEARCH_RESULT)
|
||||
coreContext.postOnCoreThread {
|
||||
Log.i("$TAG At least a new SIP address was discovered, reloading contacts")
|
||||
conferenceAvatarMap.values.forEach(ContactAvatarModel::destroy)
|
||||
conferenceAvatarMap.clear()
|
||||
|
||||
notifyContactsListChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var foundKey = ""
|
||||
for ((key, value) in magicSearchMap.entries) {
|
||||
if (value == magicSearch) {
|
||||
foundKey = key
|
||||
}
|
||||
}
|
||||
if (foundKey.isNotEmpty()) {
|
||||
magicSearchMap.remove(foundKey)
|
||||
if (queriedSipUri.isNotEmpty()) {
|
||||
magicSearchMap.remove(queriedSipUri)
|
||||
if (!found) {
|
||||
Log.i(
|
||||
"$TAG SIP URI [$foundKey] wasn't found in remote directories, adding it to unknown list to prevent further queries"
|
||||
"$TAG SIP URI [$queriedSipUri] wasn't found in remote directories, adding it to unknown list to prevent further queries"
|
||||
)
|
||||
unknownRemoteContactDirectoriesContactsMap.add(foundKey)
|
||||
unknownRemoteContactDirectoriesContactsMap.add(queriedSipUri)
|
||||
}
|
||||
}
|
||||
magicSearch.removeListener(this)
|
||||
|
|
@ -147,7 +165,26 @@ class ContactsManager
|
|||
override fun onPresenceReceived(friendList: FriendList, friends: Array<out Friend?>) {
|
||||
if (friendList.isSubscriptionBodyless) {
|
||||
Log.i("$TAG Bodyless friendlist [${friendList.displayName}] presence received")
|
||||
notifyContactsListChanged()
|
||||
|
||||
var atLeastOneFriendAdded = false
|
||||
for (friend in friends) {
|
||||
if (friend != null) {
|
||||
val address = friend.address
|
||||
if (address != null) {
|
||||
Log.i(
|
||||
"$TAG Newly discovered SIP Address [${address.asStringUriOnly()}] for friend [${friend.name}] in bodyless list [${friendList.displayName}]"
|
||||
)
|
||||
newContactAddedWithSipUri(friend, address)
|
||||
atLeastOneFriendAdded = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (atLeastOneFriendAdded) {
|
||||
notifyContactsListChanged()
|
||||
} else {
|
||||
Log.w("$TAG No new friend detected in the received bodyless friendlist, not refreshing contacts in app")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -157,7 +194,7 @@ class ContactsManager
|
|||
friend: Friend,
|
||||
sipUri: String
|
||||
) {
|
||||
reloadContactsJob?.cancel()
|
||||
reloadPresenceContactsJob?.cancel()
|
||||
Log.d(
|
||||
"$TAG Newly discovered SIP Address [$sipUri] for friend [${friend.name}] in list [${friendList.displayName}]"
|
||||
)
|
||||
|
|
@ -168,12 +205,12 @@ class ContactsManager
|
|||
friend.addAddress(address)
|
||||
friend.done()
|
||||
|
||||
newContactAddedWithSipUri(friend, sipUri)
|
||||
newContactAddedWithSipUri(friend, address)
|
||||
} else {
|
||||
Log.e("$TAG Failed to parse SIP URI [$sipUri] as Address!")
|
||||
}
|
||||
|
||||
reloadContactsJob = coroutineScope.launch {
|
||||
reloadPresenceContactsJob = coroutineScope.launch {
|
||||
delay(DELAY_BEFORE_RELOADING_CONTACTS_AFTER_PRESENCE_RECEIVED)
|
||||
coreContext.postOnCoreThread {
|
||||
Log.i("$TAG At least a new SIP address was discovered, reloading contacts")
|
||||
|
|
@ -306,7 +343,8 @@ class ContactsManager
|
|||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun newContactAddedWithSipUri(friend: Friend, sipUri: String) {
|
||||
private fun newContactAddedWithSipUri(friend: Friend, address: Address) {
|
||||
val sipUri = address.asStringUriOnly()
|
||||
if (unknownContactsAvatarsMap.keys.contains(sipUri)) {
|
||||
Log.d("$TAG Found SIP URI [$sipUri] in unknownContactsAvatarsMap, removing it")
|
||||
val oldModel = unknownContactsAvatarsMap[sipUri]
|
||||
|
|
@ -317,7 +355,6 @@ class ContactsManager
|
|||
"$TAG Found SIP URI [$sipUri] in knownContactsAvatarsMap, forcing presence update"
|
||||
)
|
||||
val oldModel = knownContactsAvatarsMap[sipUri]
|
||||
val address = Factory.instance().createAddress(sipUri)
|
||||
oldModel?.update(address)
|
||||
} else {
|
||||
Log.i(
|
||||
|
|
@ -331,12 +368,8 @@ class ContactsManager
|
|||
@WorkerThread
|
||||
fun newContactAdded(friend: Friend) {
|
||||
for (sipAddress in friend.addresses) {
|
||||
newContactAddedWithSipUri(friend, sipAddress.asStringUriOnly())
|
||||
newContactAddedWithSipUri(friend, sipAddress)
|
||||
}
|
||||
|
||||
conferenceAvatarMap.values.forEach(ContactAvatarModel::destroy)
|
||||
conferenceAvatarMap.clear()
|
||||
notifyContactsListChanged()
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
|
|
@ -369,14 +402,6 @@ class ContactsManager
|
|||
nativeContactsLoaded = true
|
||||
Log.i("$TAG Native contacts have been loaded, cleaning avatars maps")
|
||||
|
||||
val core = coreContext.core
|
||||
val found = getTemporaryFriendList(native = true)
|
||||
val count = found.friends.size
|
||||
Log.i(
|
||||
"$TAG Found temporary friend list with [$count] friends, removing it as no longer necessary"
|
||||
)
|
||||
core.removeFriendList(found)
|
||||
|
||||
knownContactsAvatarsMap.values.forEach(ContactAvatarModel::destroy)
|
||||
knownContactsAvatarsMap.clear()
|
||||
unknownContactsAvatarsMap.values.forEach(ContactAvatarModel::destroy)
|
||||
|
|
@ -387,8 +412,9 @@ class ContactsManager
|
|||
|
||||
notifyContactsListChanged()
|
||||
|
||||
Log.i("$TAG Native contacts have been loaded, creating chat rooms shortcuts")
|
||||
ShortcutUtils.createShortcutsToChatRooms(coreContext.context)
|
||||
Log.i("$TAG Native contacts have been loaded")
|
||||
// No longer create chat room shortcuts depending on most recents ones, create it when a message is sent or received instead
|
||||
// ShortcutUtils.createShortcutsToChatRooms(coreContext.context)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
|
|
@ -414,16 +440,15 @@ class ContactsManager
|
|||
|
||||
@WorkerThread
|
||||
fun findContactByAddress(address: Address): Friend? {
|
||||
val sipUri = LinphoneUtils.getAddressAsCleanStringUriOnly(address)
|
||||
Log.d("$TAG Looking for friend with SIP URI [$sipUri]")
|
||||
|
||||
val username = address.username
|
||||
Log.i("$TAG Looking for friend matching SIP address [${address.asStringUriOnly()}]")
|
||||
val found = coreContext.core.findFriend(address)
|
||||
if (found != null) {
|
||||
Log.d("$TAG Friend [${found.name}] was found using SIP URI [$sipUri]")
|
||||
Log.i("$TAG Found friend [${found.name}] matching SIP address [${address.asStringUriOnly()}]")
|
||||
return found
|
||||
}
|
||||
|
||||
val username = address.username
|
||||
val sipUri = LinphoneUtils.getAddressAsCleanStringUriOnly(address)
|
||||
// Start an async query in Magic Search in case LDAP or remote CardDAV is configured
|
||||
val remoteContactDirectories = coreContext.core.remoteContactDirectories
|
||||
if (remoteContactDirectories.isNotEmpty() && !magicSearchMap.keys.contains(sipUri) && !unknownRemoteContactDirectoriesContactsMap.contains(
|
||||
|
|
@ -446,33 +471,15 @@ class ContactsManager
|
|||
)
|
||||
}
|
||||
|
||||
val sipAddress = if (sipUri.startsWith("sip:")) {
|
||||
sipUri.substring("sip:".length)
|
||||
} else if (sipUri.startsWith("sips:")) {
|
||||
sipUri.substring("sips:".length)
|
||||
} else {
|
||||
sipUri
|
||||
}
|
||||
|
||||
return if (!username.isNullOrEmpty() && username.startsWith("+")) {
|
||||
Log.d("$TAG Looking for friend with phone number [$username]")
|
||||
return if (!username.isNullOrEmpty() && (username.startsWith("+") || username.isDigitsOnly())) {
|
||||
Log.i("$TAG Looking for friend using phone number [$username]")
|
||||
val foundUsingPhoneNumber = coreContext.core.findFriendByPhoneNumber(username)
|
||||
if (foundUsingPhoneNumber != null) {
|
||||
Log.d(
|
||||
"$TAG Friend [${foundUsingPhoneNumber.name}] was found using phone number [$username]"
|
||||
)
|
||||
foundUsingPhoneNumber
|
||||
} else {
|
||||
Log.d(
|
||||
"$TAG Friend wasn't found using phone number [$username], looking in native address book directly"
|
||||
)
|
||||
findNativeContact(sipAddress, username, true)
|
||||
Log.i("$TAG Found friend [${foundUsingPhoneNumber.name}] matching phone number [$username]")
|
||||
}
|
||||
foundUsingPhoneNumber
|
||||
} else {
|
||||
Log.d(
|
||||
"$TAG Friend wasn't found using SIP address [$sipAddress] and username [$username] isn't a phone number, looking in native address book directly"
|
||||
)
|
||||
findNativeContact(sipAddress, username.orEmpty(), false)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -516,7 +523,7 @@ class ContactsManager
|
|||
model
|
||||
} else {
|
||||
Log.d("$TAG Looking for friend matching SIP URI [$key]")
|
||||
val friend = coreContext.contactsManager.findContactByAddress(clone)
|
||||
val friend = findContactByAddress(clone)
|
||||
if (friend != null) {
|
||||
Log.d("$TAG Matching friend [${friend.name}] found for SIP URI [$key]")
|
||||
val model = ContactAvatarModel(friend, address)
|
||||
|
|
@ -578,7 +585,7 @@ class ContactsManager
|
|||
fun isContactTemporary(friend: Friend, allowNullFriendList: Boolean = false): Boolean {
|
||||
val friendList = friend.friendList
|
||||
if (friendList == null && !allowNullFriendList) return true
|
||||
return friendList?.displayName == FRIEND_LIST_TEMPORARY_STORED_NATIVE || friendList?.displayName == FRIEND_LIST_TEMPORARY_STORED_REMOTE_DIRECTORY
|
||||
return friendList?.type == FriendList.Type.ApplicationCache
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
|
|
@ -593,14 +600,16 @@ class ContactsManager
|
|||
}
|
||||
|
||||
val context = coreContext.context
|
||||
if (ActivityCompat.checkSelfPermission(
|
||||
ShortcutUtils.removeLegacyShortcuts(context)
|
||||
// No longer create chat room shortcuts depending on most recents ones, create it when a message is sent or received instead
|
||||
/*if (ActivityCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.READ_CONTACTS
|
||||
) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
Log.w("$TAG READ_CONTACTS permission was denied, creating chat rooms shortcuts")
|
||||
Log.w("$TAG READ_CONTACTS permission was denied, creating chat rooms shortcuts now")
|
||||
ShortcutUtils.createShortcutsToChatRooms(context)
|
||||
}
|
||||
}*/
|
||||
|
||||
for (list in core.friendsLists) {
|
||||
if (list.type == FriendList.Type.CardDAV && !list.uri.isNullOrEmpty()) {
|
||||
|
|
@ -625,13 +634,14 @@ class ContactsManager
|
|||
}
|
||||
|
||||
@WorkerThread
|
||||
fun getTemporaryFriendList(native: Boolean): FriendList {
|
||||
fun getRemoteContactDirectoriesCacheFriendList(): FriendList {
|
||||
val core = coreContext.core
|
||||
val name = if (native) FRIEND_LIST_TEMPORARY_STORED_NATIVE else FRIEND_LIST_TEMPORARY_STORED_REMOTE_DIRECTORY
|
||||
val name = FRIEND_LIST_TEMPORARY_STORED_REMOTE_DIRECTORY
|
||||
val temporaryFriendList = core.getFriendListByName(name) ?: core.createFriendList()
|
||||
if (temporaryFriendList.displayName.isNullOrEmpty()) {
|
||||
temporaryFriendList.isDatabaseStorageEnabled = false
|
||||
temporaryFriendList.displayName = name
|
||||
temporaryFriendList.type = FriendList.Type.ApplicationCache
|
||||
core.addFriendList(temporaryFriendList)
|
||||
Log.i(
|
||||
"$TAG Created temporary friend list with name [$name]"
|
||||
|
|
@ -640,14 +650,6 @@ class ContactsManager
|
|||
return temporaryFriendList
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun findNativeContact(address: String, username: String, searchAsPhoneNumber: Boolean): Friend? {
|
||||
// As long as read contacts permission is granted, friends will be stored in DB,
|
||||
// so if Core didn't find a matching item it in the FriendList, there's no reason the native address book
|
||||
// shall contain a matching contact.
|
||||
return null
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun getMePerson(localAddress: Address): Person {
|
||||
val account = coreContext.core.accountList.find {
|
||||
|
|
@ -656,7 +658,7 @@ class ContactsManager
|
|||
val name = account?.params?.identityAddress?.displayName ?: LinphoneUtils.getDisplayName(
|
||||
localAddress
|
||||
)
|
||||
val personBuilder = Person.Builder().setName(name)
|
||||
val personBuilder = Person.Builder().setName(name.ifEmpty { "Unknown" })
|
||||
|
||||
val photo = account?.params?.pictureUri.orEmpty()
|
||||
val bm = ImageUtils.getBitmap(coreContext.context, photo)
|
||||
|
|
@ -711,7 +713,7 @@ fun Friend.getAvatarBitmap(round: Boolean = false): Bitmap? {
|
|||
photo ?: getNativeContactPictureUri()?.toString(),
|
||||
round
|
||||
)
|
||||
} catch (numberFormatException: NumberFormatException) {
|
||||
} catch (_: NumberFormatException) {
|
||||
// Expected for contacts created by Linphone
|
||||
}
|
||||
return null
|
||||
|
|
@ -739,6 +741,8 @@ fun Friend.getNativeContactPictureUri(): Uri? {
|
|||
fd.close()
|
||||
return pictureUri
|
||||
}
|
||||
} catch (fnfe: FileNotFoundException) {
|
||||
Log.w("[Contacts Manager] Can't open [$pictureUri] for contact [$name]: $fnfe")
|
||||
} catch (e: Exception) {
|
||||
Log.e("[Contacts Manager] Can't open [$pictureUri] for contact [$name]: $e")
|
||||
}
|
||||
|
|
@ -748,7 +752,7 @@ fun Friend.getNativeContactPictureUri(): Uri? {
|
|||
lookupUri,
|
||||
ContactsContract.Contacts.Photo.CONTENT_DIRECTORY
|
||||
)
|
||||
} catch (numberFormatException: NumberFormatException) {
|
||||
} catch (_: NumberFormatException) {
|
||||
// Expected for contacts created by Linphone
|
||||
}
|
||||
}
|
||||
|
|
@ -757,7 +761,25 @@ fun Friend.getNativeContactPictureUri(): Uri? {
|
|||
|
||||
@WorkerThread
|
||||
fun Friend.getPerson(): Person {
|
||||
val personBuilder = Person.Builder().setName(name)
|
||||
val personBuilder = Person.Builder()
|
||||
val personName = if (name.orEmpty().isNotEmpty()) {
|
||||
name
|
||||
} else {
|
||||
if (!lastName.isNullOrEmpty() || !firstName.isNullOrEmpty()) {
|
||||
Log.w("[Friend] Name is null or empty, using first and last name")
|
||||
"$firstName $lastName".trim()
|
||||
} else if (!organization.isNullOrEmpty()) {
|
||||
Log.w("[Friend] Name, first name & last name are null or empty, using organization instead")
|
||||
organization
|
||||
} else if (!jobTitle.isNullOrEmpty()) {
|
||||
Log.w("[Friend] Name, first and last names & organization are null or empty, using job title instead")
|
||||
jobTitle
|
||||
} else {
|
||||
Log.e("[Friend] No identification field filled for this friend!")
|
||||
"Unknown"
|
||||
}
|
||||
}
|
||||
personBuilder.setName(personName.orEmpty().ifEmpty { "Unknown" })
|
||||
|
||||
val bm: Bitmap? = getAvatarBitmap()
|
||||
personBuilder.setIcon(
|
||||
|
|
@ -765,7 +787,7 @@ fun Friend.getPerson(): Person {
|
|||
Log.i(
|
||||
"[Friend] Can't use friend [$name] picture path, generating avatar based on initials"
|
||||
)
|
||||
AvatarGenerator(coreContext.context).setInitials(AppUtils.getInitials(name.orEmpty())).buildIcon()
|
||||
AvatarGenerator(coreContext.context).setInitials(AppUtils.getInitials(personName.orEmpty())).buildIcon()
|
||||
} else {
|
||||
IconCompat.createWithAdaptiveBitmap(bm)
|
||||
}
|
||||
|
|
@ -780,6 +802,7 @@ fun Friend.getPerson(): Person {
|
|||
@WorkerThread
|
||||
fun Friend.getListOfSipAddresses(): ArrayList<Address> {
|
||||
val addressesList = arrayListOf<Address>()
|
||||
if (corePreferences.hideSipAddresses) return addressesList
|
||||
|
||||
for (address in addresses) {
|
||||
if (addressesList.find { it.weakEqual(address) } == null) {
|
||||
|
|
@ -794,7 +817,12 @@ fun Friend.getListOfSipAddresses(): ArrayList<Address> {
|
|||
fun Friend.getListOfSipAddressesAndPhoneNumbers(listener: ContactNumberOrAddressClickListener): ArrayList<ContactNumberOrAddressModel> {
|
||||
val addressesAndNumbers = arrayListOf<ContactNumberOrAddressModel>()
|
||||
|
||||
// Will return an empty list if corePreferences.hideSipAddresses == true
|
||||
for (address in getListOfSipAddresses()) {
|
||||
if (LinphoneUtils.isSipAddressLinkedToPhoneNumberByPresence(this, address.asStringUriOnly())) {
|
||||
continue
|
||||
}
|
||||
|
||||
val data = ContactNumberOrAddressModel(
|
||||
this,
|
||||
address,
|
||||
|
|
@ -805,39 +833,26 @@ fun Friend.getListOfSipAddressesAndPhoneNumbers(listener: ContactNumberOrAddress
|
|||
)
|
||||
addressesAndNumbers.add(data)
|
||||
}
|
||||
|
||||
if (corePreferences.hidePhoneNumbers) {
|
||||
return addressesAndNumbers
|
||||
}
|
||||
|
||||
val indexOfLastSipAddress = addressesAndNumbers.count()
|
||||
for (number in phoneNumbersWithLabel) {
|
||||
val presenceModel = getPresenceModelForUriOrTel(number.phoneNumber)
|
||||
val phoneNumber = number.phoneNumber
|
||||
val presenceModel = getPresenceModelForUriOrTel(phoneNumber)
|
||||
val hasPresenceInfo = !presenceModel?.contact.isNullOrEmpty()
|
||||
var presenceAddress: Address? = null
|
||||
|
||||
if (presenceModel != null && hasPresenceInfo) {
|
||||
Log.d("[Friend] Phone number [${number.phoneNumber}] has presence information")
|
||||
// Show linked SIP address if not already stored as-is
|
||||
val contact = presenceModel.contact
|
||||
if (!contact.isNullOrEmpty()) {
|
||||
val address = core.interpretUrl(contact, false)
|
||||
if (address != null) {
|
||||
address.clean() // To remove ;user=phone
|
||||
presenceAddress = address
|
||||
if (addressesAndNumbers.find { it.address?.weakEqual(address) == true } == null) {
|
||||
val data = ContactNumberOrAddressModel(
|
||||
this,
|
||||
address,
|
||||
address.asStringUriOnly(),
|
||||
true, // SIP addresses are always enabled
|
||||
listener,
|
||||
true
|
||||
)
|
||||
addressesAndNumbers.add(indexOfLastSipAddress, data)
|
||||
}
|
||||
Log.d(
|
||||
"[Friend] Phone number [${number.phoneNumber}] is linked to SIP address [${presenceAddress.asStringUriOnly()}]"
|
||||
)
|
||||
} else {
|
||||
Log.e("[Contacts Manager] Failed to parse phone number [$phoneNumber] contact address [$contact] from presence model!")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -846,17 +861,20 @@ fun Friend.getListOfSipAddressesAndPhoneNumbers(listener: ContactNumberOrAddress
|
|||
val defaultAccount = LinphoneUtils.getDefaultAccount()
|
||||
val enablePhoneNumbers = hasPresenceInfo || !isEndToEndEncryptionMandatory()
|
||||
val address = presenceAddress ?: core.interpretUrl(
|
||||
number.phoneNumber,
|
||||
phoneNumber,
|
||||
LinphoneUtils.applyInternationalPrefix(defaultAccount)
|
||||
)
|
||||
address ?: continue
|
||||
|
||||
val label = PhoneNumberUtils.vcardParamStringToAddressBookLabel(
|
||||
coreContext.context.resources,
|
||||
number.label ?: ""
|
||||
)
|
||||
Log.d("[Contacts Manager] Parsed phone number [$phoneNumber] with label [$label] into address [${address.asStringUriOnly()}], presence address is [${presenceAddress?.asStringUriOnly()}]")
|
||||
val data = ContactNumberOrAddressModel(
|
||||
this,
|
||||
address,
|
||||
number.phoneNumber,
|
||||
phoneNumber,
|
||||
enablePhoneNumbers,
|
||||
listener,
|
||||
false,
|
||||
|
|
|
|||
|
|
@ -21,7 +21,9 @@ package org.linphone.core
|
|||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Application
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Context.POWER_SERVICE
|
||||
import android.content.Intent
|
||||
import android.media.AudioDeviceCallback
|
||||
import android.media.AudioDeviceInfo
|
||||
|
|
@ -29,15 +31,20 @@ import android.media.AudioManager
|
|||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.os.Looper
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import android.provider.Settings.SettingNotFoundException
|
||||
import androidx.annotation.AnyThread
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.core.text.isDigitsOnly
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
||||
import kotlin.system.exitProcess
|
||||
import org.linphone.BuildConfig
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
import org.linphone.LinphoneApplication.Companion.corePreferences
|
||||
import org.linphone.compatibility.Compatibility
|
||||
import org.linphone.contacts.ContactsManager
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.notifications.NotificationsManager
|
||||
|
|
@ -45,6 +52,7 @@ import org.linphone.telecom.TelecomManager
|
|||
import org.linphone.ui.call.CallActivity
|
||||
import org.linphone.utils.ActivityMonitor
|
||||
import org.linphone.utils.AppUtils
|
||||
import org.linphone.utils.AudioUtils
|
||||
import org.linphone.utils.Event
|
||||
import org.linphone.utils.FileUtils
|
||||
import org.linphone.utils.LinphoneUtils
|
||||
|
|
@ -80,6 +88,8 @@ class CoreContext
|
|||
|
||||
private val mainThread = Handler(Looper.getMainLooper())
|
||||
|
||||
var defaultAccountHasVideoConferenceFactoryUri: Boolean = false
|
||||
|
||||
var bearerAuthInfoPendingPasswordUpdate: AuthInfo? = null
|
||||
var digestAuthInfoPendingPasswordUpdate: AuthInfo? = null
|
||||
|
||||
|
|
@ -122,6 +132,10 @@ class CoreContext
|
|||
MutableLiveData<Event<List<String>>>()
|
||||
}
|
||||
|
||||
private var keepAliveServiceStarted = false
|
||||
|
||||
private lateinit var proximityWakeLock: PowerManager.WakeLock
|
||||
|
||||
@SuppressLint("HandlerLeak")
|
||||
private lateinit var coreThread: Handler
|
||||
|
||||
|
|
@ -130,14 +144,29 @@ class CoreContext
|
|||
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>?) {
|
||||
if (!addedDevices.isNullOrEmpty()) {
|
||||
Log.i("$TAG [${addedDevices.size}] new device(s) have been added:")
|
||||
var atLeastOneNewDeviceIsBluetooth = false
|
||||
for (device in addedDevices) {
|
||||
Log.i(
|
||||
"$TAG Added device [${device.productName}] with ID [${device.id}] and type [${device.type}]"
|
||||
)
|
||||
|
||||
when (device.type) {
|
||||
AudioDeviceInfo.TYPE_BLUETOOTH_SCO, AudioDeviceInfo.TYPE_BLE_HEADSET, AudioDeviceInfo.TYPE_BLE_SPEAKER, AudioDeviceInfo.TYPE_HEARING_AID -> {
|
||||
atLeastOneNewDeviceIsBluetooth = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Log.i("$TAG Reloading sound devices in 500ms")
|
||||
postOnCoreThreadDelayed({ core.reloadSoundDevices() }, 500)
|
||||
postOnCoreThreadDelayed({
|
||||
Log.i("$TAG Reloading sound devices")
|
||||
core.reloadSoundDevices()
|
||||
|
||||
if (atLeastOneNewDeviceIsBluetooth && core.callsNb > 0 && corePreferences.routeAudioToBluetoothWhenPossible) {
|
||||
Log.i("$TAG It seems a bluetooth device is now available, trying to route audio to it")
|
||||
AudioUtils.routeAudioToEitherBluetoothOrHearingAid()
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -150,14 +179,12 @@ class CoreContext
|
|||
"$TAG Removed device [${device.id}][${device.productName}][${device.type}]"
|
||||
)
|
||||
}
|
||||
if (telecomManager.getCurrentlyFollowedCalls() <= 0) {
|
||||
Log.i("$TAG No call found in Telecom's CallsManager, reloading sound devices in 500ms")
|
||||
postOnCoreThreadDelayed({ core.reloadSoundDevices() }, 500)
|
||||
} else {
|
||||
Log.i(
|
||||
"$TAG At least one active call in Telecom's CallsManager, let it handle the removed device"
|
||||
)
|
||||
}
|
||||
|
||||
Log.i("$TAG Reloading sound devices in 500ms")
|
||||
postOnCoreThreadDelayed({
|
||||
Log.i("$TAG Reloading sound devices")
|
||||
core.reloadSoundDevices()
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -165,6 +192,26 @@ class CoreContext
|
|||
private var previousCallState = Call.State.Idle
|
||||
|
||||
private val coreListener = object : CoreListenerStub() {
|
||||
@WorkerThread
|
||||
override fun onDefaultAccountChanged(core: Core, account: Account?) {
|
||||
defaultAccountHasVideoConferenceFactoryUri = account?.params?.audioVideoConferenceFactoryAddress != null
|
||||
|
||||
val defaultDomain = corePreferences.defaultDomain
|
||||
val isAccountOnDefaultDomain = account?.params?.domain == defaultDomain
|
||||
val domainFilter = corePreferences.contactsFilter
|
||||
Log.i("$TAG Currently selected filter is [$domainFilter]")
|
||||
|
||||
if (!isAccountOnDefaultDomain && domainFilter == defaultDomain) {
|
||||
corePreferences.contactsFilter = "*"
|
||||
Log.i(
|
||||
"$TAG New default account isn't on default domain, changing filter to any SIP contacts instead"
|
||||
)
|
||||
} else if (isAccountOnDefaultDomain && domainFilter != "") {
|
||||
corePreferences.contactsFilter = defaultDomain
|
||||
Log.i("$TAG New default account is on default domain, using that domain as filter instead of wildcard")
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun onMessagesReceived(
|
||||
core: Core,
|
||||
|
|
@ -222,6 +269,18 @@ class CoreContext
|
|||
) {
|
||||
Log.i("$TAG Configuring state changed [$status], message is [$message]")
|
||||
if (status == ConfiguringState.Successful) {
|
||||
val accounts = core.accountList
|
||||
if (core.defaultAccount == null && accounts.isNotEmpty()) {
|
||||
val firstAccount = accounts.firstOrNull()
|
||||
if (firstAccount != null) {
|
||||
val sipUri = firstAccount.params.identityAddress?.asStringUriOnly()
|
||||
Log.w(
|
||||
"$TAG Default account is null but account list isn't empty, using account [$sipUri] as default"
|
||||
)
|
||||
core.defaultAccount = firstAccount
|
||||
}
|
||||
}
|
||||
|
||||
provisioningAppliedEvent.postValue(Event(true))
|
||||
corePreferences.firstLaunch = false
|
||||
showGreenToastEvent.postValue(
|
||||
|
|
@ -256,6 +315,34 @@ class CoreContext
|
|||
"$TAG Call [${call.remoteAddress.asStringUriOnly()}] state changed [$currentState]"
|
||||
)
|
||||
when (currentState) {
|
||||
Call.State.IncomingReceived -> {
|
||||
if (corePreferences.autoAnswerEnabled) {
|
||||
val autoAnswerDelay = corePreferences.autoAnswerDelay
|
||||
if (autoAnswerDelay == 0) {
|
||||
Log.w("$TAG Auto answering call immediately")
|
||||
answerCall(call, true)
|
||||
} else {
|
||||
Log.i("$TAG Scheduling auto answering in $autoAnswerDelay milliseconds")
|
||||
postOnCoreThreadDelayed({
|
||||
Log.w("$TAG Auto answering call")
|
||||
answerCall(call, true)
|
||||
}, autoAnswerDelay.toLong())
|
||||
}
|
||||
}
|
||||
}
|
||||
Call.State.IncomingEarlyMedia -> {
|
||||
if (core.ringDuringIncomingEarlyMedia) {
|
||||
val speaker = core.audioDevices.find {
|
||||
it.type == AudioDevice.Type.Speaker
|
||||
}
|
||||
if (speaker != null) {
|
||||
Log.i("$TAG Ringing during incoming early media enabled, make sure speaker audio device [${speaker.id}] is used")
|
||||
call.outputAudioDevice = speaker
|
||||
} else {
|
||||
Log.w("$TAG No speaker device found, incoming call early media ringing will be played on default device")
|
||||
}
|
||||
}
|
||||
}
|
||||
Call.State.OutgoingInit -> {
|
||||
val conferenceInfo = core.findConferenceInformationFromUri(call.remoteAddress)
|
||||
// Do not show outgoing call view for conference calls, wait for connected state
|
||||
|
|
@ -269,10 +356,20 @@ class CoreContext
|
|||
)
|
||||
}
|
||||
}
|
||||
Call.State.OutgoingRinging, Call.State.OutgoingEarlyMedia -> {
|
||||
if (corePreferences.routeAudioToBluetoothWhenPossible) {
|
||||
Log.i("$TAG Trying to route audio to either bluetooth or hearing aid if available")
|
||||
AudioUtils.routeAudioToEitherBluetoothOrHearingAid(call)
|
||||
}
|
||||
}
|
||||
Call.State.Connected -> {
|
||||
postOnMainThread {
|
||||
showCallActivity()
|
||||
}
|
||||
if (corePreferences.routeAudioToBluetoothWhenPossible) {
|
||||
Log.i("$TAG Call is connected, trying to route audio to either bluetooth or hearing aid if available")
|
||||
AudioUtils.routeAudioToEitherBluetoothOrHearingAid(call)
|
||||
}
|
||||
}
|
||||
Call.State.StreamsRunning -> {
|
||||
if (previousCallState == Call.State.Connected) {
|
||||
|
|
@ -282,6 +379,15 @@ class CoreContext
|
|||
call.startRecording()
|
||||
}
|
||||
}
|
||||
|
||||
if (core.isInBackground) {
|
||||
// App is in background which means user likely answered the call from the notification
|
||||
// In this case start proximity sensor, otherwise CallActivity will handle it
|
||||
postOnMainThread {
|
||||
Log.i("$TAG App is in background, start proximity sensor")
|
||||
enableProximitySensor(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Call.State.Error -> {
|
||||
|
|
@ -319,6 +425,11 @@ class CoreContext
|
|||
Log.i("$TAG Available audio devices list was updated")
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun onFirstCallStarted(core: Core) {
|
||||
Log.i("$TAG First call started")
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun onLastCallEnded(core: Core) {
|
||||
Log.i("$TAG Last call ended")
|
||||
|
|
@ -332,6 +443,11 @@ class CoreContext
|
|||
core.videoDevice = frontFacing
|
||||
}
|
||||
}
|
||||
|
||||
postOnMainThread {
|
||||
Log.i("$TAG Releasing proximity sensor if it was enabled")
|
||||
enableProximitySensor(false)
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
|
|
@ -424,12 +540,32 @@ class CoreContext
|
|||
if (account.findAuthInfo() == digestAuthInfoPendingPasswordUpdate) {
|
||||
Log.i("$TAG Removed account matches auth info pending password update, removing dialog")
|
||||
clearAuthenticationRequestDialogEvent.postValue(Event(true))
|
||||
digestAuthInfoPendingPasswordUpdate = null
|
||||
}
|
||||
|
||||
if (core.defaultAccount == null || core.defaultAccount == account) {
|
||||
Log.w("$TAG Removed account was the default one, choosing another as default if possible")
|
||||
val newDefaultAccount = core.accountList.find {
|
||||
it.params.isRegisterEnabled
|
||||
} ?: core.accountList.firstOrNull()
|
||||
if (newDefaultAccount == null) {
|
||||
Log.e("$TAG Failed to find a new default account!")
|
||||
} else {
|
||||
Log.i("$TAG New default account will be [${newDefaultAccount.params.identityAddress?.asStringUriOnly()}]")
|
||||
// Delay changing default account to allow for other onAccountRemoved listeners to trigger first
|
||||
postOnCoreThread {
|
||||
core.defaultAccount = newDefaultAccount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var logcatEnabled: Boolean = corePreferences.printLogsInLogcat
|
||||
|
||||
private var crashlyticsEnabled: Boolean = corePreferences.sendLogsToCrashlytics
|
||||
private var crashlyticsAvailable = true
|
||||
|
||||
private val loggingServiceListener = object : LoggingServiceListenerStub() {
|
||||
@WorkerThread
|
||||
override fun onLogMessageWritten(
|
||||
|
|
@ -447,7 +583,9 @@ class CoreContext
|
|||
else -> android.util.Log.d(domain, message)
|
||||
}
|
||||
}
|
||||
FirebaseCrashlytics.getInstance().log("[$domain] [${level.name}] $message")
|
||||
if (crashlyticsEnabled) {
|
||||
FirebaseCrashlytics.getInstance().log("[$domain] [${level.name}] $message")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -467,9 +605,12 @@ class CoreContext
|
|||
Factory.instance().loggingService.addListener(loggingServiceListener)
|
||||
} catch (e: Exception) {
|
||||
Log.e("$TAG Failed to instantiate Crashlytics: $e")
|
||||
crashlyticsEnabled = false
|
||||
crashlyticsAvailable = false
|
||||
}
|
||||
} else {
|
||||
Log.i("$TAG Crashlytics is disabled")
|
||||
crashlyticsAvailable = false
|
||||
}
|
||||
Log.i("=========================================")
|
||||
Log.i("==== Linphone-android information dump ====")
|
||||
|
|
@ -487,6 +628,8 @@ class CoreContext
|
|||
core.isAutoIterateEnabled = true
|
||||
core.addListener(coreListener)
|
||||
|
||||
defaultAccountHasVideoConferenceFactoryUri = core.defaultAccount?.params?.audioVideoConferenceFactoryAddress != null
|
||||
|
||||
coreThread.postDelayed({ startCore() }, 50)
|
||||
|
||||
Looper.loop()
|
||||
|
|
@ -505,7 +648,6 @@ class CoreContext
|
|||
@WorkerThread
|
||||
fun startCore() {
|
||||
Log.i("$TAG Starting Core")
|
||||
updateFriendListsSubscriptionDependingOnDefaultAccount()
|
||||
|
||||
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
audioManager.registerAudioDeviceCallback(audioDeviceCallback, coreThread)
|
||||
|
|
@ -537,6 +679,15 @@ class CoreContext
|
|||
|
||||
if (oldVersion < 600000) { // 6.0.0 initial release
|
||||
configurationMigration5To6()
|
||||
} else if (oldVersion < 600004) { // 6.0.4
|
||||
disablePushNotificationsFromThirdPartySipAccounts()
|
||||
} else if (oldVersion < 600009) { // 6.0.9
|
||||
removePortFromSipIdentity()
|
||||
}
|
||||
|
||||
if (core.logCollectionUploadServerUrl.isNullOrEmpty()) {
|
||||
Log.w("$TAG Logs sharing server URL not set, fixing that")
|
||||
core.logCollectionUploadServerUrl = "https://files.linphone.org/http-file-transfer-server/hft.php"
|
||||
}
|
||||
|
||||
corePreferences.linphoneConfigurationVersion = currentVersion
|
||||
|
|
@ -547,15 +698,29 @@ class CoreContext
|
|||
Log.i("$TAG No configuration migration required")
|
||||
}
|
||||
|
||||
if (corePreferences.keepServiceAlive) {
|
||||
Log.i("$TAG Starting keep alive service")
|
||||
startKeepAliveService()
|
||||
}
|
||||
|
||||
contactsManager.onCoreStarted(core)
|
||||
telecomManager.onCoreStarted(core)
|
||||
notificationsManager.onCoreStarted(core, oldVersion < 600000) // Re-create channels when migrating from a non 6.0 version
|
||||
Log.i("$TAG Started contacts, telecom & notifications managers")
|
||||
|
||||
if (corePreferences.keepServiceAlive) {
|
||||
if (activityMonitor.isInForeground() || corePreferences.autoStart) {
|
||||
Log.i("$TAG Keep alive service is enabled and either app is in foreground or auto start is enabled, starting it")
|
||||
startKeepAliveService()
|
||||
} else {
|
||||
Log.w("$TAG Keep alive service is enabled but auto start isn't and app is not in foreground, not starting it")
|
||||
}
|
||||
}
|
||||
|
||||
val powerManager = context.getSystemService(POWER_SERVICE) as PowerManager
|
||||
if (!powerManager.isWakeLockLevelSupported(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK)) {
|
||||
Log.w("$TAG PROXIMITY_SCREEN_OFF_WAKE_LOCK isn't supported on this device!")
|
||||
} else {
|
||||
proximityWakeLock = powerManager.newWakeLock(
|
||||
PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK,
|
||||
"${context.packageName};proximity_sensor"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
|
|
@ -625,6 +790,21 @@ class CoreContext
|
|||
}
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
fun postOnCoreThreadWhenAvailableForHeavyTask(@WorkerThread lambda: (core: Core) -> Unit, name: String) {
|
||||
postOnCoreThread {
|
||||
if (core.callsNb >= 1) {
|
||||
Log.i("$TAG At least one call is active, wait until there is no more call before executing lambda [$name] (checking again in 1 sec)")
|
||||
coreContext.postOnCoreThreadDelayed({
|
||||
postOnCoreThreadWhenAvailableForHeavyTask(lambda, name)
|
||||
}, 1000)
|
||||
} else {
|
||||
Log.i("$TAG No active call at the moment, executing lambda [$name] right now")
|
||||
lambda.invoke(core)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
fun postOnMainThread(
|
||||
@UiThread lambda: () -> Unit
|
||||
|
|
@ -643,6 +823,10 @@ class CoreContext
|
|||
Log.i("$TAG App is in foreground, PUBLISHING presence as Online")
|
||||
core.consolidatedPresence = ConsolidatedPresence.Online
|
||||
}
|
||||
|
||||
if (corePreferences.keepServiceAlive && !keepAliveServiceStarted) {
|
||||
startKeepAliveService()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -742,10 +926,6 @@ class CoreContext
|
|||
if (forceZRTP) {
|
||||
params.mediaEncryption = MediaEncryption.ZRTP
|
||||
}
|
||||
/*if (LinphoneUtils.checkIfNetworkHasLowBandwidth(context)) {
|
||||
Log.w("$TAG Enabling low bandwidth mode!")
|
||||
params.isLowBandwidthEnabled = true
|
||||
}*/
|
||||
|
||||
params.recordFile = LinphoneUtils.getRecordingFilePathForAddress(address)
|
||||
|
||||
|
|
@ -759,14 +939,39 @@ class CoreContext
|
|||
"$TAG Using account matching address ${localAddress.asStringUriOnly()} as From"
|
||||
)
|
||||
} else {
|
||||
val defaultAccount = core.defaultAccount
|
||||
params.account = defaultAccount
|
||||
Log.e(
|
||||
"$TAG Failed to find account matching address ${localAddress.asStringUriOnly()}"
|
||||
"$TAG Failed to find account matching address ${localAddress.asStringUriOnly()}, using default one [${defaultAccount?.params?.identityAddress?.asStringUriOnly()}]"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val defaultAccount = core.defaultAccount
|
||||
params.account = defaultAccount
|
||||
Log.i("$TAG No local address given, using default account [${defaultAccount?.params?.identityAddress?.asStringUriOnly()}]")
|
||||
}
|
||||
|
||||
val username = address.username.orEmpty()
|
||||
val domain = address.domain.orEmpty()
|
||||
val account = params.account ?: core.defaultAccount
|
||||
if (account != null && Compatibility.isIpAddress(domain)) {
|
||||
Log.i("$TAG SIP URI [${address.asStringUriOnly()}] seems to have an IP address as domain")
|
||||
if (username.isNotEmpty() && (username.startsWith("+") || username.isDigitsOnly())) {
|
||||
val identityDomain = account.params.identityAddress?.domain
|
||||
Log.w("$TAG Username [$username] looks like a phone number, replacing domain [$domain] by the local account one [$identityDomain]")
|
||||
if (identityDomain != null) {
|
||||
val newAddress = address.clone()
|
||||
newAddress.domain = identityDomain
|
||||
|
||||
core.inviteAddressWithParams(newAddress, params)
|
||||
Log.i("$TAG Starting call to [${newAddress.asStringUriOnly()}]")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val call = core.inviteAddressWithParams(address, params)
|
||||
Log.i("$TAG Starting call $call")
|
||||
core.inviteAddressWithParams(address, params)
|
||||
Log.i("$TAG Starting call to [${address.asStringUriOnly()}]")
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
|
|
@ -796,7 +1001,7 @@ class CoreContext
|
|||
}
|
||||
|
||||
@WorkerThread
|
||||
fun answerCall(call: Call) {
|
||||
fun answerCall(call: Call, autoAnswer: Boolean = false) {
|
||||
Log.i(
|
||||
"$TAG Answering call with remote address [${call.remoteAddress.asStringUriOnly()}] and to address [${call.toAddress.asStringUriOnly()}]"
|
||||
)
|
||||
|
|
@ -822,6 +1027,12 @@ class CoreContext
|
|||
Log.i(
|
||||
"$TAG Enabling video on call params to prevent audio-only layout when answering"
|
||||
)
|
||||
} else if (autoAnswer) {
|
||||
val videoBothWays = corePreferences.autoAnswerVideoCallsWithVideoDirectionSendReceive
|
||||
if (videoBothWays) {
|
||||
Log.i("$TAG Call is being auto-answered, requesting video in both ways according to user setting")
|
||||
params.videoDirection = MediaDirection.SendRecv
|
||||
}
|
||||
}
|
||||
|
||||
call.acceptWithParams(params)
|
||||
|
|
@ -849,11 +1060,25 @@ class CoreContext
|
|||
intent.addFlags(
|
||||
Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT
|
||||
)
|
||||
context.startActivity(intent)
|
||||
val options = Compatibility.getPendingIntentActivityOptions(true)
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
0,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
options.toBundle()
|
||||
)
|
||||
|
||||
val senderOptions = Compatibility.getPendingIntentActivityOptions(false)
|
||||
Compatibility.sendPendingIntent(pendingIntent, senderOptions.toBundle())
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun startKeepAliveService() {
|
||||
if (keepAliveServiceStarted) {
|
||||
Log.w("$TAG Keep alive service already started, skipping")
|
||||
}
|
||||
|
||||
val serviceIntent = Intent(Intent.ACTION_MAIN).setClass(
|
||||
context,
|
||||
CoreKeepAliveThirdPartyAccountsService::class.java
|
||||
|
|
@ -861,6 +1086,7 @@ class CoreContext
|
|||
Log.i("$TAG Starting Keep alive for third party accounts Service")
|
||||
try {
|
||||
context.startService(serviceIntent)
|
||||
keepAliveServiceStarted = true
|
||||
} catch (e: Exception) {
|
||||
Log.e("$TAG Failed to start keep alive service: $e")
|
||||
}
|
||||
|
|
@ -876,34 +1102,23 @@ class CoreContext
|
|||
"$TAG Stopping Keep alive for third party accounts Service"
|
||||
)
|
||||
context.stopService(serviceIntent)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun updateFriendListsSubscriptionDependingOnDefaultAccount() {
|
||||
val account = core.defaultAccount
|
||||
if (account != null) {
|
||||
val enabled = account.params.domain == corePreferences.defaultDomain
|
||||
if (enabled != core.isFriendListSubscriptionEnabled) {
|
||||
core.isFriendListSubscriptionEnabled = enabled
|
||||
Log.i(
|
||||
"$TAG Friend list(s) subscription are now ${if (enabled) "enabled" else "disabled"}"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Log.e("$TAG Default account is null, do not touch friend lists subscription")
|
||||
}
|
||||
keepAliveServiceStarted = false
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun playDtmf(character: Char, duration: Int = 200, ignoreSystemPolicy: Boolean = false) {
|
||||
if (ignoreSystemPolicy || Settings.System.getInt(
|
||||
context.contentResolver,
|
||||
Settings.System.DTMF_TONE_WHEN_DIALING
|
||||
) != 0
|
||||
) {
|
||||
core.playDtmf(character, duration)
|
||||
} else {
|
||||
Log.w("$TAG Numpad DTMF tones are disabled in system settings, not playing them")
|
||||
try {
|
||||
if (ignoreSystemPolicy || Settings.System.getInt(
|
||||
context.contentResolver,
|
||||
Settings.System.DTMF_TONE_WHEN_DIALING
|
||||
) != 0
|
||||
) {
|
||||
core.playDtmf(character, duration)
|
||||
} else {
|
||||
Log.w("$TAG Numpad DTMF tones are disabled in system settings, not playing them")
|
||||
}
|
||||
} catch (snfe: SettingNotFoundException) {
|
||||
Log.e("$TAG DTMF_TONE_WHEN_DIALING system setting not found: $snfe")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -935,9 +1150,36 @@ class CoreContext
|
|||
core.setUserAgent(userAgent, sdkUserAgent)
|
||||
}
|
||||
|
||||
// Migration between versions related
|
||||
|
||||
@WorkerThread
|
||||
fun enableLogcat(enable: Boolean) {
|
||||
logcatEnabled = enable
|
||||
private fun removePortFromSipIdentity() {
|
||||
for (account in core.accountList) {
|
||||
val params = account.params
|
||||
val identity = params.identityAddress
|
||||
if (identity != null && identity.port != 0) {
|
||||
val clone = params.clone()
|
||||
val newIdentity = identity.clone()
|
||||
newIdentity.port = 0
|
||||
clone.identityAddress = newIdentity
|
||||
Log.w("$TAG Found account with identity address [${identity.asStringUriOnly()}] that contains port information in domain, removing port information in new identity [${newIdentity.asStringUriOnly()}]")
|
||||
account.params = clone
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun disablePushNotificationsFromThirdPartySipAccounts() {
|
||||
for (account in core.accountList) {
|
||||
val params = account.params
|
||||
val pushAvailableForDomain = params.identityAddress?.domain in corePreferences.pushNotificationCompatibleDomains
|
||||
if (!pushAvailableForDomain && params.pushNotificationAllowed) {
|
||||
val clone = params.clone()
|
||||
clone.pushNotificationAllowed = false
|
||||
Log.w("$TAG Updating account [${params.identityAddress?.asStringUriOnly()}] params to disable push notifications, they won't work and may cause issues when used with UDP transport protocol")
|
||||
account.params = clone
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
|
|
@ -966,7 +1208,7 @@ class CoreContext
|
|||
|
||||
for (account in core.accountList) {
|
||||
val params = account.params
|
||||
if (params.domain == corePreferences.defaultDomain && params.limeAlgo.isNullOrEmpty()) {
|
||||
if (params.identityAddress?.domain == corePreferences.defaultDomain && params.limeAlgo.isNullOrEmpty()) {
|
||||
val clone = params.clone()
|
||||
clone.limeAlgo = "c25519"
|
||||
Log.i("$TAG Updating account [${params.identityAddress?.asStringUriOnly()}] params to use LIME algo c25519")
|
||||
|
|
@ -999,4 +1241,54 @@ class CoreContext
|
|||
Log.i("$TAG Removing previous grammar files (without .belr extension)")
|
||||
corePreferences.clearPreviousGrammars()
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun isCrashlyticsAvailable(): Boolean {
|
||||
return crashlyticsAvailable
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun updateLogcatEnabledSetting(enabled: Boolean) {
|
||||
logcatEnabled = enabled
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun updateCrashlyticsEnabledSetting(enabled: Boolean) {
|
||||
crashlyticsEnabled = enabled
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun enableProximitySensor(enable: Boolean) {
|
||||
if (::proximityWakeLock.isInitialized) {
|
||||
if (enable && !proximityWakeLock.isHeld) {
|
||||
Log.i("$TAG Acquiring proximity sensor wake lock for 2 hours")
|
||||
proximityWakeLock.acquire(7200 * 1000L) // 2 hours
|
||||
} else if (!enable && proximityWakeLock.isHeld) {
|
||||
Log.i("$TAG Releasing proximity sensor wake lock")
|
||||
proximityWakeLock.release(PowerManager.RELEASE_FLAG_WAIT_FOR_NO_PROXIMITY)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setBackCamera(): Boolean {
|
||||
for (camera in core.videoDevicesList) {
|
||||
if (camera.contains("Back")) {
|
||||
Log.i("TAG Found back facing camera [$camera], using it")
|
||||
coreContext.core.videoDevice = camera
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun setFrontCamera(): Boolean {
|
||||
for (camera in core.videoDevicesList) {
|
||||
if (camera.contains("Front")) {
|
||||
Log.i("$TAG Found front facing camera [$camera], using it")
|
||||
coreContext.core.videoDevice = camera
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,19 +19,18 @@
|
|||
*/
|
||||
package org.linphone.core
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.IBinder
|
||||
import androidx.annotation.AnyThread
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
import org.linphone.R
|
||||
import org.linphone.compatibility.Compatibility
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.core.tools.service.FileTransferService
|
||||
import org.linphone.ui.main.MainActivity
|
||||
|
|
@ -171,14 +170,11 @@ class CoreFileTransferService : FileTransferService() {
|
|||
postNotification()
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
@AnyThread
|
||||
private fun postNotification() {
|
||||
val notificationsManager = NotificationManagerCompat.from(this)
|
||||
if (ActivityCompat.checkSelfPermission(
|
||||
this,
|
||||
Manifest.permission.POST_NOTIFICATIONS
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
if (Compatibility.isPostNotificationsPermissionGranted(this)) {
|
||||
if (mServiceNotification != null) {
|
||||
Log.i("$TAG Sending notification to manager")
|
||||
notificationsManager.notify(SERVICE_NOTIF_ID, mServiceNotification)
|
||||
|
|
|
|||
|
|
@ -61,6 +61,10 @@ class CoreInCallService : CoreService() {
|
|||
return null
|
||||
}
|
||||
|
||||
override fun createServiceNotificationChannel() {
|
||||
// Do nothing, app's Notifications Manager will do the job
|
||||
}
|
||||
|
||||
override fun createServiceNotification() {
|
||||
// Do nothing, app's Notifications Manager will do the job
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import android.content.Context
|
|||
import androidx.annotation.AnyThread
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.annotation.WorkerThread
|
||||
import org.linphone.BuildConfig
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
|
|
@ -39,116 +40,173 @@ class CorePreferences
|
|||
|
||||
private var _config: Config? = null
|
||||
|
||||
@get:WorkerThread @set:WorkerThread
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var config: Config
|
||||
get() = _config ?: coreContext.core.config
|
||||
set(value) {
|
||||
_config = value
|
||||
}
|
||||
|
||||
@get:WorkerThread @set:WorkerThread
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var printLogsInLogcat: Boolean
|
||||
get() = config.getBool("app", "debug", org.linphone.BuildConfig.DEBUG)
|
||||
get() = config.getBool("app", "debug", BuildConfig.DEBUG)
|
||||
set(value) {
|
||||
config.setBool("app", "debug", value)
|
||||
}
|
||||
|
||||
@get:WorkerThread @set:WorkerThread
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var sendLogsToCrashlytics: Boolean
|
||||
get() = config.getBool("app", "send_logs_to_crashlytics", BuildConfig.CRASHLYTICS_ENABLED)
|
||||
set(value) {
|
||||
config.setBool("app", "send_logs_to_crashlytics", value)
|
||||
}
|
||||
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var firstLaunch: Boolean
|
||||
get() = config.getBool("app", "first_6.0_launch", true)
|
||||
set(value) {
|
||||
config.setBool("app", "first_6.0_launch", value)
|
||||
}
|
||||
|
||||
@get:WorkerThread @set:WorkerThread
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var linphoneConfigurationVersion: Int
|
||||
get() = config.getInt("app", "config_version", 52005)
|
||||
set(value) {
|
||||
config.setInt("app", "config_version", value)
|
||||
}
|
||||
|
||||
@get:WorkerThread @set:WorkerThread
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var autoStart: Boolean
|
||||
get() = config.getBool("app", "auto_start", true)
|
||||
set(value) {
|
||||
config.setBool("app", "auto_start", value)
|
||||
}
|
||||
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var checkForUpdateServerUrl: String
|
||||
get() = config.getString("misc", "version_check_url_root", "").orEmpty()
|
||||
set(value) {
|
||||
config.setString("misc", "version_check_url_root", value)
|
||||
}
|
||||
|
||||
@get:WorkerThread @set:WorkerThread
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var conditionsAndPrivacyPolicyAccepted: Boolean
|
||||
get() = config.getBool("app", "read_and_agree_terms_and_privacy", false)
|
||||
set(value) {
|
||||
config.setBool("app", "read_and_agree_terms_and_privacy", value)
|
||||
}
|
||||
|
||||
@get:WorkerThread @set:WorkerThread
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var publishPresence: Boolean
|
||||
get() = config.getBool("app", "publish_presence", true)
|
||||
set(value) {
|
||||
config.setBool("app", "publish_presence", value)
|
||||
}
|
||||
|
||||
@get:WorkerThread @set:WorkerThread
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var keepServiceAlive: Boolean
|
||||
get() = config.getBool("app", "keep_service_alive", false)
|
||||
set(value) {
|
||||
config.setBool("app", "keep_service_alive", value)
|
||||
}
|
||||
|
||||
@get:WorkerThread @set:WorkerThread
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var deviceName: String
|
||||
get() = config.getString("app", "device", "").orEmpty().trim()
|
||||
set(value) {
|
||||
config.setString("app", "device", value.trim())
|
||||
}
|
||||
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var showDeveloperSettings: Boolean
|
||||
get() = config.getBool("ui", "show_developer_settings", false)
|
||||
set(value) {
|
||||
config.setBool("ui", "show_developer_settings", value)
|
||||
}
|
||||
|
||||
// Call settings
|
||||
|
||||
// This won't be done if bluetooth or wired headset is used
|
||||
@get:WorkerThread @set:WorkerThread
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var routeAudioToBluetoothWhenPossible: Boolean
|
||||
get() = config.getBool("app", "route_audio_to_bluetooth_when_possible", true)
|
||||
set(value) {
|
||||
config.setBool("app", "route_audio_to_bluetooth_when_possible", value)
|
||||
}
|
||||
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var routeAudioToSpeakerWhenVideoIsEnabled: Boolean
|
||||
get() = config.getBool("app", "route_audio_to_speaker_when_video_enabled", true)
|
||||
set(value) {
|
||||
config.setBool("app", "route_audio_to_speaker_when_video_enabled", value)
|
||||
}
|
||||
|
||||
@get:WorkerThread @set:WorkerThread
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var callRecordingUseSmffFormat: Boolean
|
||||
get() = config.getBool("app", "use_smff_for_call_recording", false)
|
||||
set(value) {
|
||||
config.setBool("app", "use_smff_for_call_recording", value)
|
||||
}
|
||||
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var automaticallyStartCallRecording: Boolean
|
||||
get() = config.getBool("app", "auto_start_call_record", false)
|
||||
set(value) {
|
||||
config.setBool("app", "auto_start_call_record", value)
|
||||
}
|
||||
|
||||
@get:WorkerThread @set:WorkerThread
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var showDialogWhenCallingDeviceUuidDirectly: Boolean
|
||||
get() = config.getBool("app", "show_confirmation_dialog_zrtp_trust_call", true)
|
||||
set(value) {
|
||||
config.setBool("app", "show_confirmation_dialog_zrtp_trust_call", value)
|
||||
}
|
||||
|
||||
@get:WorkerThread @set:WorkerThread
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var acceptEarlyMedia: Boolean
|
||||
get() = config.getBool("sip", "incoming_calls_early_media", false)
|
||||
set(value) {
|
||||
config.setBool("sip", "incoming_calls_early_media", value)
|
||||
}
|
||||
|
||||
@get:WorkerThread @set:WorkerThread
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var allowOutgoingEarlyMedia: Boolean
|
||||
get() = config.getBool("misc", "real_early_media", false)
|
||||
set(value) {
|
||||
config.setBool("misc", "real_early_media", value)
|
||||
}
|
||||
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var autoAnswerEnabled: Boolean
|
||||
get() = config.getBool("app", "auto_answer", false)
|
||||
set(value) {
|
||||
config.setBool("app", "auto_answer", value)
|
||||
}
|
||||
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var autoAnswerDelay: Int
|
||||
get() = config.getInt("app", "auto_answer_delay", 0)
|
||||
set(value) {
|
||||
config.setInt("app", "auto_answer_delay", value)
|
||||
}
|
||||
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var autoAnswerVideoCallsWithVideoDirectionSendReceive: Boolean
|
||||
get() = config.getBool("app", "auto_answer_video_send_receive", false)
|
||||
set(value) {
|
||||
config.setBool("app", "auto_answer_video_send_receive", value)
|
||||
}
|
||||
|
||||
// Conversation related
|
||||
|
||||
@get:WorkerThread @set:WorkerThread
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var markConversationAsReadWhenDismissingMessageNotification: Boolean
|
||||
get() = config.getBool("app", "mark_as_read_notif_dismissal", false)
|
||||
set(value) {
|
||||
config.setBool("app", "mark_as_read_notif_dismissal", value)
|
||||
}
|
||||
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var makePublicMediaFilesDownloaded: Boolean
|
||||
// Keep old name for backward compatibility
|
||||
get() = config.getBool("app", "make_downloaded_images_public_in_gallery", false)
|
||||
|
|
@ -158,7 +216,7 @@ class CorePreferences
|
|||
|
||||
// Conference related
|
||||
|
||||
@get:WorkerThread @set:WorkerThread
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var createEndToEndEncryptedMeetingsAndGroupCalls: Boolean
|
||||
get() = config.getBool("app", "create_e2e_encrypted_conferences", false)
|
||||
set(value) {
|
||||
|
|
@ -167,21 +225,35 @@ class CorePreferences
|
|||
|
||||
// Contacts related
|
||||
|
||||
@get:WorkerThread @set:WorkerThread
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var sortContactsByFirstName: Boolean
|
||||
get() = config.getBool("ui", "sort_contacts_by_first_name", true) // If disabled, last name will be used
|
||||
set(value) {
|
||||
config.setBool("ui", "sort_contacts_by_first_name", value)
|
||||
}
|
||||
|
||||
@get:AnyThread
|
||||
var hideContactsWithoutPhoneNumberOrSipAddress: Boolean
|
||||
get() = config.getBool("ui", "hide_contacts_without_phone_number_or_sip_address", false)
|
||||
set(value) {
|
||||
config.setBool("ui", "hide_contacts_without_phone_number_or_sip_address", value)
|
||||
}
|
||||
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var contactsFilter: String
|
||||
get() = config.getString("ui", "contacts_filter", "")!! // Default value must be empty!
|
||||
set(value) {
|
||||
config.setString("ui", "contacts_filter", value)
|
||||
}
|
||||
|
||||
@get:WorkerThread @set:WorkerThread
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var showFavoriteContacts: Boolean
|
||||
get() = config.getBool("ui", "show_favorites_contacts", true)
|
||||
set(value) {
|
||||
config.setBool("ui", "show_favorites_contacts", value)
|
||||
}
|
||||
|
||||
@get:WorkerThread @set:WorkerThread
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var friendListInWhichStoreNewlyCreatedFriends: String
|
||||
get() = config.getString(
|
||||
"app",
|
||||
|
|
@ -192,9 +264,23 @@ class CorePreferences
|
|||
config.setString("app", "friend_list_to_store_newly_created_contacts", value)
|
||||
}
|
||||
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var editNativeContactsInLinphone: Boolean
|
||||
get() = config.getBool("ui", "edit_native_contact_in_linphone", false)
|
||||
set(value) {
|
||||
config.setBool("ui", "edit_native_contact_in_linphone", value)
|
||||
}
|
||||
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var disableAddContact: Boolean
|
||||
get() = config.getBool("ui", "disable_add_contact", false)
|
||||
set(value) {
|
||||
config.setBool("ui", "disable_add_contact", value)
|
||||
}
|
||||
|
||||
// Voice recordings related
|
||||
|
||||
@get:WorkerThread @set:WorkerThread
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var voiceRecordingMaxDuration: Int
|
||||
get() = config.getInt("app", "voice_recording_max_duration", 600000) // in ms
|
||||
set(value) = config.setInt("app", "voice_recording_max_duration", value)
|
||||
|
|
@ -202,7 +288,7 @@ class CorePreferences
|
|||
// User interface related
|
||||
|
||||
// -1 means auto, 0 no, 1 yes
|
||||
@get:WorkerThread @set:WorkerThread
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var darkMode: Int
|
||||
get() {
|
||||
if (!darkModeAllowed) return 0
|
||||
|
|
@ -213,93 +299,132 @@ class CorePreferences
|
|||
}
|
||||
|
||||
// Allows to make screenshots
|
||||
@get:WorkerThread @set:WorkerThread
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var enableSecureMode: Boolean
|
||||
get() = config.getBool("ui", "enable_secure_mode", true)
|
||||
set(value) {
|
||||
config.setBool("ui", "enable_secure_mode", value)
|
||||
}
|
||||
|
||||
@get:WorkerThread @set:WorkerThread
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var automaticallyShowDialpad: Boolean
|
||||
get() = config.getBool("ui", "automatically_show_dialpad", false)
|
||||
set(value) {
|
||||
config.setBool("ui", "automatically_show_dialpad", value)
|
||||
}
|
||||
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var themeMainColor: String
|
||||
get() = config.getString("ui", "theme_main_color", "orange")!!
|
||||
set(value) {
|
||||
config.setString("ui", "theme_main_color", value)
|
||||
}
|
||||
|
||||
@get:WorkerThread
|
||||
// Customization options
|
||||
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var showMicrophoneAndSpeakerVuMeters: Boolean
|
||||
get() = config.getBool("ui", "show_mic_speaker_vu_meter", false)
|
||||
set(value) {
|
||||
config.setBool("ui", "show_mic_speaker_vu_meter", value)
|
||||
}
|
||||
|
||||
@get:AnyThread @set:WorkerThread
|
||||
var pushNotificationCompatibleDomains: Array<String>
|
||||
get() = config.getStringList("app", "push_notification_domains", arrayOf("sip.linphone.org"))
|
||||
set(value) {
|
||||
config.setStringList("app", "push_notification_domains", value)
|
||||
}
|
||||
|
||||
@get:AnyThread
|
||||
val defaultDomain: String
|
||||
get() = config.getString("app", "default_domain", "sip.linphone.org")!!
|
||||
|
||||
@get:AnyThread
|
||||
val darkModeAllowed: Boolean
|
||||
get() = config.getBool("ui", "dark_mode_allowed", true)
|
||||
|
||||
@get:WorkerThread
|
||||
@get:AnyThread
|
||||
val changeMainColorAllowed: Boolean
|
||||
get() = config.getBool("ui", "change_main_color_allowed", false)
|
||||
|
||||
@get:WorkerThread
|
||||
@get:AnyThread
|
||||
val onlyDisplaySipUriUsername: Boolean
|
||||
get() = config.getBool("ui", "only_display_sip_uri_username", false)
|
||||
|
||||
@get:WorkerThread
|
||||
@get:AnyThread
|
||||
val hideSipAddresses: Boolean
|
||||
get() = config.getBool("ui", "hide_sip_addresses", false)
|
||||
|
||||
@get:AnyThread
|
||||
val disableChat: Boolean
|
||||
get() = config.getBool("ui", "disable_chat_feature", false)
|
||||
|
||||
@get:WorkerThread
|
||||
@get:AnyThread
|
||||
val disableMeetings: Boolean
|
||||
get() = config.getBool("ui", "disable_meetings_feature", false)
|
||||
|
||||
@get:WorkerThread
|
||||
@get:AnyThread
|
||||
val disableBroadcasts: Boolean
|
||||
get() = config.getBool("ui", "disable_broadcast_feature", true) // TODO FIXME: not implemented yet
|
||||
|
||||
@get:WorkerThread
|
||||
@get:AnyThread
|
||||
val disableCallRecordings: Boolean
|
||||
get() = config.getBool("ui", "disable_call_recordings_feature", false)
|
||||
|
||||
@get:WorkerThread
|
||||
@get:AnyThread
|
||||
val maxAccountsCount: Int
|
||||
get() = config.getInt("ui", "max_account", 0) // 0 means no max
|
||||
|
||||
@get:WorkerThread
|
||||
@get:AnyThread
|
||||
val hidePhoneNumbers: Boolean
|
||||
get() = config.getBool("ui", "hide_phone_numbers", false)
|
||||
|
||||
@get:WorkerThread
|
||||
@get:AnyThread
|
||||
val hideSettings: Boolean
|
||||
get() = config.getBool("ui", "hide_settings", false)
|
||||
|
||||
@get:WorkerThread
|
||||
@get:AnyThread
|
||||
val hideAccountSettings: Boolean
|
||||
get() = config.getBool("ui", "hide_account_settings", false)
|
||||
|
||||
@get:WorkerThread
|
||||
@get:AnyThread
|
||||
val hideAdvancedSettings: Boolean
|
||||
get() = config.getBool("ui", "hide_advanced_settings", false)
|
||||
|
||||
@get:AnyThread
|
||||
val hideAssistantCreateAccount: Boolean
|
||||
get() = config.getBool("ui", "assistant_hide_create_account", false)
|
||||
|
||||
@get:WorkerThread
|
||||
@get:AnyThread
|
||||
val hideAssistantScanQrCode: Boolean
|
||||
get() = config.getBool("ui", "assistant_disable_qr_code", false)
|
||||
|
||||
@get:WorkerThread
|
||||
@get:AnyThread
|
||||
val hideAssistantThirdPartySipAccount: Boolean
|
||||
get() = config.getBool("ui", "assistant_hide_third_party_account", false)
|
||||
|
||||
@get:WorkerThread
|
||||
@get:AnyThread
|
||||
val magicSearchResultsLimit: Int
|
||||
get() = config.getInt("ui", "max_number_of_magic_search_results", 300)
|
||||
|
||||
@get:AnyThread
|
||||
val singleSignOnClientId: String
|
||||
get() = config.getString("app", "oidc_client_id", "linphone")!!
|
||||
|
||||
@get:WorkerThread
|
||||
@get:AnyThread
|
||||
val useUsernameAsSingleSignOnLoginHint: Boolean
|
||||
get() = config.getBool("ui", "use_username_as_sso_login_hint", false)
|
||||
|
||||
@get:WorkerThread
|
||||
@get:AnyThread
|
||||
val thirdPartySipAccountDefaultTransport: String
|
||||
get() = config.getString("ui", "assistant_third_party_sip_account_transport", "tls")!!
|
||||
|
||||
@get:WorkerThread
|
||||
@get:AnyThread
|
||||
val thirdPartySipAccountDefaultDomain: String
|
||||
get() = config.getString("ui", "assistant_third_party_sip_account_domain", "")!!
|
||||
|
||||
@get:WorkerThread
|
||||
@get:AnyThread
|
||||
val assistantDirectlyGoToThirdPartySipAccountLogin: Boolean
|
||||
get() = config.getBool(
|
||||
"ui",
|
||||
|
|
@ -307,24 +432,16 @@ class CorePreferences
|
|||
false
|
||||
)
|
||||
|
||||
@get:WorkerThread
|
||||
@get:AnyThread
|
||||
val fetchContactsFromDefaultDirectory: Boolean
|
||||
get() = config.getBool("app", "fetch_contacts_from_default_directory", true)
|
||||
|
||||
@get:WorkerThread
|
||||
val automaticallyShowDialpad: Boolean
|
||||
get() = config.getBool("ui", "automatically_show_dialpad", false)
|
||||
|
||||
@get:WorkerThread
|
||||
@get:AnyThread
|
||||
val showLettersOnDialpad: Boolean
|
||||
get() = config.getBool("ui", "show_letters_on_dialpad", true)
|
||||
|
||||
// Paths
|
||||
|
||||
@get:WorkerThread
|
||||
val defaultDomain: String
|
||||
get() = config.getString("app", "default_domain", "sip.linphone.org")!!
|
||||
|
||||
@get:AnyThread
|
||||
val configPath: String
|
||||
get() = context.filesDir.absolutePath + "/" + CONFIG_FILE_NAME
|
||||
|
|
|
|||
|
|
@ -175,7 +175,7 @@ class VFS {
|
|||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, getSecretKey())
|
||||
val iv = cipher.iv
|
||||
return Pair<ByteArray, ByteArray>(
|
||||
return Pair(
|
||||
iv,
|
||||
cipher.doFinal(textToEncrypt.toByteArray(StandardCharsets.UTF_8))
|
||||
)
|
||||
|
|
@ -193,7 +193,7 @@ class VFS {
|
|||
@Throws(java.lang.Exception::class)
|
||||
private fun encryptToken(token: String): Pair<String?, String?> {
|
||||
val encryptedData = encryptData(token)
|
||||
return Pair<String?, String?>(
|
||||
return Pair(
|
||||
Base64.encodeToString(encryptedData.first, Base64.DEFAULT),
|
||||
Base64.encodeToString(encryptedData.second, Base64.DEFAULT)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -26,8 +26,10 @@ import android.content.Context
|
|||
import android.content.Intent
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
import org.linphone.core.Address
|
||||
import org.linphone.core.AudioDevice
|
||||
import org.linphone.core.ConferenceParams
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.utils.AudioUtils
|
||||
|
||||
class NotificationBroadcastReceiver : BroadcastReceiver() {
|
||||
companion object {
|
||||
|
|
@ -36,47 +38,69 @@ class NotificationBroadcastReceiver : BroadcastReceiver() {
|
|||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val notificationId = intent.getIntExtra(NotificationsManager.INTENT_NOTIF_ID, 0)
|
||||
Log.i(
|
||||
"$TAG Got notification broadcast for ID [$notificationId]"
|
||||
)
|
||||
val action = intent.action
|
||||
Log.i("$TAG Got notification broadcast for ID [$notificationId] with action [$action]")
|
||||
|
||||
// Wait for coreContext to be ready to handle intent
|
||||
while (!coreContext.isReady()) {
|
||||
Thread.sleep(50)
|
||||
}
|
||||
|
||||
if (intent.action == NotificationsManager.INTENT_ANSWER_CALL_NOTIF_ACTION || intent.action == NotificationsManager.INTENT_HANGUP_CALL_NOTIF_ACTION) {
|
||||
handleCallIntent(intent, notificationId)
|
||||
} else if (intent.action == NotificationsManager.INTENT_REPLY_MESSAGE_NOTIF_ACTION || intent.action == NotificationsManager.INTENT_MARK_MESSAGE_AS_READ_NOTIF_ACTION) {
|
||||
handleChatIntent(context, intent, notificationId)
|
||||
if (
|
||||
action == NotificationsManager.INTENT_ANSWER_CALL_NOTIF_ACTION ||
|
||||
action == NotificationsManager.INTENT_HANGUP_CALL_NOTIF_ACTION ||
|
||||
action == NotificationsManager.INTENT_TOGGLE_SPEAKER_CALL_NOTIF_ACTION
|
||||
) {
|
||||
handleCallIntent(intent, notificationId, action)
|
||||
} else if (
|
||||
action == NotificationsManager.INTENT_REPLY_MESSAGE_NOTIF_ACTION ||
|
||||
action == NotificationsManager.INTENT_MARK_MESSAGE_AS_READ_NOTIF_ACTION
|
||||
) {
|
||||
handleChatIntent(context, intent, notificationId, action)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleCallIntent(intent: Intent, notificationId: Int) {
|
||||
val remoteSipAddress = intent.getStringExtra(NotificationsManager.INTENT_REMOTE_ADDRESS)
|
||||
if (remoteSipAddress == null) {
|
||||
private fun handleCallIntent(intent: Intent, notificationId: Int, action: String) {
|
||||
val remoteSipUri = intent.getStringExtra(NotificationsManager.INTENT_REMOTE_SIP_URI)
|
||||
if (remoteSipUri == null) {
|
||||
Log.e("$TAG Remote SIP address is null for call notification ID [$notificationId]")
|
||||
return
|
||||
}
|
||||
|
||||
coreContext.postOnCoreThread { core ->
|
||||
val call = core.calls.find {
|
||||
it.remoteAddress.asStringUriOnly() == remoteSipAddress
|
||||
it.remoteAddress.asStringUriOnly() == remoteSipUri
|
||||
}
|
||||
if (call == null) {
|
||||
Log.e("$TAG Couldn't find call from remote address [$remoteSipAddress]")
|
||||
Log.e("$TAG Couldn't find call from remote address [$remoteSipUri]")
|
||||
} else {
|
||||
if (intent.action == NotificationsManager.INTENT_ANSWER_CALL_NOTIF_ACTION) {
|
||||
coreContext.answerCall(call)
|
||||
} else {
|
||||
coreContext.terminateCall(call)
|
||||
when (action) {
|
||||
NotificationsManager.INTENT_ANSWER_CALL_NOTIF_ACTION -> {
|
||||
Log.i("$TAG Answering call with remote address [$remoteSipUri]")
|
||||
coreContext.answerCall(call)
|
||||
}
|
||||
NotificationsManager.INTENT_HANGUP_CALL_NOTIF_ACTION -> {
|
||||
Log.i("$TAG Declining/terminating call with remote address [$remoteSipUri]")
|
||||
coreContext.terminateCall(call)
|
||||
}
|
||||
NotificationsManager.INTENT_TOGGLE_SPEAKER_CALL_NOTIF_ACTION -> {
|
||||
val audioDevice = call.outputAudioDevice
|
||||
val isUsingSpeaker = audioDevice?.type == AudioDevice.Type.Speaker
|
||||
if (isUsingSpeaker) {
|
||||
Log.i("$TAG Routing audio to earpiece for call [$remoteSipUri]")
|
||||
AudioUtils.routeAudioToEarpiece(call)
|
||||
} else {
|
||||
Log.i("$TAG Routing audio to speaker for call [$remoteSipUri]")
|
||||
AudioUtils.routeAudioToSpeaker(call)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleChatIntent(context: Context, intent: Intent, notificationId: Int) {
|
||||
val remoteSipAddress = intent.getStringExtra(NotificationsManager.INTENT_REMOTE_ADDRESS)
|
||||
private fun handleChatIntent(context: Context, intent: Intent, notificationId: Int, action: String) {
|
||||
val remoteSipAddress = intent.getStringExtra(NotificationsManager.INTENT_REMOTE_SIP_URI)
|
||||
if (remoteSipAddress == null) {
|
||||
Log.e("$TAG Remote SIP address is null for notification ID [$notificationId]")
|
||||
return
|
||||
|
|
@ -88,7 +112,7 @@ class NotificationBroadcastReceiver : BroadcastReceiver() {
|
|||
}
|
||||
|
||||
val reply = getMessageText(intent)?.toString()
|
||||
if (intent.action == NotificationsManager.INTENT_REPLY_MESSAGE_NOTIF_ACTION) {
|
||||
if (action == NotificationsManager.INTENT_REPLY_MESSAGE_NOTIF_ACTION) {
|
||||
if (reply == null) {
|
||||
Log.e("$TAG Couldn't get reply text")
|
||||
return
|
||||
|
|
@ -128,13 +152,13 @@ class NotificationBroadcastReceiver : BroadcastReceiver() {
|
|||
return@postOnCoreThread
|
||||
}
|
||||
|
||||
if (intent.action == NotificationsManager.INTENT_REPLY_MESSAGE_NOTIF_ACTION) {
|
||||
if (action == NotificationsManager.INTENT_REPLY_MESSAGE_NOTIF_ACTION) {
|
||||
val msg = room.createMessageFromUtf8(reply)
|
||||
msg.userData = notificationId
|
||||
msg.addListener(coreContext.notificationsManager.chatMessageListener)
|
||||
msg.send()
|
||||
Log.i("$TAG Reply sent for notif id [$notificationId]")
|
||||
} else if (intent.action == NotificationsManager.INTENT_MARK_MESSAGE_AS_READ_NOTIF_ACTION) {
|
||||
} else if (action == NotificationsManager.INTENT_MARK_MESSAGE_AS_READ_NOTIF_ACTION) {
|
||||
Log.i("$TAG Marking chat room from notification id [$notificationId] as read")
|
||||
room.markAsRead()
|
||||
if (!coreContext.notificationsManager.dismissChatNotification(room)) {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -26,13 +26,11 @@ import androidx.core.telecom.CallControlResult
|
|||
import androidx.core.telecom.CallControlScope
|
||||
import androidx.core.telecom.CallEndpointCompat
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
import org.linphone.LinphoneApplication.Companion.corePreferences
|
||||
import org.linphone.core.AudioDevice
|
||||
import org.linphone.core.Call
|
||||
import org.linphone.core.CallListenerStub
|
||||
import org.linphone.core.Reason
|
||||
|
|
@ -50,9 +48,7 @@ class TelecomCallControlCallback(
|
|||
private const val TAG = "[Telecom Call Control Callback]"
|
||||
}
|
||||
|
||||
private var availableEndpoints: List<CallEndpointCompat> = arrayListOf()
|
||||
private var currentEndpoint = CallEndpointCompat.TYPE_UNKNOWN
|
||||
private var endpointUpdateRequestFromLinphone: Boolean = false
|
||||
private var mutedByTelecomManager = false
|
||||
|
||||
private val callListener = object : CallListenerStub() {
|
||||
@WorkerThread
|
||||
|
|
@ -60,72 +56,35 @@ class TelecomCallControlCallback(
|
|||
Log.i("$TAG Call [${call.remoteAddress.asStringUriOnly()}] state changed [$state]")
|
||||
if (state == Call.State.Connected) {
|
||||
if (call.dir == Call.Dir.Incoming) {
|
||||
val isVideo = LinphoneUtils.isVideoEnabled(call)
|
||||
val type = if (isVideo) {
|
||||
CallAttributesCompat.Companion.CALL_TYPE_VIDEO_CALL
|
||||
} else {
|
||||
CallAttributesCompat.Companion.CALL_TYPE_AUDIO_CALL
|
||||
}
|
||||
scope.launch {
|
||||
Log.i("$TAG Answering [${if (isVideo) "video" else "audio"}] call")
|
||||
callControl.answer(type)
|
||||
}
|
||||
|
||||
if (isVideo && corePreferences.routeAudioToSpeakerWhenVideoIsEnabled) {
|
||||
Log.i("$TAG Answering video call, routing audio to speaker")
|
||||
AudioUtils.routeAudioToSpeaker(call)
|
||||
}
|
||||
answerCall()
|
||||
} else {
|
||||
scope.launch {
|
||||
Log.i("$TAG Setting call active")
|
||||
callControl.setActive()
|
||||
val result = callControl.setActive()
|
||||
if (result is CallControlResult.Error) {
|
||||
Log.e("$TAG Failed to set call control active: $result")
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (state == Call.State.End) {
|
||||
val reason = call.reason
|
||||
val direction = call.dir
|
||||
scope.launch {
|
||||
val disconnectCause = when (reason) {
|
||||
Reason.NotAnswered -> DisconnectCause.REMOTE
|
||||
Reason.Declined -> DisconnectCause.REJECTED
|
||||
Reason.Busy -> {
|
||||
if (direction == Call.Dir.Incoming) {
|
||||
DisconnectCause.MISSED
|
||||
} else {
|
||||
DisconnectCause.BUSY
|
||||
}
|
||||
}
|
||||
else -> DisconnectCause.LOCAL
|
||||
}
|
||||
Log.i("$TAG Disconnecting [${if (direction == Call.Dir.Incoming)"incoming" else "outgoing"}] call with cause [${disconnectCauseToString(disconnectCause)}] because it has ended with reason [$reason]")
|
||||
try {
|
||||
callControl.disconnect(DisconnectCause(disconnectCause))
|
||||
} catch (ise: IllegalArgumentException) {
|
||||
Log.e("$TAG Couldn't disconnect call control with cause [${disconnectCauseToString(disconnectCause)}]: $ise")
|
||||
}
|
||||
}
|
||||
callEnded()
|
||||
} else if (state == Call.State.Error) {
|
||||
val reason = call.reason
|
||||
scope.launch {
|
||||
// For some reason DisconnectCause.ERROR or DisconnectCause.BUSY triggers an IllegalArgumentException with following message
|
||||
// Valid DisconnectCause codes are limited to [DisconnectCause.LOCAL, DisconnectCause.REMOTE, DisconnectCause.MISSED, or DisconnectCause.REJECTED]
|
||||
val disconnectCause = DisconnectCause.REJECTED
|
||||
Log.w("$TAG Disconnecting call with cause [${disconnectCauseToString(disconnectCause)}] due to error [$message] and reason [$reason]")
|
||||
try {
|
||||
callControl.disconnect(DisconnectCause(disconnectCause))
|
||||
} catch (ise: IllegalArgumentException) {
|
||||
Log.e("$TAG Couldn't disconnect call control with cause [${disconnectCauseToString(disconnectCause)}]: $ise")
|
||||
}
|
||||
}
|
||||
callError(message)
|
||||
} else if (state == Call.State.Pausing) {
|
||||
scope.launch {
|
||||
Log.i("$TAG Pausing call")
|
||||
callControl.setInactive()
|
||||
val result = callControl.setInactive()
|
||||
if (result is CallControlResult.Error) {
|
||||
Log.e("$TAG Failed to set call control inactive: $result")
|
||||
}
|
||||
}
|
||||
} else if (state == Call.State.Resuming) {
|
||||
scope.launch {
|
||||
Log.i("$TAG Resuming call")
|
||||
callControl.setActive()
|
||||
val result = callControl.setActive()
|
||||
if (result is CallControlResult.Error) {
|
||||
Log.e("$TAG Failed to set call control active: $result")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -144,67 +103,24 @@ class TelecomCallControlCallback(
|
|||
"$TAG Callback have been set for call, Telecom call ID is [${callControl.getCallId()}]"
|
||||
)
|
||||
|
||||
callControl.availableEndpoints.onEach { list ->
|
||||
Log.i("$TAG New available audio endpoints list")
|
||||
if (availableEndpoints != list) {
|
||||
Log.i(
|
||||
"$TAG List size of available audio endpoints has changed, reload sound devices in SDK"
|
||||
)
|
||||
coreContext.postOnCoreThread { core ->
|
||||
core.reloadSoundDevices()
|
||||
Log.i("$TAG Sound devices reloaded")
|
||||
}
|
||||
coreContext.postOnCoreThread {
|
||||
val state = call.state
|
||||
Log.i("$TAG Call state currently is [$state]")
|
||||
when (state) {
|
||||
Call.State.Connected, Call.State.StreamsRunning -> answerCall()
|
||||
Call.State.End -> callEnded()
|
||||
Call.State.Error -> callError("")
|
||||
Call.State.Released -> callEnded()
|
||||
else -> {} // doing nothing
|
||||
}
|
||||
}
|
||||
|
||||
availableEndpoints = list
|
||||
for (endpoint in list) {
|
||||
Log.i("$TAG Available audio endpoint [${endpoint.name}]")
|
||||
}
|
||||
callControl.availableEndpoints.onEach { list ->
|
||||
Log.i("$TAG New available audio endpoints list but ignoring it")
|
||||
}.launchIn(scope)
|
||||
|
||||
callControl.currentCallEndpoint.onEach { endpoint ->
|
||||
val type = endpoint.type
|
||||
currentEndpoint = type
|
||||
if (endpointUpdateRequestFromLinphone) {
|
||||
Log.i("$TAG Linphone requests to use [${endpoint.name}] audio endpoint with type [$type]")
|
||||
} else {
|
||||
Log.i("$TAG Android requests us to use [${endpoint.name}] audio endpoint with type [$type]")
|
||||
}
|
||||
|
||||
if (!endpointUpdateRequestFromLinphone && !coreContext.isConnectedToAndroidAuto && (type == CallEndpointCompat.Companion.TYPE_EARPIECE || type == CallEndpointCompat.Companion.TYPE_SPEAKER)) {
|
||||
Log.w("$TAG Device isn't connected to Android Auto, do not follow system request to change audio endpoint to either earpiece or speaker")
|
||||
return@onEach
|
||||
}
|
||||
|
||||
// Change audio route in SDK, this way the usual listener will trigger
|
||||
// and we'll be able to update the UI accordingly
|
||||
val route = arrayListOf<AudioDevice.Type>()
|
||||
when (type) {
|
||||
CallEndpointCompat.Companion.TYPE_EARPIECE -> {
|
||||
route.add(AudioDevice.Type.Earpiece)
|
||||
}
|
||||
CallEndpointCompat.Companion.TYPE_SPEAKER -> {
|
||||
route.add(AudioDevice.Type.Speaker)
|
||||
}
|
||||
CallEndpointCompat.Companion.TYPE_BLUETOOTH -> {
|
||||
route.add(AudioDevice.Type.Bluetooth)
|
||||
}
|
||||
CallEndpointCompat.Companion.TYPE_WIRED_HEADSET -> {
|
||||
route.add(AudioDevice.Type.Headphones)
|
||||
route.add(AudioDevice.Type.Headset)
|
||||
}
|
||||
}
|
||||
if (route.isNotEmpty()) {
|
||||
coreContext.postOnCoreThread {
|
||||
if (!AudioUtils.applyAudioRouteChangeInLinphone(call, route)) {
|
||||
Log.w("$TAG Failed to apply audio route change, trying again in 200ms")
|
||||
coreContext.postOnCoreThreadDelayed({
|
||||
AudioUtils.applyAudioRouteChangeInLinphone(call, route)
|
||||
}, 200)
|
||||
}
|
||||
}
|
||||
}
|
||||
endpointUpdateRequestFromLinphone = false
|
||||
Log.i("$TAG Android requests us to use [${endpoint.name}] audio endpoint with type [${endpointTypeToString(endpoint.type)}], ignoring it")
|
||||
}.launchIn(scope)
|
||||
|
||||
callControl.isMuted.onEach { muted ->
|
||||
|
|
@ -214,8 +130,10 @@ class TelecomCallControlCallback(
|
|||
"$TAG We're asked to [${if (muted) "mute" else "unmute"}] the call in state [$callState]"
|
||||
)
|
||||
// Only follow un-mute requests for not outgoing calls (such as joining a conference muted)
|
||||
// and if connected to Android Auto that has a way to let user mute/unmute from the car directly.
|
||||
if (muted || (!LinphoneUtils.isCallOutgoing(callState, false) && coreContext.isConnectedToAndroidAuto)) {
|
||||
// and if connected to Android Auto that has a way to let user mute/unmute from the car directly
|
||||
// or if we muted the call previously following Telecom Manager request.
|
||||
if (muted || mutedByTelecomManager || (!LinphoneUtils.isCallOutgoing(callState, false) && coreContext.isConnectedToAndroidAuto)) {
|
||||
mutedByTelecomManager = muted
|
||||
call.microphoneMuted = muted
|
||||
coreContext.refreshMicrophoneMuteStateEvent.postValue(Event(true))
|
||||
} else {
|
||||
|
|
@ -233,74 +151,71 @@ class TelecomCallControlCallback(
|
|||
}.launchIn(scope)
|
||||
}
|
||||
|
||||
fun applyAudioRouteToCallWithId(routes: List<AudioDevice.Type>): Boolean {
|
||||
endpointUpdateRequestFromLinphone = true
|
||||
Log.i("$TAG Looking for audio endpoint with type [${routes.first()}]")
|
||||
|
||||
var wiredHeadsetFound = false
|
||||
for (endpoint in availableEndpoints) {
|
||||
Log.i(
|
||||
"$TAG Found audio endpoint [${endpoint.name}] with type [${endpoint.type}]"
|
||||
)
|
||||
val matches = when (endpoint.type) {
|
||||
CallEndpointCompat.Companion.TYPE_EARPIECE -> {
|
||||
routes.find { it == AudioDevice.Type.Earpiece }
|
||||
}
|
||||
CallEndpointCompat.Companion.TYPE_SPEAKER -> {
|
||||
routes.find { it == AudioDevice.Type.Speaker }
|
||||
}
|
||||
CallEndpointCompat.Companion.TYPE_BLUETOOTH -> {
|
||||
routes.find { it == AudioDevice.Type.Bluetooth }
|
||||
}
|
||||
CallEndpointCompat.Companion.TYPE_WIRED_HEADSET -> {
|
||||
wiredHeadsetFound = true
|
||||
routes.find { it == AudioDevice.Type.Headset || it == AudioDevice.Type.Headphones }
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
|
||||
if (matches != null) {
|
||||
Log.i(
|
||||
"$TAG Found matching audio endpoint [${endpoint.name}], trying to use it"
|
||||
)
|
||||
if (currentEndpoint == endpoint.type) {
|
||||
Log.w("$TAG Endpoint already in use, skipping")
|
||||
continue
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
Log.i("$TAG Requesting audio endpoint change with [${endpoint.name}]")
|
||||
var result: CallControlResult = callControl.requestEndpointChange(endpoint)
|
||||
var attempts = 1
|
||||
while (result is CallControlResult.Error && attempts <= 10) {
|
||||
delay(100)
|
||||
Log.i(
|
||||
"$TAG Previous attempt failed [$result], requesting again audio endpoint change with [${endpoint.name}]"
|
||||
)
|
||||
result = callControl.requestEndpointChange(endpoint)
|
||||
attempts += 1
|
||||
}
|
||||
|
||||
if (result is CallControlResult.Error) {
|
||||
Log.e("$TAG Failed to change endpoint audio device, error [$result]")
|
||||
} else {
|
||||
Log.i(
|
||||
"$TAG It took [$attempts] attempt(s) to change endpoint audio device..."
|
||||
)
|
||||
currentEndpoint = endpoint.type
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if (routes.size == 1 && routes[0] == AudioDevice.Type.Earpiece && wiredHeadsetFound) {
|
||||
Log.e("$TAG User asked for earpiece but endpoint doesn't exists!")
|
||||
private fun answerCall() {
|
||||
val isVideo = LinphoneUtils.isVideoEnabled(call)
|
||||
val type = if (isVideo) {
|
||||
CallAttributesCompat.CALL_TYPE_VIDEO_CALL
|
||||
} else {
|
||||
Log.e("$TAG No matching endpoint found")
|
||||
CallAttributesCompat.CALL_TYPE_AUDIO_CALL
|
||||
}
|
||||
scope.launch {
|
||||
Log.i("$TAG Answering [${if (isVideo) "video" else "audio"}] call")
|
||||
val result = callControl.answer(type)
|
||||
if (result is CallControlResult.Error) {
|
||||
Log.e("$TAG Failed to answer call control: $result")
|
||||
}
|
||||
}
|
||||
|
||||
if (isVideo && corePreferences.routeAudioToSpeakerWhenVideoIsEnabled) {
|
||||
Log.i("$TAG Answering video call, routing audio to speaker")
|
||||
AudioUtils.routeAudioToSpeaker(call)
|
||||
}
|
||||
}
|
||||
|
||||
private fun callEnded() {
|
||||
val reason = call.reason
|
||||
val direction = call.dir
|
||||
scope.launch {
|
||||
val disconnectCause = when (reason) {
|
||||
Reason.NotAnswered -> DisconnectCause.REMOTE
|
||||
Reason.Declined -> DisconnectCause.REJECTED
|
||||
Reason.Busy -> {
|
||||
if (direction == Call.Dir.Incoming) {
|
||||
DisconnectCause.MISSED
|
||||
} else {
|
||||
DisconnectCause.BUSY
|
||||
}
|
||||
}
|
||||
else -> DisconnectCause.LOCAL
|
||||
}
|
||||
Log.i("$TAG Disconnecting [${if (direction == Call.Dir.Incoming)"incoming" else "outgoing"}] call with cause [${disconnectCauseToString(disconnectCause)}] because it has ended with reason [$reason]")
|
||||
try {
|
||||
val result = callControl.disconnect(DisconnectCause(disconnectCause))
|
||||
if (result is CallControlResult.Error) {
|
||||
Log.e("$TAG Failed to disconnect call control: $result")
|
||||
}
|
||||
} catch (ise: IllegalArgumentException) {
|
||||
Log.e("$TAG Couldn't disconnect call control with cause [${disconnectCauseToString(disconnectCause)}]: $ise")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun callError(message: String) {
|
||||
val reason = call.reason
|
||||
scope.launch {
|
||||
// For some reason DisconnectCause.ERROR or DisconnectCause.BUSY triggers an IllegalArgumentException with following message
|
||||
// Valid DisconnectCause codes are limited to [DisconnectCause.LOCAL, DisconnectCause.REMOTE, DisconnectCause.MISSED, or DisconnectCause.REJECTED]
|
||||
val disconnectCause = DisconnectCause.REJECTED
|
||||
Log.w("$TAG Disconnecting call with cause [${disconnectCauseToString(disconnectCause)}] due to error [$message] and reason [$reason]")
|
||||
try {
|
||||
val result = callControl.disconnect(DisconnectCause(disconnectCause))
|
||||
if (result is CallControlResult.Error) {
|
||||
Log.e("$TAG Failed to disconnect call control: $result")
|
||||
}
|
||||
} catch (ise: IllegalArgumentException) {
|
||||
Log.e("$TAG Couldn't disconnect call control with cause [${disconnectCauseToString(disconnectCause)}]: $ise")
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun disconnectCauseToString(cause: Int): String {
|
||||
|
|
@ -321,4 +236,16 @@ class TelecomCallControlCallback(
|
|||
else -> "UNEXPECTED: $cause"
|
||||
}
|
||||
}
|
||||
|
||||
private fun endpointTypeToString(type: Int): String {
|
||||
return when (type) {
|
||||
CallEndpointCompat.TYPE_UNKNOWN -> "UNKNOWN"
|
||||
CallEndpointCompat.TYPE_EARPIECE -> "EARPIECE"
|
||||
CallEndpointCompat.TYPE_BLUETOOTH -> "BLUETOOTH"
|
||||
CallEndpointCompat.TYPE_WIRED_HEADSET -> "WIRED HEADSET"
|
||||
CallEndpointCompat.TYPE_SPEAKER -> "SPEAKER"
|
||||
CallEndpointCompat.TYPE_STREAMING -> "STREAMING"
|
||||
else -> "UNEXPECTED: $type"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ import kotlinx.coroutines.Dispatchers
|
|||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
import org.linphone.core.AudioDevice
|
||||
import org.linphone.core.Call
|
||||
import org.linphone.core.Core
|
||||
import org.linphone.core.CoreListenerStub
|
||||
|
|
@ -69,19 +68,18 @@ class TelecomManager
|
|||
}
|
||||
}
|
||||
|
||||
private val hasTelecomFeature = context.packageManager.hasSystemFeature("android.software.telecom")
|
||||
|
||||
private var currentlyFollowedCalls: Int = 0
|
||||
|
||||
init {
|
||||
val hasTelecomFeature =
|
||||
context.packageManager.hasSystemFeature("android.software.telecom")
|
||||
Log.i(
|
||||
"$TAG android.software.telecom feature is [${if (hasTelecomFeature) "available" else "not available"}]"
|
||||
)
|
||||
|
||||
try {
|
||||
callsManager.registerAppWithTelecom(
|
||||
CallsManager.CAPABILITY_BASELINE or
|
||||
CallsManager.Companion.CAPABILITY_SUPPORTS_VIDEO_CALLING
|
||||
CallsManager.CAPABILITY_SUPPORTS_VIDEO_CALLING
|
||||
)
|
||||
Log.i("$TAG App has been registered with Telecom")
|
||||
} catch (e: Exception) {
|
||||
|
|
@ -89,11 +87,6 @@ class TelecomManager
|
|||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun getCurrentlyFollowedCalls(): Int {
|
||||
return currentlyFollowedCalls
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun onCallCreated(call: Call) {
|
||||
Log.i("$TAG Call to [${call.remoteAddress.asStringUriOnly()}] created in state [${call.state}]")
|
||||
|
|
@ -119,9 +112,9 @@ class TelecomManager
|
|||
|
||||
val isVideo = LinphoneUtils.isVideoEnabled(call)
|
||||
val type = if (isVideo) {
|
||||
CallAttributesCompat.Companion.CALL_TYPE_VIDEO_CALL
|
||||
CallAttributesCompat.CALL_TYPE_VIDEO_CALL
|
||||
} else {
|
||||
CallAttributesCompat.Companion.CALL_TYPE_AUDIO_CALL
|
||||
CallAttributesCompat.CALL_TYPE_AUDIO_CALL
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
|
|
@ -185,8 +178,14 @@ class TelecomManager
|
|||
}
|
||||
}
|
||||
}
|
||||
} catch (e: CallException) {
|
||||
Log.e("$TAG Failed to add call to Telecom's CallsManager: $e")
|
||||
} catch (ce: CallException) {
|
||||
Log.e("$TAG Failed to add call to Telecom's CallsManager: $ce")
|
||||
} catch (se: SecurityException) {
|
||||
Log.e("$TAG Security exception trying to add call to Telecom's CallsManager: $se")
|
||||
} catch (ise: IllegalArgumentException) {
|
||||
Log.e("$TAG Illegal argument exception trying to add call to Telecom's CallsManager: $ise")
|
||||
} catch (e: Exception) {
|
||||
Log.e("$TAG Exception trying to add call to Telecom's CallsManager: $e")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -194,26 +193,21 @@ class TelecomManager
|
|||
@WorkerThread
|
||||
fun onCoreStarted(core: Core) {
|
||||
Log.i("$TAG Core has been started")
|
||||
core.addListener(coreListener)
|
||||
if (hasTelecomFeature) {
|
||||
core.addListener(coreListener)
|
||||
} else {
|
||||
Log.w(
|
||||
"$TAG android.software.telecom feature is not available, enable audio focus requests in Linphone SDK"
|
||||
)
|
||||
coreContext.core.config.setBool("audio", "android_disable_audio_focus_requests", false)
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun onCoreStopped(core: Core) {
|
||||
Log.i("$TAG Core is being stopped")
|
||||
core.removeListener(coreListener)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun applyAudioRouteToCallWithId(routes: List<AudioDevice.Type>, callId: String): Boolean {
|
||||
Log.i(
|
||||
"$TAG Looking for audio endpoint with type [${routes.first()}] for call with ID [$callId]"
|
||||
)
|
||||
val callControlCallback = map[callId]
|
||||
if (callControlCallback == null) {
|
||||
Log.w("$TAG Failed to find callbacks for call with ID [$callId]")
|
||||
return false
|
||||
if (hasTelecomFeature) {
|
||||
core.removeListener(coreListener)
|
||||
}
|
||||
|
||||
return callControlCallback.applyAudioRouteToCallWithId(routes)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
package org.linphone.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.content.res.Resources
|
||||
|
|
@ -224,15 +225,19 @@ open class GenericActivity : AppCompatActivity() {
|
|||
|
||||
fun goToAndroidPermissionSettings() {
|
||||
Log.i("$TAG Going into Android settings for our app")
|
||||
val intent = Intent(
|
||||
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||
Uri.fromParts(
|
||||
"package",
|
||||
packageName, null
|
||||
try {
|
||||
val intent = Intent(
|
||||
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||
Uri.fromParts(
|
||||
"package",
|
||||
packageName, null
|
||||
)
|
||||
)
|
||||
)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
startActivity(intent)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
startActivity(intent)
|
||||
} catch (anfe: ActivityNotFoundException) {
|
||||
Log.e("$TAG Failed to go to android settings: $anfe")
|
||||
}
|
||||
}
|
||||
|
||||
protected fun enableWindowSecureMode(enable: Boolean) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,134 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2025 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.assistant.fragment
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.core.net.toUri
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.navGraphViewModels
|
||||
import org.linphone.R
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.ui.GenericFragment
|
||||
import org.linphone.databinding.AssistantRecoverAccountFragmentBinding
|
||||
import org.linphone.ui.assistant.viewmodel.AccountCreationViewModel
|
||||
import kotlin.getValue
|
||||
|
||||
@UiThread
|
||||
class RecoverAccountFragment : GenericFragment() {
|
||||
companion object {
|
||||
private const val TAG = "[Recover Account Fragment]"
|
||||
}
|
||||
|
||||
private lateinit var binding: AssistantRecoverAccountFragmentBinding
|
||||
|
||||
private val viewModel: AccountCreationViewModel by navGraphViewModels(
|
||||
R.id.assistant_nav_graph
|
||||
)
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
binding = AssistantRecoverAccountFragmentBinding.inflate(layoutInflater)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
binding.lifecycleOwner = viewLifecycleOwner
|
||||
binding.viewModel = viewModel
|
||||
observeToastEvents(viewModel)
|
||||
|
||||
viewModel.accountRecoveryTokenReceivedEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { token ->
|
||||
Log.i("$TAG Account recovery token received [$token], opening browser")
|
||||
recoverPhoneNumberAccount(token)
|
||||
}
|
||||
}
|
||||
|
||||
binding.setBackClickListener {
|
||||
goBack()
|
||||
}
|
||||
|
||||
binding.setRecoverEmailAccountClickListener {
|
||||
recoverEmailAccount()
|
||||
}
|
||||
|
||||
binding.setRecoverPhoneNumberAccountClickListener {
|
||||
viewModel.requestAccountRecoveryToken()
|
||||
}
|
||||
}
|
||||
|
||||
private fun goBack() {
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
|
||||
private fun recoverEmailAccount() {
|
||||
val rootUrl = getString(R.string.web_platform_forgotten_password_url)
|
||||
val url = "$rootUrl/recovery/email"
|
||||
try {
|
||||
Log.i("$TAG Trying to open [$url] URL")
|
||||
val browserIntent = Intent(Intent.ACTION_VIEW, url.toUri())
|
||||
startActivity(browserIntent)
|
||||
} catch (ise: IllegalStateException) {
|
||||
Log.e(
|
||||
"$TAG Can't start ACTION_VIEW intent for URL [$url], IllegalStateException: $ise"
|
||||
)
|
||||
} catch (anfe: ActivityNotFoundException) {
|
||||
Log.e(
|
||||
"$TAG Can't start ACTION_VIEW intent for URL [$url], ActivityNotFoundException: $anfe"
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(
|
||||
"$TAG Can't start ACTION_VIEW intent for URL [$url]: $e"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun recoverPhoneNumberAccount(recoveryToken: String) {
|
||||
val rootUrl = getString(R.string.web_platform_forgotten_password_url)
|
||||
val url = "$rootUrl/recovery/phone/$recoveryToken"
|
||||
try {
|
||||
Log.i("$TAG Trying to open [$url] URL")
|
||||
val browserIntent = Intent(Intent.ACTION_VIEW, url.toUri())
|
||||
startActivity(browserIntent)
|
||||
} catch (ise: IllegalStateException) {
|
||||
Log.e(
|
||||
"$TAG Can't start ACTION_VIEW intent for URL [$url], IllegalStateException: $ise"
|
||||
)
|
||||
} catch (anfe: ActivityNotFoundException) {
|
||||
Log.e(
|
||||
"$TAG Can't start ACTION_VIEW intent for URL [$url], ActivityNotFoundException: $anfe"
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(
|
||||
"$TAG Can't start ACTION_VIEW intent for URL [$url]: $e"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@
|
|||
*/
|
||||
package org.linphone.ui.assistant.fragment
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
|
|
@ -77,6 +78,14 @@ class LandingFragment : GenericFragment() {
|
|||
requireActivity().finish()
|
||||
}
|
||||
|
||||
binding.setHelpClickListener {
|
||||
if (findNavController().currentDestination?.id == R.id.landingFragment) {
|
||||
val action =
|
||||
LandingFragmentDirections.actionLandingFragmentToHelpFragment()
|
||||
findNavController().navigate(action)
|
||||
}
|
||||
}
|
||||
|
||||
binding.setRegisterClickListener {
|
||||
if (viewModel.conditionsAndPrivacyPolicyAccepted) {
|
||||
goToRegisterFragment()
|
||||
|
|
@ -102,14 +111,10 @@ class LandingFragment : GenericFragment() {
|
|||
}
|
||||
|
||||
binding.setForgottenPasswordClickListener {
|
||||
val url = getString(R.string.web_platform_forgotten_password_url)
|
||||
try {
|
||||
val browserIntent = Intent(Intent.ACTION_VIEW, url.toUri())
|
||||
startActivity(browserIntent)
|
||||
} catch (ise: IllegalStateException) {
|
||||
Log.e(
|
||||
"$TAG Can't start ACTION_VIEW intent for URL [$url], IllegalStateException: $ise"
|
||||
)
|
||||
if (findNavController().currentDestination?.id == R.id.landingFragment) {
|
||||
val action =
|
||||
LandingFragmentDirections.actionLandingFragmentToRecoverAccountFragment()
|
||||
findNavController().navigate(action)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -206,31 +211,36 @@ class LandingFragment : GenericFragment() {
|
|||
model.privacyPolicyClickedEvent.observe(viewLifecycleOwner) {
|
||||
it.consume {
|
||||
val url = getString(R.string.website_privacy_policy_url)
|
||||
try {
|
||||
val browserIntent = Intent(Intent.ACTION_VIEW, url.toUri())
|
||||
startActivity(browserIntent)
|
||||
} catch (ise: IllegalStateException) {
|
||||
Log.e(
|
||||
"$TAG Can't start ACTION_VIEW intent for URL [$url], IllegalStateException: $ise"
|
||||
)
|
||||
}
|
||||
openUrlInBrowser(url)
|
||||
}
|
||||
}
|
||||
|
||||
model.generalTermsClickedEvent.observe(viewLifecycleOwner) {
|
||||
it.consume {
|
||||
val url = getString(R.string.website_terms_and_conditions_url)
|
||||
try {
|
||||
val browserIntent = Intent(Intent.ACTION_VIEW, url.toUri())
|
||||
startActivity(browserIntent)
|
||||
} catch (ise: IllegalStateException) {
|
||||
Log.e(
|
||||
"$TAG Can't start ACTION_VIEW intent for URL [$url], IllegalStateException: $ise"
|
||||
)
|
||||
}
|
||||
openUrlInBrowser(url)
|
||||
}
|
||||
}
|
||||
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
private fun openUrlInBrowser(url: String) {
|
||||
try {
|
||||
val browserIntent = Intent(Intent.ACTION_VIEW, url.toUri())
|
||||
startActivity(browserIntent)
|
||||
} catch (ise: IllegalStateException) {
|
||||
Log.e(
|
||||
"$TAG Can't start ACTION_VIEW intent for URL [$url], IllegalStateException: $ise"
|
||||
)
|
||||
} catch (anfe: ActivityNotFoundException) {
|
||||
Log.e(
|
||||
"$TAG Can't start ACTION_VIEW intent for URL [$url], ActivityNotFoundException: $anfe"
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(
|
||||
"$TAG Can't start ACTION_VIEW intent for URL [$url]: $e"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import androidx.activity.result.contract.ActivityResultContracts
|
|||
import androidx.annotation.UiThread
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.navGraphViewModels
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
import org.linphone.R
|
||||
import org.linphone.compatibility.Compatibility
|
||||
|
|
@ -36,6 +37,8 @@ import org.linphone.core.tools.Log
|
|||
import org.linphone.databinding.AssistantPermissionsFragmentBinding
|
||||
import org.linphone.ui.GenericFragment
|
||||
import org.linphone.ui.assistant.AssistantActivity
|
||||
import org.linphone.ui.assistant.viewmodel.PermissionsViewModel
|
||||
import kotlin.getValue
|
||||
|
||||
@UiThread
|
||||
class PermissionsFragment : GenericFragment() {
|
||||
|
|
@ -45,6 +48,10 @@ class PermissionsFragment : GenericFragment() {
|
|||
|
||||
private lateinit var binding: AssistantPermissionsFragmentBinding
|
||||
|
||||
private val viewModel: PermissionsViewModel by navGraphViewModels(
|
||||
R.id.assistant_nav_graph
|
||||
)
|
||||
|
||||
private var leaving = false
|
||||
|
||||
private val requestPermissionLauncher = registerForActivityResult(
|
||||
|
|
@ -93,6 +100,7 @@ class PermissionsFragment : GenericFragment() {
|
|||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
binding.lifecycleOwner = viewLifecycleOwner
|
||||
binding.viewModel = viewModel
|
||||
|
||||
binding.setBackClickListener {
|
||||
findNavController().popBackStack()
|
||||
|
|
@ -180,10 +188,13 @@ class PermissionsFragment : GenericFragment() {
|
|||
|
||||
private fun areAllPermissionsGranted(): Boolean {
|
||||
for (permission in Compatibility.getAllRequiredPermissionsArray()) {
|
||||
if (ContextCompat.checkSelfPermission(requireContext(), permission) != PackageManager.PERMISSION_GRANTED) {
|
||||
val granted = ContextCompat.checkSelfPermission(requireContext(), permission) == PackageManager.PERMISSION_GRANTED
|
||||
viewModel.setPermissionGranted(permission, granted)
|
||||
if (!granted) {
|
||||
Log.w("$TAG Permission [$permission] hasn't been granted yet!")
|
||||
return false
|
||||
}
|
||||
|
||||
}
|
||||
return Compatibility.hasFullScreenIntentPermission(requireContext())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,10 +84,12 @@ class QrCodeScannerFragment : GenericFragment() {
|
|||
goBack()
|
||||
}
|
||||
|
||||
viewModel.qrCodeFoundEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { isValid ->
|
||||
if (isValid) {
|
||||
viewModel.remoteProvisioningSuccessfulEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { atLeastOneAccountFound ->
|
||||
if (atLeastOneAccountFound) {
|
||||
requireActivity().finish()
|
||||
} else {
|
||||
goBack()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -145,6 +147,8 @@ class QrCodeScannerFragment : GenericFragment() {
|
|||
core.nativePreviewWindowId = null
|
||||
core.isVideoPreviewEnabled = false
|
||||
core.isQrcodeVideoPreviewEnabled = false
|
||||
|
||||
coreContext.setFrontCamera()
|
||||
}
|
||||
|
||||
super.onPause()
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ class RegisterCodeConfirmationFragment : GenericFragment() {
|
|||
clipboard.addPrimaryClipChangedListener {
|
||||
val data = clipboard.primaryClip
|
||||
if (data != null && data.itemCount > 0) {
|
||||
val clip = data.getItemAt(0).text.toString()
|
||||
val clip = data.getItemAt(0).text?.toString() ?: ""
|
||||
if (clip.length == 4) {
|
||||
Log.i(
|
||||
"$TAG Found 4 digits [$clip] as primary clip in clipboard, using it and clear it"
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
*/
|
||||
package org.linphone.ui.assistant.fragment
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
|
|
@ -41,7 +42,6 @@ import org.linphone.LinphoneApplication.Companion.coreContext
|
|||
import org.linphone.R
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.databinding.AssistantRegisterFragmentBinding
|
||||
import org.linphone.ui.GenericActivity
|
||||
import org.linphone.ui.GenericFragment
|
||||
import org.linphone.ui.assistant.viewmodel.AccountCreationViewModel
|
||||
import org.linphone.utils.ConfirmationDialogModel
|
||||
|
|
@ -108,6 +108,14 @@ class RegisterFragment : GenericFragment() {
|
|||
Log.e(
|
||||
"$TAG Can't start ACTION_VIEW intent for URL [$url], IllegalStateException: $ise"
|
||||
)
|
||||
} catch (anfe: ActivityNotFoundException) {
|
||||
Log.e(
|
||||
"$TAG Can't start ACTION_VIEW intent for URL [$url], ActivityNotFoundException: $anfe"
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(
|
||||
"$TAG Can't start ACTION_VIEW intent for URL [$url]: $e"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -131,16 +139,6 @@ class RegisterFragment : GenericFragment() {
|
|||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
||||
})
|
||||
|
||||
viewModel.pushNotificationsAvailable.observe(viewLifecycleOwner) { available ->
|
||||
if (!available) {
|
||||
val text = getString(R.string.assistant_account_register_unavailable_no_push_toast)
|
||||
(requireActivity() as GenericActivity).showRedToast(
|
||||
text,
|
||||
R.drawable.warning_circle
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.normalizedPhoneNumberEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { number ->
|
||||
showPhoneNumberConfirmationDialog(number)
|
||||
|
|
@ -165,20 +163,13 @@ class RegisterFragment : GenericFragment() {
|
|||
}
|
||||
}
|
||||
|
||||
viewModel.errorHappenedEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { error ->
|
||||
(requireActivity() as GenericActivity).showRedToast(
|
||||
error,
|
||||
R.drawable.warning_circle
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val telephonyManager = requireContext().getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager
|
||||
val countryIso = telephonyManager.networkCountryIso
|
||||
coreContext.postOnCoreThread {
|
||||
val fragmentContext = context ?: return@postOnCoreThread
|
||||
|
||||
val adapter = object : ArrayAdapter<String>(
|
||||
requireContext(),
|
||||
fragmentContext,
|
||||
R.layout.drop_down_item,
|
||||
viewModel.dialPlansLabelList
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ import org.linphone.ui.GenericActivity
|
|||
import org.linphone.ui.GenericFragment
|
||||
import org.linphone.ui.assistant.viewmodel.ThirdPartySipAccountLoginViewModel
|
||||
import org.linphone.ui.main.sso.fragment.SingleSignOnFragmentDirections
|
||||
import org.linphone.utils.DialogUtils
|
||||
import org.linphone.utils.PhoneNumberUtils
|
||||
|
||||
@UiThread
|
||||
|
|
@ -98,6 +99,10 @@ class ThirdPartySipAccountLoginFragment : GenericFragment() {
|
|||
goBack()
|
||||
}
|
||||
|
||||
binding.setOutboundProxyTooltipClickListener {
|
||||
showOutboundProxyInfoDialog()
|
||||
}
|
||||
|
||||
viewModel.showPassword.observe(viewLifecycleOwner) {
|
||||
lifecycleScope.launch {
|
||||
delay(50)
|
||||
|
|
@ -159,4 +164,9 @@ class ThirdPartySipAccountLoginFragment : GenericFragment() {
|
|||
private fun goBack() {
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
|
||||
private fun showOutboundProxyInfoDialog() {
|
||||
val dialog = DialogUtils.getAccountOutboundProxyHelpDialog(requireActivity())
|
||||
dialog.show()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
*/
|
||||
package org.linphone.ui.assistant.fragment
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
|
|
@ -67,6 +68,14 @@ class ThirdPartySipAccountWarningFragment : GenericFragment() {
|
|||
Log.e(
|
||||
"$TAG Can't start ACTION_VIEW intent for URL [$url], IllegalStateException: $ise"
|
||||
)
|
||||
} catch (anfe: ActivityNotFoundException) {
|
||||
Log.e(
|
||||
"$TAG Can't start ACTION_VIEW intent for URL [$url], ActivityNotFoundException: $anfe"
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(
|
||||
"$TAG Can't start ACTION_VIEW intent for URL [$url]: $e"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -47,7 +47,6 @@ import org.linphone.core.Dictionary
|
|||
import org.linphone.core.Factory
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.ui.GenericViewModel
|
||||
import org.linphone.utils.AppUtils
|
||||
import org.linphone.utils.Event
|
||||
import org.linphone.utils.LinphoneUtils
|
||||
|
||||
|
|
@ -105,7 +104,7 @@ class AccountCreationViewModel
|
|||
|
||||
val accountCreatedEvent = MutableLiveData<Event<Boolean>>()
|
||||
|
||||
val errorHappenedEvent: MutableLiveData<Event<String>> by lazy {
|
||||
val accountRecoveryTokenReceivedEvent: MutableLiveData<Event<String>> by lazy {
|
||||
MutableLiveData<Event<String>>()
|
||||
}
|
||||
|
||||
|
|
@ -113,7 +112,9 @@ class AccountCreationViewModel
|
|||
private var waitForPushJob: Job? = null
|
||||
|
||||
private lateinit var accountManagerServices: AccountManagerServices
|
||||
private var requestedTokenIsForAccountCreation: Boolean = true
|
||||
private var accountCreationToken: String? = null
|
||||
private var accountRecoveryToken: String? = null
|
||||
|
||||
private var accountCreatedAuthInfo: AuthInfo? = null
|
||||
private var accountCreated: Account? = null
|
||||
|
|
@ -124,7 +125,7 @@ class AccountCreationViewModel
|
|||
request: AccountManagerServicesRequest,
|
||||
data: String?
|
||||
) {
|
||||
Log.i("$TAG Request [$request] was successful, data is [$data]")
|
||||
Log.i("$TAG Request [${request.type}] was successful, data is [$data]")
|
||||
operationInProgress.postValue(false)
|
||||
|
||||
when (request.type) {
|
||||
|
|
@ -138,18 +139,15 @@ class AccountCreationViewModel
|
|||
)
|
||||
}
|
||||
}
|
||||
AccountManagerServicesRequest.Type.SendAccountCreationTokenByPush,
|
||||
AccountManagerServicesRequest.Type.SendAccountRecoveryTokenByPush -> {
|
||||
Log.i("$TAG Send token by push notification request has been accepted, it should be received soon")
|
||||
}
|
||||
AccountManagerServicesRequest.Type.SendPhoneNumberLinkingCodeBySms -> {
|
||||
goToSmsCodeConfirmationViewEvent.postValue(Event(true))
|
||||
}
|
||||
AccountManagerServicesRequest.Type.LinkPhoneNumberUsingCode -> {
|
||||
val account = accountCreated
|
||||
if (account != null) {
|
||||
Log.i(
|
||||
"$TAG Account [${account.params.identityAddress?.asStringUriOnly()}] has been created & activated, setting it as default"
|
||||
)
|
||||
coreContext.core.defaultAccount = account
|
||||
}
|
||||
accountCreatedEvent.postValue(Event(true))
|
||||
enableAccountAndSetItAsDefault()
|
||||
}
|
||||
else -> { }
|
||||
}
|
||||
|
|
@ -163,7 +161,7 @@ class AccountCreationViewModel
|
|||
parameterErrors: Dictionary?
|
||||
) {
|
||||
Log.e(
|
||||
"$TAG Request [$request] returned an error with status code [$statusCode] and message [$errorMessage]"
|
||||
"$TAG Request [${request.type}] returned an error with status code [$statusCode] and message [$errorMessage]"
|
||||
)
|
||||
operationInProgress.postValue(false)
|
||||
|
||||
|
|
@ -181,7 +179,8 @@ class AccountCreationViewModel
|
|||
}
|
||||
|
||||
when (request.type) {
|
||||
AccountManagerServicesRequest.Type.SendAccountCreationTokenByPush -> {
|
||||
AccountManagerServicesRequest.Type.SendAccountCreationTokenByPush,
|
||||
AccountManagerServicesRequest.Type.SendAccountRecoveryTokenByPush -> {
|
||||
Log.w("$TAG Cancelling job waiting for push notification")
|
||||
waitingForFlexiApiPushToken = false
|
||||
waitForPushJob?.cancel()
|
||||
|
|
@ -227,11 +226,19 @@ class AccountCreationViewModel
|
|||
|
||||
val token = customPayload.getString("token")
|
||||
if (token.isNotEmpty()) {
|
||||
accountCreationToken = token
|
||||
Log.i(
|
||||
"$TAG Extracted token [$accountCreationToken] from push payload, creating account"
|
||||
)
|
||||
createAccount()
|
||||
if (requestedTokenIsForAccountCreation) {
|
||||
accountCreationToken = token
|
||||
Log.i(
|
||||
"$TAG Extracted token [$accountCreationToken] from push payload, creating account"
|
||||
)
|
||||
createAccount()
|
||||
} else {
|
||||
accountRecoveryToken = token
|
||||
Log.i(
|
||||
"$TAG Extracted token [$accountRecoveryToken] from push payload, opening browser"
|
||||
)
|
||||
accountRecoveryTokenReceivedEvent.postValue(Event(token))
|
||||
}
|
||||
} else {
|
||||
Log.e("$TAG Push payload JSON object has an empty 'token'!")
|
||||
onFlexiApiTokenRequestError()
|
||||
|
|
@ -301,7 +308,7 @@ class AccountCreationViewModel
|
|||
}
|
||||
|
||||
@UiThread
|
||||
fun phoneNumberConfirmedByUser() {
|
||||
fun askUserToConfirmPhoneNumber() {
|
||||
coreContext.postOnCoreThread {
|
||||
if (::accountManagerServices.isInitialized) {
|
||||
val dialPlan = selectedDialPlan.value
|
||||
|
|
@ -324,9 +331,7 @@ class AccountCreationViewModel
|
|||
normalizedPhoneNumberEvent.postValue(Event(formattedPhoneNumber))
|
||||
} else {
|
||||
Log.e("$TAG Account manager services hasn't been initialized!")
|
||||
errorHappenedEvent.postValue(
|
||||
Event(AppUtils.getString(R.string.assistant_account_register_unexpected_error))
|
||||
)
|
||||
showRedToast(R.string.assistant_account_register_unexpected_error, R.drawable.warning_circle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -337,8 +342,8 @@ class AccountCreationViewModel
|
|||
|
||||
coreContext.postOnCoreThread {
|
||||
if (accountCreationToken.isNullOrEmpty()) {
|
||||
Log.i("$TAG We don't have a creation token, let's request one")
|
||||
requestFlexiApiToken()
|
||||
Log.i("$TAG We don't have an account creation token yet, let's request one")
|
||||
requestFlexiApiToken(requestAccountCreationToken = true)
|
||||
} else {
|
||||
val authInfo = accountCreatedAuthInfo
|
||||
if (authInfo != null) {
|
||||
|
|
@ -352,6 +357,20 @@ class AccountCreationViewModel
|
|||
}
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun requestAccountRecoveryToken() {
|
||||
coreContext.postOnCoreThread {
|
||||
val existingToken = accountRecoveryToken
|
||||
if (existingToken.isNullOrEmpty()) {
|
||||
Log.i("$TAG We don't have an account recovery token yet, let's request one")
|
||||
requestFlexiApiToken(requestAccountCreationToken = false)
|
||||
} else {
|
||||
Log.i("$TAG We've already have a token [$existingToken], using it")
|
||||
accountRecoveryTokenReceivedEvent.postValue(Event(existingToken))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun toggleShowPassword() {
|
||||
showPassword.value = showPassword.value == false
|
||||
|
|
@ -372,7 +391,7 @@ class AccountCreationViewModel
|
|||
val account = accountCreated
|
||||
if (::accountManagerServices.isInitialized && account != null) {
|
||||
val code =
|
||||
"${smsCodeFirstDigit.value}${smsCodeSecondDigit.value}${smsCodeThirdDigit.value}${smsCodeLastDigit.value}"
|
||||
"${smsCodeFirstDigit.value.orEmpty().trim()}${smsCodeSecondDigit.value.orEmpty().trim()}${smsCodeThirdDigit.value.orEmpty().trim()}${smsCodeLastDigit.value.orEmpty().trim()}"
|
||||
val identity = account.params.identityAddress
|
||||
if (identity != null) {
|
||||
Log.i(
|
||||
|
|
@ -496,6 +515,9 @@ class AccountCreationViewModel
|
|||
)
|
||||
accountParams.internationalPrefix = dialPlan.internationalCallPrefix
|
||||
accountParams.internationalPrefixIsoCountryCode = dialPlan.isoCountryCode
|
||||
|
||||
// Do not enable account just yet, wait for it to be activated using SMS code
|
||||
accountParams.isRegisterEnabled = false
|
||||
}
|
||||
val account = core.createAccount(accountParams)
|
||||
core.addAccount(account)
|
||||
|
|
@ -508,7 +530,23 @@ class AccountCreationViewModel
|
|||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun requestFlexiApiToken() {
|
||||
private fun enableAccountAndSetItAsDefault() {
|
||||
val account = accountCreated ?: return
|
||||
Log.i(
|
||||
"$TAG Account [${account.params.identityAddress?.asStringUriOnly()}] has been created & activated, enable it & setting it as default"
|
||||
)
|
||||
|
||||
val newParams = account.params.clone()
|
||||
newParams.isRegisterEnabled = true
|
||||
account.params = newParams
|
||||
|
||||
coreContext.core.defaultAccount = account
|
||||
accountCreatedEvent.postValue(Event(true))
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun requestFlexiApiToken(requestAccountCreationToken: Boolean) {
|
||||
requestedTokenIsForAccountCreation = requestAccountCreationToken
|
||||
if (!coreContext.core.isPushNotificationAvailable) {
|
||||
Log.e(
|
||||
"$TAG Core says push notification aren't available, can't request a token from FlexiAPI"
|
||||
|
|
@ -534,11 +572,21 @@ class AccountCreationViewModel
|
|||
}
|
||||
|
||||
// Request an auth token, will be sent by push
|
||||
val request = accountManagerServices.createSendAccountCreationTokenByPushRequest(
|
||||
provider,
|
||||
param,
|
||||
prid
|
||||
)
|
||||
val request = if (requestAccountCreationToken) {
|
||||
Log.i("$TAG Requesting account creation token")
|
||||
accountManagerServices.createSendAccountCreationTokenByPushRequest(
|
||||
provider,
|
||||
param,
|
||||
prid
|
||||
)
|
||||
} else {
|
||||
Log.i("$TAG Requesting account recovery token")
|
||||
accountManagerServices.createSendAccountRecoveryTokenByPushRequest(
|
||||
provider,
|
||||
param,
|
||||
prid
|
||||
)
|
||||
}
|
||||
request.addListener(accountManagerServicesListener)
|
||||
request.submit()
|
||||
|
||||
|
|
@ -569,12 +617,6 @@ class AccountCreationViewModel
|
|||
private fun onFlexiApiTokenRequestError() {
|
||||
Log.e("$TAG Flexi API token request by push error!")
|
||||
operationInProgress.postValue(false)
|
||||
errorHappenedEvent.postValue(
|
||||
Event(
|
||||
AppUtils.getString(
|
||||
R.string.assistant_account_register_push_notification_not_received_error
|
||||
)
|
||||
)
|
||||
)
|
||||
showRedToast(R.string.assistant_account_register_push_notification_not_received_error, R.drawable.warning_circle)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -181,6 +181,16 @@ open class AccountLoginViewModel
|
|||
return@postOnCoreThread
|
||||
}
|
||||
|
||||
val accounts = core.accountList
|
||||
val found = accounts.find {
|
||||
it.params.identityAddress?.weakEqual(identityAddress) == true
|
||||
}
|
||||
if (found != null) {
|
||||
Log.w("$TAG An account with the same identity address [${identityAddress.asStringUriOnly()}] already exists, do not add it again!")
|
||||
showRedToast(R.string.assistant_account_login_already_connected_error, R.drawable.warning_circle)
|
||||
return@postOnCoreThread
|
||||
}
|
||||
|
||||
val user = identityAddress.username
|
||||
if (user == null) {
|
||||
Log.e(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2025 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.assistant.viewmodel
|
||||
|
||||
import android.Manifest
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.ui.GenericViewModel
|
||||
|
||||
class PermissionsViewModel
|
||||
@UiThread
|
||||
constructor() : GenericViewModel() {
|
||||
companion object {
|
||||
private const val TAG = "[Permissions ViewModel]"
|
||||
}
|
||||
|
||||
val cameraPermissionGranted = MutableLiveData<Boolean>()
|
||||
|
||||
val recordAudioPermissionGranted = MutableLiveData<Boolean>()
|
||||
|
||||
val readContactsPermissionGranted = MutableLiveData<Boolean>()
|
||||
|
||||
val postNotificationsPermissionGranted = MutableLiveData<Boolean>()
|
||||
|
||||
fun setPermissionGranted(permission: String, granted: Boolean) {
|
||||
Log.i("$TAG Permission [$permission] is ${if (granted) "granted" else "not granted yet/denied"}")
|
||||
when (permission) {
|
||||
Manifest.permission.READ_CONTACTS -> readContactsPermissionGranted.postValue(granted)
|
||||
Manifest.permission.RECORD_AUDIO -> recordAudioPermissionGranted.postValue(granted)
|
||||
Manifest.permission.CAMERA -> cameraPermissionGranted.postValue(granted)
|
||||
Manifest.permission.POST_NOTIFICATIONS -> postNotificationsPermissionGranted.postValue(granted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -19,7 +19,6 @@
|
|||
*/
|
||||
package org.linphone.ui.assistant.viewmodel
|
||||
|
||||
import android.util.Patterns
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
|
|
@ -31,6 +30,8 @@ import org.linphone.core.tools.Log
|
|||
import org.linphone.ui.GenericViewModel
|
||||
import org.linphone.utils.Event
|
||||
import org.linphone.R
|
||||
import org.linphone.core.GlobalState
|
||||
import org.linphone.utils.LinphoneUtils
|
||||
|
||||
class QrCodeViewModel
|
||||
@UiThread
|
||||
|
|
@ -39,7 +40,7 @@ class QrCodeViewModel
|
|||
private const val TAG = "[Qr Code Scanner ViewModel]"
|
||||
}
|
||||
|
||||
val qrCodeFoundEvent = MutableLiveData<Event<Boolean>>()
|
||||
val remoteProvisioningSuccessfulEvent = MutableLiveData<Event<Boolean>>()
|
||||
|
||||
val onErrorEvent = MutableLiveData<Event<Boolean>>()
|
||||
|
||||
|
|
@ -47,37 +48,54 @@ class QrCodeViewModel
|
|||
@WorkerThread
|
||||
override fun onConfiguringStatus(core: Core, status: ConfiguringState, message: String?) {
|
||||
Log.i("$TAG Configuring state is [$status]")
|
||||
if (status == ConfiguringState.Successful) {
|
||||
qrCodeFoundEvent.postValue(Event(true))
|
||||
} else if (status == ConfiguringState.Failed) {
|
||||
if (status == ConfiguringState.Failed) {
|
||||
Log.e("$TAG Failure applying remote provisioning: $message")
|
||||
showRedToast(R.string.remote_provisioning_config_failed_toast, R.drawable.warning_circle)
|
||||
onErrorEvent.postValue(Event(true))
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun onGlobalStateChanged(core: Core, state: GlobalState?, message: String) {
|
||||
if (state == GlobalState.On) {
|
||||
if (core.accountList.isEmpty()) {
|
||||
Log.w("$TAG Provisioning was successful but no account has been configured yet, staying in assistant")
|
||||
// Remote provisioning didn't contain any account
|
||||
// and there wasn't at least one configured before either
|
||||
remoteProvisioningSuccessfulEvent.postValue(Event(false))
|
||||
} else {
|
||||
Log.i("$TAG At least an account exists in Core, leaving assistant")
|
||||
remoteProvisioningSuccessfulEvent.postValue(Event(true))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun onQrcodeFound(core: Core, result: String?) {
|
||||
Log.i("$TAG QR Code found: [$result]")
|
||||
if (result == null) {
|
||||
showRedToast(R.string.assistant_qr_code_invalid_toast, R.drawable.warning_circle)
|
||||
} else {
|
||||
val isValidUrl = Patterns.WEB_URL.matcher(result).matches()
|
||||
if (!isValidUrl) {
|
||||
Log.e("$TAG The content of the QR Code doesn't seem to be a valid web URL")
|
||||
val url = LinphoneUtils.getRemoteProvisioningUrlFromUri(result)
|
||||
if (url == null) {
|
||||
Log.e("$TAG The content of the QR Code [$result] doesn't seem to be a valid web URL")
|
||||
showRedToast(R.string.assistant_qr_code_invalid_toast, R.drawable.warning_circle)
|
||||
} else {
|
||||
Log.i(
|
||||
"$TAG QR code URL set, restarting the Core to apply configuration changes"
|
||||
)
|
||||
core.nativePreviewWindowId = null
|
||||
core.isVideoPreviewEnabled = false
|
||||
core.isQrcodeVideoPreviewEnabled = false
|
||||
return
|
||||
}
|
||||
|
||||
core.provisioningUri = result
|
||||
coreContext.core.stop()
|
||||
Log.i(
|
||||
"$TAG Setting QR code URL [$url], restarting the Core outside of iterate() loop to apply configuration changes"
|
||||
)
|
||||
core.nativePreviewWindowId = null
|
||||
core.isVideoPreviewEnabled = false
|
||||
core.isQrcodeVideoPreviewEnabled = false
|
||||
core.provisioningUri = url
|
||||
|
||||
coreContext.postOnCoreThread { core ->
|
||||
Log.i("$TAG Stopping Core")
|
||||
core.stop()
|
||||
Log.i("$TAG Core has been stopped, restarting it")
|
||||
coreContext.core.start()
|
||||
core.start()
|
||||
Log.i("$TAG Core has been restarted")
|
||||
}
|
||||
}
|
||||
|
|
@ -101,18 +119,20 @@ class QrCodeViewModel
|
|||
@UiThread
|
||||
fun setBackCamera() {
|
||||
coreContext.postOnCoreThread { core ->
|
||||
for (camera in core.videoDevicesList) {
|
||||
if (camera.contains("Back")) {
|
||||
Log.i("$TAG Found back facing camera [$camera], using it")
|
||||
coreContext.core.videoDevice = camera
|
||||
return@postOnCoreThread
|
||||
}
|
||||
}
|
||||
// Just in case, on some devices such as Xiaomi Redmi Note 5
|
||||
// this is required right after granting the CAMERA permission
|
||||
core.reloadVideoDevices()
|
||||
|
||||
val first = core.videoDevicesList.firstOrNull()
|
||||
if (first != null) {
|
||||
Log.w("$TAG No back facing camera found, using first one available [$first]")
|
||||
coreContext.core.videoDevice = first
|
||||
if (!coreContext.setBackCamera()) {
|
||||
for (camera in core.videoDevicesList) {
|
||||
if (camera != "StaticImage: Static picture") {
|
||||
Log.w("$TAG No back facing camera found, using first one available [$camera]")
|
||||
coreContext.core.videoDevice = camera
|
||||
return@postOnCoreThread
|
||||
}
|
||||
}
|
||||
|
||||
Log.e("$TAG No camera device found!")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,6 +67,8 @@ class ThirdPartySipAccountLoginViewModel
|
|||
|
||||
val expandAdvancedSettings = MutableLiveData<Boolean>()
|
||||
|
||||
val proxy = MutableLiveData<String>()
|
||||
|
||||
val outboundProxy = MutableLiveData<String>()
|
||||
|
||||
val loginEnabled = MediatorLiveData<Boolean>()
|
||||
|
|
@ -173,11 +175,17 @@ class ThirdPartySipAccountLoginViewModel
|
|||
|
||||
// Remove sip: in front of domain, just in case...
|
||||
val domainValue = domain.value.orEmpty().trim()
|
||||
val domain = if (domainValue.startsWith("sip:")) {
|
||||
val domainWithoutSip = if (domainValue.startsWith("sip:")) {
|
||||
domainValue.substring("sip:".length)
|
||||
} else {
|
||||
domainValue
|
||||
}
|
||||
val domainAddress = Factory.instance().createAddress("sip:$domainWithoutSip")
|
||||
val port = domainAddress?.port ?: -1
|
||||
if (port != -1) {
|
||||
Log.w("$TAG It seems a port [$port] was set in the domain [$domainValue], removing it from SIP identity but setting it to proxy server URI")
|
||||
}
|
||||
val domain = domainAddress?.domain ?: domainWithoutSip
|
||||
|
||||
// Allow to enter SIP identity instead of simply username
|
||||
// in case identity domain doesn't match proxy domain
|
||||
|
|
@ -194,7 +202,6 @@ class ThirdPartySipAccountLoginViewModel
|
|||
val userId = authId.value.orEmpty().trim()
|
||||
|
||||
Log.i("$TAG Parsed username is [$user], user ID [$userId] and domain [$domain]")
|
||||
|
||||
val identity = "sip:$user@$domain"
|
||||
val identityAddress = Factory.instance().createAddress(identity)
|
||||
if (identityAddress == null) {
|
||||
|
|
@ -202,6 +209,17 @@ class ThirdPartySipAccountLoginViewModel
|
|||
showRedToast(R.string.assistant_login_cant_parse_address_toast, R.drawable.warning_circle)
|
||||
return@postOnCoreThread
|
||||
}
|
||||
Log.i("$TAG Computed SIP identity is [${identityAddress.asStringUriOnly()}]")
|
||||
|
||||
val accounts = core.accountList
|
||||
val found = accounts.find {
|
||||
it.params.identityAddress?.weakEqual(identityAddress) == true
|
||||
}
|
||||
if (found != null) {
|
||||
Log.w("$TAG An account with the same identity address [${found.params.identityAddress?.asStringUriOnly()}] already exists, do not add it again!")
|
||||
showRedToast(R.string.assistant_account_login_already_connected_error, R.drawable.warning_circle)
|
||||
return@postOnCoreThread
|
||||
}
|
||||
|
||||
newlyCreatedAuthInfo = Factory.instance().createAuthInfo(
|
||||
user,
|
||||
|
|
@ -209,7 +227,7 @@ class ThirdPartySipAccountLoginViewModel
|
|||
password.value.orEmpty().trim(),
|
||||
null,
|
||||
null,
|
||||
domainValue
|
||||
domainAddress?.domain ?: domainValue
|
||||
)
|
||||
core.addAuthInfo(newlyCreatedAuthInfo)
|
||||
|
||||
|
|
@ -220,8 +238,27 @@ class ThirdPartySipAccountLoginViewModel
|
|||
}
|
||||
accountParams.identityAddress = identityAddress
|
||||
|
||||
val proxyServerValue = proxy.value.orEmpty().trim()
|
||||
val proxyServerAddress = if (proxyServerValue.isNotEmpty()) {
|
||||
val server = if (proxyServerValue.startsWith("sip:")) {
|
||||
proxyServerValue
|
||||
} else {
|
||||
"sip:$proxyServerValue"
|
||||
}
|
||||
Factory.instance().createAddress(server)
|
||||
} else {
|
||||
domainAddress ?: Factory.instance().createAddress("sip:$domainWithoutSip")
|
||||
}
|
||||
proxyServerAddress?.transport = when (transport.value.orEmpty().trim()) {
|
||||
TransportType.Tcp.name.uppercase(Locale.getDefault()) -> TransportType.Tcp
|
||||
TransportType.Tls.name.uppercase(Locale.getDefault()) -> TransportType.Tls
|
||||
else -> TransportType.Udp
|
||||
}
|
||||
Log.i("$TAG Created proxy server SIP address [${proxyServerAddress?.asStringUriOnly()}]")
|
||||
accountParams.serverAddress = proxyServerAddress
|
||||
|
||||
val outboundProxyValue = outboundProxy.value.orEmpty().trim()
|
||||
val serverAddress = if (outboundProxyValue.isNotEmpty()) {
|
||||
val outboundProxyAddress = if (outboundProxyValue.isNotEmpty()) {
|
||||
val server = if (outboundProxyValue.startsWith("sip:")) {
|
||||
outboundProxyValue
|
||||
} else {
|
||||
|
|
@ -229,15 +266,17 @@ class ThirdPartySipAccountLoginViewModel
|
|||
}
|
||||
Factory.instance().createAddress(server)
|
||||
} else {
|
||||
Factory.instance().createAddress("sip:$domain")
|
||||
null
|
||||
}
|
||||
|
||||
serverAddress?.transport = when (transport.value.orEmpty().trim()) {
|
||||
TransportType.Tcp.name.uppercase(Locale.getDefault()) -> TransportType.Tcp
|
||||
TransportType.Tls.name.uppercase(Locale.getDefault()) -> TransportType.Tls
|
||||
else -> TransportType.Udp
|
||||
if (outboundProxyAddress != null) {
|
||||
outboundProxyAddress.transport = when (transport.value.orEmpty().trim()) {
|
||||
TransportType.Tcp.name.uppercase(Locale.getDefault()) -> TransportType.Tcp
|
||||
TransportType.Tls.name.uppercase(Locale.getDefault()) -> TransportType.Tls
|
||||
else -> TransportType.Udp
|
||||
}
|
||||
Log.i("$TAG Created outbound proxy server SIP address [${outboundProxyAddress?.asStringUriOnly()}]")
|
||||
accountParams.setRoutesAddresses(arrayOf(outboundProxyAddress))
|
||||
}
|
||||
accountParams.serverAddress = serverAddress
|
||||
|
||||
val prefix = internationalPrefix.value.orEmpty().trim()
|
||||
val isoCountryCode = internationalPrefixIsoCountryCode.value.orEmpty()
|
||||
|
|
|
|||
|
|
@ -25,7 +25,10 @@ import android.content.pm.PackageManager
|
|||
import android.content.res.Resources
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.os.PowerManager
|
||||
import android.view.KeyEvent
|
||||
import android.view.KeyboardShortcutGroup
|
||||
import android.view.KeyboardShortcutInfo
|
||||
import android.view.Menu
|
||||
import androidx.activity.SystemBarStyle
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
|
|
@ -67,6 +70,7 @@ import org.linphone.ui.call.viewmodel.CallsViewModel
|
|||
import org.linphone.ui.call.viewmodel.CurrentCallViewModel
|
||||
import org.linphone.ui.call.viewmodel.SharedCallViewModel
|
||||
import org.linphone.ui.main.MainActivity
|
||||
import org.linphone.utils.AppUtils
|
||||
|
||||
@UiThread
|
||||
class CallActivity : GenericActivity() {
|
||||
|
|
@ -80,8 +84,6 @@ class CallActivity : GenericActivity() {
|
|||
private lateinit var callsViewModel: CallsViewModel
|
||||
private lateinit var callViewModel: CurrentCallViewModel
|
||||
|
||||
private lateinit var proximityWakeLock: PowerManager.WakeLock
|
||||
|
||||
private var bottomSheetDialog: BottomSheetDialogFragment? = null
|
||||
|
||||
private var isPipSupported = false
|
||||
|
|
@ -150,16 +152,6 @@ class CallActivity : GenericActivity() {
|
|||
WindowInsetsCompat.CONSUMED
|
||||
}
|
||||
|
||||
val powerManager = getSystemService(POWER_SERVICE) as PowerManager
|
||||
if (!powerManager.isWakeLockLevelSupported(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK)) {
|
||||
Log.w("$TAG PROXIMITY_SCREEN_OFF_WAKE_LOCK isn't supported on this device!")
|
||||
}
|
||||
|
||||
proximityWakeLock = powerManager.newWakeLock(
|
||||
PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK,
|
||||
"$packageName;proximity_sensor"
|
||||
)
|
||||
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
WindowInfoTracker
|
||||
.getOrCreate(this@CallActivity)
|
||||
|
|
@ -269,7 +261,7 @@ class CallActivity : GenericActivity() {
|
|||
|
||||
callViewModel.proximitySensorEnabled.observe(this) { enabled ->
|
||||
Log.i("$TAG ${if (enabled) "Enabling" else "Disabling"} proximity sensor")
|
||||
enableProximitySensor(enabled)
|
||||
coreContext.enableProximitySensor(enabled)
|
||||
}
|
||||
|
||||
callsViewModel.showIncomingCallEvent.observe(this) {
|
||||
|
|
@ -374,7 +366,7 @@ class CallActivity : GenericActivity() {
|
|||
}
|
||||
|
||||
override fun onPause() {
|
||||
enableProximitySensor(false)
|
||||
coreContext.enableProximitySensor(false)
|
||||
|
||||
super.onPause()
|
||||
|
||||
|
|
@ -383,7 +375,7 @@ class CallActivity : GenericActivity() {
|
|||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
enableProximitySensor(false)
|
||||
coreContext.enableProximitySensor(false)
|
||||
|
||||
super.onDestroy()
|
||||
|
||||
|
|
@ -421,7 +413,51 @@ class CallActivity : GenericActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
@UiThread
|
||||
override fun onProvideKeyboardShortcuts(
|
||||
data: MutableList<KeyboardShortcutGroup?>?,
|
||||
menu: Menu?,
|
||||
deviceId: Int
|
||||
) {
|
||||
super.onProvideKeyboardShortcuts(data, menu, deviceId)
|
||||
|
||||
val keyboardShortcutGroup = KeyboardShortcutGroup(
|
||||
"Answer/Decline incoming call",
|
||||
listOf(
|
||||
KeyboardShortcutInfo(
|
||||
AppUtils.getString(R.string.call_action_answer),
|
||||
KeyEvent.KEYCODE_A,
|
||||
KeyEvent.META_CTRL_ON or KeyEvent.META_SHIFT_ON
|
||||
),
|
||||
KeyboardShortcutInfo(
|
||||
AppUtils.getString(R.string.call_action_decline),
|
||||
KeyEvent.KEYCODE_D,
|
||||
KeyEvent.META_CTRL_ON or KeyEvent.META_SHIFT_ON
|
||||
)
|
||||
)
|
||||
)
|
||||
data?.add(keyboardShortcutGroup)
|
||||
Log.i("$TAG Incoming call answer/decline shortcuts added")
|
||||
}
|
||||
|
||||
override fun onKeyShortcut(keyCode: Int, event: KeyEvent?): Boolean {
|
||||
if (event?.isCtrlPressed == true && event.isShiftPressed) {
|
||||
val navController = findNavController(R.id.call_nav_container)
|
||||
if (navController.currentDestination?.id == R.id.incomingCallFragment) {
|
||||
when (keyCode) {
|
||||
KeyEvent.KEYCODE_A -> {
|
||||
Log.i("$TAG Answer incoming call shortcut triggered")
|
||||
callViewModel.answer()
|
||||
}
|
||||
KeyEvent.KEYCODE_D -> {
|
||||
Log.i("$TAG Decline incoming call shortcut triggered")
|
||||
callViewModel.hangUp()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun goToMainActivity() {
|
||||
if (isPipSupported && callViewModel.isVideoEnabled.value == true) {
|
||||
Log.i("$TAG User is going back to MainActivity, try entering PiP mode")
|
||||
|
|
@ -545,16 +581,4 @@ class CallActivity : GenericActivity() {
|
|||
modalBottomSheet.show(supportFragmentManager, ConferenceLayoutMenuDialogFragment.TAG)
|
||||
bottomSheetDialog = modalBottomSheet
|
||||
}
|
||||
|
||||
private fun enableProximitySensor(enable: Boolean) {
|
||||
if (enable && !proximityWakeLock.isHeld) {
|
||||
Log.i("$TAG Acquiring PROXIMITY_SCREEN_OFF_WAKE_LOCK for 2 hours")
|
||||
proximityWakeLock.acquire(7200 * 1000L) // 2 heures
|
||||
} else if (!enable && proximityWakeLock.isHeld) {
|
||||
Log.i(
|
||||
"$TAG Asking to release PROXIMITY_SCREEN_OFF_WAKE_LOCK (next time sensor detects no proximity)"
|
||||
)
|
||||
proximityWakeLock.release(PowerManager.RELEASE_FLAG_WAIT_FOR_NO_PROXIMITY)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ class ConferenceParticipantsListAdapter :
|
|||
(holder as ViewHolder).bind(getItem(position))
|
||||
}
|
||||
|
||||
inner class ViewHolder(
|
||||
class ViewHolder(
|
||||
val binding: CallConferenceParticipantListCellBinding
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
@UiThread
|
||||
|
|
|
|||
|
|
@ -19,9 +19,6 @@
|
|||
*/
|
||||
package org.linphone.ui.call.conference.fragment
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.os.SystemClock
|
||||
import android.view.LayoutInflater
|
||||
|
|
@ -43,6 +40,7 @@ import org.linphone.ui.call.CallActivity
|
|||
import org.linphone.ui.call.fragment.GenericCallFragment
|
||||
import org.linphone.ui.call.viewmodel.CallsViewModel
|
||||
import org.linphone.ui.call.viewmodel.CurrentCallViewModel
|
||||
import org.linphone.utils.AppUtils
|
||||
import org.linphone.utils.Event
|
||||
import org.linphone.utils.startAnimatedDrawable
|
||||
|
||||
|
|
@ -272,14 +270,12 @@ class ActiveConferenceCallFragment : GenericCallFragment() {
|
|||
}
|
||||
}
|
||||
|
||||
binding.setShareConferenceClickListener {
|
||||
binding.setCopyConferenceUriToClipboardClickListener {
|
||||
val sipUri = callViewModel.conferenceModel.sipUri.value.orEmpty()
|
||||
if (sipUri.isNotEmpty()) {
|
||||
Log.i("$TAG Sharing conference SIP URI [$sipUri]")
|
||||
|
||||
val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
Log.i("$TAG Copying conference SIP URI [$sipUri] into clipboard")
|
||||
val label = "Conference SIP address"
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText(label, sipUri))
|
||||
AppUtils.copyToClipboard(requireContext(), label, sipUri)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -27,8 +27,6 @@ import androidx.annotation.UiThread
|
|||
import androidx.core.view.doOnPreDraw
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.linphone.core.Address
|
||||
import org.linphone.core.Friend
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.databinding.GenericAddParticipantsFragmentBinding
|
||||
import org.linphone.ui.call.viewmodel.CurrentCallViewModel
|
||||
|
|
@ -65,10 +63,6 @@ class ConferenceAddParticipantsFragment : GenericAddressPickerFragment() {
|
|||
return false
|
||||
}
|
||||
|
||||
override fun onSingleAddressSelected(address: Address, friend: Friend) {
|
||||
Log.e("$TAG This shouldn't happen as we should always be in multiple selection mode here!")
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
viewModel = ViewModelProvider(this)[AddParticipantsViewModel::class.java]
|
||||
|
||||
|
|
|
|||
|
|
@ -19,9 +19,6 @@
|
|||
*/
|
||||
package org.linphone.ui.call.conference.fragment
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.Gravity
|
||||
import android.view.LayoutInflater
|
||||
|
|
@ -45,6 +42,7 @@ import org.linphone.ui.GenericActivity
|
|||
import org.linphone.ui.call.adapter.ConferenceParticipantsListAdapter
|
||||
import org.linphone.ui.call.fragment.GenericCallFragment
|
||||
import org.linphone.ui.call.viewmodel.CurrentCallViewModel
|
||||
import org.linphone.utils.AppUtils
|
||||
import org.linphone.utils.ConfirmationDialogModel
|
||||
import org.linphone.utils.DialogUtils
|
||||
|
||||
|
|
@ -175,7 +173,9 @@ class ConferenceParticipantsListFragment : GenericCallFragment() {
|
|||
|
||||
model.confirmEvent.observe(viewLifecycleOwner) {
|
||||
it.consume {
|
||||
viewModel.conferenceModel.kickParticipant(participant)
|
||||
coreContext.postOnCoreThread {
|
||||
viewModel.conferenceModel.kickParticipant(participant)
|
||||
}
|
||||
val message = getString(R.string.conference_participant_was_kicked_out_toast)
|
||||
val icon = R.drawable.check
|
||||
(requireActivity() as GenericActivity).showGreenToast(message, icon)
|
||||
|
|
@ -205,10 +205,8 @@ class ConferenceParticipantsListFragment : GenericCallFragment() {
|
|||
val sipUri = viewModel.conferenceModel.sipUri.value.orEmpty()
|
||||
if (sipUri.isNotEmpty()) {
|
||||
Log.i("$TAG Sharing conference SIP URI [$sipUri]")
|
||||
|
||||
val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val label = "Conference SIP address"
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText(label, sipUri))
|
||||
AppUtils.copyToClipboard(requireContext(), label, sipUri)
|
||||
}
|
||||
|
||||
popupWindow.dismiss()
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ import androidx.annotation.UiThread
|
|||
import androidx.annotation.WorkerThread
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
import org.linphone.core.MediaDirection
|
||||
import org.linphone.core.ParticipantDevice
|
||||
import org.linphone.core.ParticipantDeviceListenerStub
|
||||
import org.linphone.core.StreamType
|
||||
|
|
@ -43,9 +42,9 @@ class ConferenceParticipantDeviceModel
|
|||
|
||||
val avatarModel = coreContext.contactsManager.getContactAvatarModelForAddress(device.address)
|
||||
|
||||
val name = avatarModel.contactName ?: device.name ?: LinphoneUtils.getDisplayName(
|
||||
device.address
|
||||
)
|
||||
val name = avatarModel.contactName ?: device.name.orEmpty().ifEmpty {
|
||||
LinphoneUtils.getDisplayName(device.address)
|
||||
}
|
||||
|
||||
val isMuted = MutableLiveData<Boolean>()
|
||||
|
||||
|
|
@ -57,7 +56,7 @@ class ConferenceParticipantDeviceModel
|
|||
|
||||
val isVideoAvailable = MutableLiveData<Boolean>()
|
||||
|
||||
val isSendingVideo = MutableLiveData<Boolean>()
|
||||
val isThumbnailAvailable = MutableLiveData<Boolean>()
|
||||
|
||||
val isJoining = MutableLiveData<Boolean>()
|
||||
|
||||
|
|
@ -108,28 +107,6 @@ class ConferenceParticipantDeviceModel
|
|||
isSpeaking.postValue(speaking)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun onStreamAvailabilityChanged(
|
||||
participantDevice: ParticipantDevice,
|
||||
available: Boolean,
|
||||
streamType: StreamType?
|
||||
) {
|
||||
Log.d(
|
||||
"$TAG Participant device [${participantDevice.address.asStringUriOnly()}] stream [$streamType] availability changed to ${if (available) "available" else "not available"}"
|
||||
)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun onStreamCapabilityChanged(
|
||||
participantDevice: ParticipantDevice,
|
||||
direction: MediaDirection?,
|
||||
streamType: StreamType?
|
||||
) {
|
||||
Log.d(
|
||||
"$TAG Participant device [${participantDevice.address.asStringUriOnly()}] stream [$streamType] capability changed to [$direction]"
|
||||
)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun onScreenSharingChanged(
|
||||
participantDevice: ParticipantDevice,
|
||||
|
|
@ -149,19 +126,21 @@ class ConferenceParticipantDeviceModel
|
|||
Log.i(
|
||||
"$TAG Participant device [${participantDevice.address.asStringUriOnly()}] thumbnail availability changed to ${if (available) "available" else "not available"}"
|
||||
)
|
||||
isVideoAvailable.postValue(available)
|
||||
isThumbnailAvailable.postValue(available)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun onThumbnailStreamCapabilityChanged(
|
||||
override fun onStreamAvailabilityChanged(
|
||||
participantDevice: ParticipantDevice,
|
||||
direction: MediaDirection?
|
||||
available: Boolean,
|
||||
streamType: StreamType?
|
||||
) {
|
||||
Log.i(
|
||||
"$TAG Participant device [${participantDevice.address.asStringUriOnly()}] thumbnail capability changed to [$direction]"
|
||||
)
|
||||
val sending = direction == MediaDirection.SendRecv || direction == MediaDirection.SendOnly
|
||||
isSendingVideo.postValue(sending)
|
||||
if (streamType == StreamType.Video) {
|
||||
Log.i(
|
||||
"$TAG Participant device [${participantDevice.address.asStringUriOnly()}] video stream availability changed to ${if (available) "available" else "not available"}"
|
||||
)
|
||||
isVideoAvailable.postValue(available)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -196,18 +175,33 @@ class ConferenceParticipantDeviceModel
|
|||
Log.i("$TAG Participant [${device.address.asStringUriOnly()}] is sharing its screen")
|
||||
}
|
||||
|
||||
isVideoAvailable.postValue(device.getStreamAvailability(StreamType.Video))
|
||||
val videoCapability = device.getStreamCapability(StreamType.Video)
|
||||
isSendingVideo.postValue(
|
||||
videoCapability == MediaDirection.SendRecv || videoCapability == MediaDirection.SendOnly
|
||||
val videoAvailability = device.getStreamAvailability(StreamType.Video)
|
||||
isVideoAvailable.postValue(videoAvailability)
|
||||
Log.i(
|
||||
"$TAG Participant device [${device.address.asStringUriOnly()}] video stream availability is ${if (videoAvailability) "available" else "not available"}"
|
||||
)
|
||||
|
||||
// In case of joining conference without bundle mode, thumbnail stream availability will be false,
|
||||
// but we need to display our video preview for video stream to be sent
|
||||
val thumbnailVideoAvailability = if (isMe) videoAvailability else device.thumbnailStreamAvailability
|
||||
isThumbnailAvailable.postValue(thumbnailVideoAvailability)
|
||||
Log.i(
|
||||
"$TAG Participant device [${device.address.asStringUriOnly()}] thumbnail availability is ${if (thumbnailVideoAvailability) "available" else "not available"}"
|
||||
)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun destroy() {
|
||||
clearWindowId()
|
||||
device.removeListener(deviceListener)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun clearWindowId() {
|
||||
Log.i("$TAG Clearing participant [${device.address.asStringUriOnly()}] device window ID")
|
||||
device.nativeVideoWindowId = null
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun setTextureView(view: TextureView) {
|
||||
Log.i(
|
||||
|
|
|
|||
|
|
@ -68,6 +68,8 @@ class ConferenceViewModel
|
|||
|
||||
val conferenceLayout = MutableLiveData<Int>()
|
||||
|
||||
val screenSharingParticipantName = MutableLiveData<String>()
|
||||
|
||||
val isScreenSharing = MutableLiveData<Boolean>()
|
||||
|
||||
val isPaused = MutableLiveData<Boolean>()
|
||||
|
|
@ -125,10 +127,13 @@ class ConferenceViewModel
|
|||
conference: Conference,
|
||||
device: ParticipantDevice
|
||||
) {
|
||||
if (conference.isMe(device.address)) {
|
||||
if (device.isMe) {
|
||||
Log.i("$TAG Our device media capability changed")
|
||||
val direction = device.getStreamCapability(StreamType.Video)
|
||||
val sendingVideo = direction == MediaDirection.SendRecv || direction == MediaDirection.SendOnly
|
||||
localVideoStreamToggled(sendingVideo)
|
||||
} else {
|
||||
Log.i("$TAG Participant [${device.address.asStringUriOnly()}] device media capability changed")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -156,7 +161,7 @@ class ConferenceViewModel
|
|||
} else {
|
||||
Log.w("$TAG Notified active speaker participant device is null, using first one that's not us")
|
||||
val firstNotUs = participantDevices.value.orEmpty().find {
|
||||
it.isMe == false
|
||||
!it.isMe
|
||||
}
|
||||
if (firstNotUs != null) {
|
||||
Log.i("$TAG Newly active speaker participant is [${firstNotUs.name}]")
|
||||
|
|
@ -237,7 +242,17 @@ class ConferenceViewModel
|
|||
"$TAG Participant device [${device.address.asStringUriOnly()}] is ${if (enabled) "sharing it's screen" else "no longer sharing it's screen"}"
|
||||
)
|
||||
isScreenSharing.postValue(enabled)
|
||||
|
||||
if (enabled) {
|
||||
val deviceModel = participantDevices.value.orEmpty().find {
|
||||
it.device == device || device.address.weakEqual(it.device.address)
|
||||
}
|
||||
if (deviceModel != null) {
|
||||
screenSharingParticipantName.postValue(deviceModel.name)
|
||||
} else {
|
||||
Log.w("$TAG Failed to find screen sharing participant device model!")
|
||||
}
|
||||
|
||||
val call = conference.call
|
||||
if (call != null) {
|
||||
val currentLayout = getCurrentLayout(call)
|
||||
|
|
@ -250,6 +265,8 @@ class ConferenceViewModel
|
|||
} else {
|
||||
Log.e("$TAG Screen sharing was enabled but conference's call is null!")
|
||||
}
|
||||
} else {
|
||||
screenSharingParticipantName.postValue("")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -261,6 +278,7 @@ class ConferenceViewModel
|
|||
isPaused.postValue(!isIn)
|
||||
Log.i("$TAG We [${if (isIn) "are" else "aren't"}] in the conference")
|
||||
|
||||
subject.postValue(conference.subjectUtf8.orEmpty())
|
||||
computeParticipants(false)
|
||||
if (conference.participantList.size >= 1) { // we do not count
|
||||
Log.i("$TAG Joined conference already has at least another participant")
|
||||
|
|
@ -312,7 +330,7 @@ class ConferenceViewModel
|
|||
val chatEnabled = conference.currentParams.isChatEnabled
|
||||
isConversationAvailable.postValue(chatEnabled)
|
||||
|
||||
val confSubject = conference.subject.orEmpty()
|
||||
val confSubject = conference.subjectUtf8.orEmpty()
|
||||
Log.i(
|
||||
"$TAG Configuring conference with subject [$confSubject] from call [${call.callLog.callId}]"
|
||||
)
|
||||
|
|
@ -334,6 +352,15 @@ class ConferenceViewModel
|
|||
"$TAG Conference has a participant sharing its screen, changing layout from mosaic to active speaker"
|
||||
)
|
||||
setNewLayout(ACTIVE_SPEAKER_LAYOUT)
|
||||
} else if (currentLayout == AUDIO_ONLY_LAYOUT) {
|
||||
val defaultLayout = call.core.defaultConferenceLayout.toInt()
|
||||
if (defaultLayout == Conference.Layout.ActiveSpeaker.toInt()) {
|
||||
Log.w("$TAG Joined conference in audio only layout, switching to active speaker layout")
|
||||
setNewLayout(ACTIVE_SPEAKER_LAYOUT)
|
||||
} else {
|
||||
Log.w("$TAG Joined conference in audio only layout, switching to grid layout")
|
||||
setNewLayout(GRID_LAYOUT)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -366,15 +393,17 @@ class ConferenceViewModel
|
|||
|
||||
@UiThread
|
||||
fun goToConversation() {
|
||||
coreContext.postOnCoreThread { core ->
|
||||
Log.i("$TAG Navigating to conference's conversation")
|
||||
val chatRoom = conference.chatRoom
|
||||
if (chatRoom != null) {
|
||||
goToConversationEvent.postValue(Event(LinphoneUtils.getConversationId(chatRoom)))
|
||||
} else {
|
||||
Log.e(
|
||||
"$TAG No chat room available for current conference [${conference.conferenceAddress?.asStringUriOnly()}]"
|
||||
)
|
||||
if (::conference.isInitialized) {
|
||||
coreContext.postOnCoreThread { core ->
|
||||
Log.i("$TAG Navigating to conference's conversation")
|
||||
val chatRoom = conference.chatRoom
|
||||
if (chatRoom != null) {
|
||||
goToConversationEvent.postValue(Event(LinphoneUtils.getConversationId(chatRoom)))
|
||||
} else {
|
||||
Log.e(
|
||||
"$TAG No chat room available for current conference [${conference.conferenceAddress?.asStringUriOnly()}]"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -393,82 +422,96 @@ class ConferenceViewModel
|
|||
|
||||
@UiThread
|
||||
fun inviteSipUrisIntoConference(uris: List<String>) {
|
||||
coreContext.postOnCoreThread { core ->
|
||||
val addresses = arrayListOf<Address>()
|
||||
for (uri in uris) {
|
||||
val address = core.interpretUrl(uri, false)
|
||||
if (address != null) {
|
||||
addresses.add(address)
|
||||
Log.i("$TAG Address [${address.asStringUriOnly()}] will be added to conference")
|
||||
} else {
|
||||
Log.e(
|
||||
"$TAG Failed to parse SIP URI [$uri] into address, can't add it to the conference!"
|
||||
)
|
||||
showRedToast(R.string.conference_failed_to_add_participant_invalid_address_toast, R.drawable.warning_circle)
|
||||
if (::conference.isInitialized) {
|
||||
coreContext.postOnCoreThread { core ->
|
||||
val addresses = arrayListOf<Address>()
|
||||
for (uri in uris) {
|
||||
val address = core.interpretUrl(uri, false)
|
||||
if (address != null) {
|
||||
addresses.add(address)
|
||||
Log.i("$TAG Address [${address.asStringUriOnly()}] will be added to conference")
|
||||
} else {
|
||||
Log.e(
|
||||
"$TAG Failed to parse SIP URI [$uri] into address, can't add it to the conference!"
|
||||
)
|
||||
showRedToast(
|
||||
R.string.conference_failed_to_add_participant_invalid_address_toast,
|
||||
R.drawable.warning_circle
|
||||
)
|
||||
}
|
||||
}
|
||||
val addressesArray = arrayOfNulls<Address>(addresses.size)
|
||||
addresses.toArray(addressesArray)
|
||||
Log.i("$TAG Trying to add [${addressesArray.size}] new participant(s) into conference")
|
||||
conference.addParticipants(addressesArray)
|
||||
}
|
||||
val addressesArray = arrayOfNulls<Address>(addresses.size)
|
||||
addresses.toArray(addressesArray)
|
||||
Log.i("$TAG Trying to add [${addressesArray.size}] new participant(s) into conference")
|
||||
conference.addParticipants(addressesArray)
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun kickParticipant(participant: Participant) {
|
||||
coreContext.postOnCoreThread {
|
||||
Log.i(
|
||||
"$TAG Kicking participant [${participant.address.asStringUriOnly()}] out of conference"
|
||||
)
|
||||
conference.removeParticipant(participant)
|
||||
if (::conference.isInitialized) {
|
||||
coreContext.postOnCoreThread {
|
||||
Log.i(
|
||||
"$TAG Kicking participant [${participant.address.asStringUriOnly()}] out of conference"
|
||||
)
|
||||
conference.removeParticipant(participant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun setNewLayout(newLayout: Int) {
|
||||
val call = conference.call
|
||||
if (call != null) {
|
||||
val params = call.core.createCallParams(call)
|
||||
if (params != null) {
|
||||
val currentLayout = getCurrentLayout(call)
|
||||
if (currentLayout != newLayout) {
|
||||
when (newLayout) {
|
||||
AUDIO_ONLY_LAYOUT -> {
|
||||
Log.i("$TAG Changing conference layout to [Audio Only]")
|
||||
params.isVideoEnabled = false
|
||||
}
|
||||
ACTIVE_SPEAKER_LAYOUT -> {
|
||||
Log.i("$TAG Changing conference layout to [Active Speaker]")
|
||||
params.conferenceVideoLayout = Conference.Layout.ActiveSpeaker
|
||||
}
|
||||
GRID_LAYOUT -> {
|
||||
Log.i("$TAG Changing conference layout to [Grid]")
|
||||
params.conferenceVideoLayout = Conference.Layout.Grid
|
||||
}
|
||||
}
|
||||
if (::conference.isInitialized) {
|
||||
val call = conference.call
|
||||
if (call != null) {
|
||||
val params = call.core.createCallParams(call)
|
||||
if (params != null) {
|
||||
val currentLayout = getCurrentLayout(call)
|
||||
if (currentLayout != newLayout) {
|
||||
when (newLayout) {
|
||||
AUDIO_ONLY_LAYOUT -> {
|
||||
Log.i("$TAG Changing conference layout to [Audio Only]")
|
||||
params.isVideoEnabled = false
|
||||
}
|
||||
|
||||
if (currentLayout == AUDIO_ONLY_LAYOUT) {
|
||||
// Previous layout was audio only, make sure video isn't sent without user consent when switching layout
|
||||
Log.i(
|
||||
"$TAG Previous layout was [Audio Only], enabling video but in receive only direction"
|
||||
ACTIVE_SPEAKER_LAYOUT -> {
|
||||
Log.i("$TAG Changing conference layout to [Active Speaker]")
|
||||
params.conferenceVideoLayout = Conference.Layout.ActiveSpeaker
|
||||
}
|
||||
|
||||
GRID_LAYOUT -> {
|
||||
Log.i("$TAG Changing conference layout to [Grid]")
|
||||
params.conferenceVideoLayout = Conference.Layout.Grid
|
||||
}
|
||||
}
|
||||
|
||||
Log.i("$TAG Clearing participant devices window IDs")
|
||||
participantDevices.value.orEmpty().forEach(ConferenceParticipantDeviceModel::clearWindowId)
|
||||
|
||||
if (currentLayout == AUDIO_ONLY_LAYOUT) {
|
||||
// Previous layout was audio only, make sure video isn't sent without user consent when switching layout
|
||||
Log.i(
|
||||
"$TAG Previous layout was [Audio Only], enabling video but in receive only direction"
|
||||
)
|
||||
params.isVideoEnabled = true
|
||||
params.videoDirection = MediaDirection.RecvOnly
|
||||
}
|
||||
|
||||
Log.i("$TAG Updating conference's call params")
|
||||
call.update(params)
|
||||
conferenceLayout.postValue(newLayout)
|
||||
} else {
|
||||
Log.w(
|
||||
"$TAG The conference is already using selected layout, aborting layout change"
|
||||
)
|
||||
params.isVideoEnabled = true
|
||||
params.videoDirection = MediaDirection.RecvOnly
|
||||
}
|
||||
|
||||
Log.i("$TAG Updating conference's call params")
|
||||
call.update(params)
|
||||
conferenceLayout.postValue(newLayout)
|
||||
} else {
|
||||
Log.w(
|
||||
"$TAG The conference is already using selected layout, aborting layout change"
|
||||
)
|
||||
Log.e("$TAG Failed to create call params, aborting layout change")
|
||||
}
|
||||
} else {
|
||||
Log.e("$TAG Failed to create call params, aborting layout change")
|
||||
Log.e("$TAG Failed to get call from conference, aborting layout change")
|
||||
}
|
||||
} else {
|
||||
Log.e("$TAG Failed to get call from conference, aborting layout change")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -557,6 +600,10 @@ class ConferenceViewModel
|
|||
activeSpeaker.postValue(model)
|
||||
activeSpeakerParticipantDeviceFound = true
|
||||
}
|
||||
if (device == conference.screenSharingParticipantDevice) {
|
||||
Log.i("$TAG Using participant is [${model.name}] as current screen sharing sender")
|
||||
screenSharingParticipantName.postValue(model.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -691,36 +738,38 @@ class ConferenceViewModel
|
|||
|
||||
@WorkerThread
|
||||
private fun addParticipant(participant: Participant) {
|
||||
val list = arrayListOf<ConferenceParticipantModel>()
|
||||
list.addAll(participants.value.orEmpty())
|
||||
if (::conference.isInitialized) {
|
||||
val list = arrayListOf<ConferenceParticipantModel>()
|
||||
list.addAll(participants.value.orEmpty())
|
||||
|
||||
val avatarModel = coreContext.contactsManager.getContactAvatarModelForAddress(
|
||||
participant.address
|
||||
)
|
||||
val newModel = ConferenceParticipantModel(
|
||||
participant,
|
||||
avatarModel,
|
||||
isMeAdmin.value == true,
|
||||
false,
|
||||
{ participant -> // Remove from conference
|
||||
removeParticipantEvent.postValue(
|
||||
Event(Pair(avatarModel.name.value.orEmpty(), participant))
|
||||
)
|
||||
},
|
||||
{ participant, setAdmin -> // Change admin status
|
||||
conference.setParticipantAdminStatus(participant, setAdmin)
|
||||
}
|
||||
)
|
||||
list.add(newModel)
|
||||
|
||||
participants.postValue(sortParticipantList(list))
|
||||
participantsLabel.postValue(
|
||||
AppUtils.getStringWithPlural(
|
||||
R.plurals.conference_participants_list_title,
|
||||
list.size,
|
||||
"${list.size}"
|
||||
val avatarModel = coreContext.contactsManager.getContactAvatarModelForAddress(
|
||||
participant.address
|
||||
)
|
||||
)
|
||||
val newModel = ConferenceParticipantModel(
|
||||
participant,
|
||||
avatarModel,
|
||||
isMeAdmin.value == true,
|
||||
false,
|
||||
{ participant -> // Remove from conference
|
||||
removeParticipantEvent.postValue(
|
||||
Event(Pair(avatarModel.name.value.orEmpty(), participant))
|
||||
)
|
||||
},
|
||||
{ participant, setAdmin -> // Change admin status
|
||||
conference.setParticipantAdminStatus(participant, setAdmin)
|
||||
}
|
||||
)
|
||||
list.add(newModel)
|
||||
|
||||
participants.postValue(sortParticipantList(list))
|
||||
participantsLabel.postValue(
|
||||
AppUtils.getStringWithPlural(
|
||||
R.plurals.conference_participants_list_title,
|
||||
list.size,
|
||||
"${list.size}"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
|
|
|
|||
|
|
@ -258,7 +258,7 @@ class ActiveCallFragment : GenericCallFragment() {
|
|||
)
|
||||
} else {
|
||||
// Only allow "trying again" once
|
||||
showZrtpAlertDialog(false)
|
||||
showZrtpAlertDialog()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -361,15 +361,6 @@ class ActiveCallFragment : GenericCallFragment() {
|
|||
}
|
||||
}
|
||||
|
||||
callViewModel.chatRoomCreationErrorEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { error ->
|
||||
(requireActivity() as GenericActivity).showRedToast(
|
||||
getString(error),
|
||||
R.drawable.warning_circle
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
callViewModel.goToConversationEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { conversationId ->
|
||||
if (findNavController().currentDestination?.id == R.id.activeCallFragment) {
|
||||
|
|
@ -408,7 +399,7 @@ class ActiveCallFragment : GenericCallFragment() {
|
|||
|
||||
if (callViewModel.isZrtpAlertDialogVisible) {
|
||||
Log.i("$TAG Fragment resuming, showing ZRTP alert dialog")
|
||||
showZrtpAlertDialog(false)
|
||||
showZrtpAlertDialog()
|
||||
} else if (callViewModel.isZrtpDialogVisible) {
|
||||
Log.i("$TAG Fragment resuming, showing ZRTP SAS validation dialog")
|
||||
callViewModel.showZrtpSasDialogIfPossible()
|
||||
|
|
@ -481,12 +472,12 @@ class ActiveCallFragment : GenericCallFragment() {
|
|||
callViewModel.isZrtpDialogVisible = true
|
||||
}
|
||||
|
||||
private fun showZrtpAlertDialog(allowTryAgain: Boolean = true) {
|
||||
private fun showZrtpAlertDialog() {
|
||||
if (zrtpSasDialog != null) {
|
||||
zrtpSasDialog?.dismiss()
|
||||
}
|
||||
|
||||
val model = ZrtpAlertDialogModel(allowTryAgain)
|
||||
val model = ZrtpAlertDialogModel(false)
|
||||
val dialog = DialogUtils.getZrtpAlertDialog(requireActivity(), model)
|
||||
|
||||
model.tryAgainEvent.observe(viewLifecycleOwner) { event ->
|
||||
|
|
|
|||
|
|
@ -99,7 +99,9 @@ class CallsListFragment : GenericCallFragment() {
|
|||
|
||||
adapter.callClickedEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { model ->
|
||||
model.togglePauseResume()
|
||||
coreContext.postOnCoreThread {
|
||||
model.togglePauseResume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,16 +19,22 @@
|
|||
*/
|
||||
package org.linphone.ui.call.fragment
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
import org.linphone.R
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.databinding.CallIncomingFragmentBinding
|
||||
import org.linphone.ui.call.viewmodel.CurrentCallViewModel
|
||||
import org.linphone.utils.AppUtils
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
@UiThread
|
||||
class IncomingCallFragment : GenericCallFragment() {
|
||||
|
|
@ -40,6 +46,53 @@ class IncomingCallFragment : GenericCallFragment() {
|
|||
|
||||
private lateinit var callViewModel: CurrentCallViewModel
|
||||
|
||||
private val marginSize = AppUtils.getDimension(R.dimen.sliding_accept_decline_call_margin)
|
||||
private val areaSize = AppUtils.getDimension(R.dimen.call_button_size) + marginSize
|
||||
private var initialX = 0f
|
||||
private var slidingButtonX = 0f
|
||||
private val slidingButtonTouchListener = View.OnTouchListener { view, event ->
|
||||
val width = binding.bottomBar.lockedScreenBottomBar.root.width.toFloat()
|
||||
val aboveAnswer = view.x + view.width > width - areaSize
|
||||
val aboveDecline = view.x < areaSize
|
||||
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
if (initialX == 0f) {
|
||||
initialX = view.x
|
||||
}
|
||||
slidingButtonX = view.x - event.rawX
|
||||
true
|
||||
}
|
||||
MotionEvent.ACTION_UP -> {
|
||||
if (aboveAnswer) {
|
||||
// Accept
|
||||
callViewModel.answer()
|
||||
} else if (aboveDecline) {
|
||||
// Decline
|
||||
callViewModel.hangUp()
|
||||
} else {
|
||||
// Animate going back to initial position
|
||||
view.animate()
|
||||
.x(initialX)
|
||||
.setDuration(500)
|
||||
.start()
|
||||
}
|
||||
true
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
view.animate()
|
||||
.x(min(max(marginSize, event.rawX + slidingButtonX), width - view.width - marginSize))
|
||||
.setDuration(0)
|
||||
.start()
|
||||
true
|
||||
}
|
||||
else -> {
|
||||
view.performClick()
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
|
|
@ -49,6 +102,7 @@ class IncomingCallFragment : GenericCallFragment() {
|
|||
return binding.root
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
|
|
@ -68,11 +122,14 @@ class IncomingCallFragment : GenericCallFragment() {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
binding.bottomBar.lockedScreenBottomBar.slidingButton.setOnTouchListener(slidingButtonTouchListener)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
callViewModel.refreshKeyguardLockedStatus()
|
||||
coreContext.notificationsManager.setIncomingCallFragmentCurrentlyDisplayed(true)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import androidx.navigation.navGraphViewModels
|
|||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
import org.linphone.LinphoneApplication.Companion.corePreferences
|
||||
import org.linphone.R
|
||||
import org.linphone.contacts.getListOfSipAddressesAndPhoneNumbers
|
||||
import org.linphone.core.Address
|
||||
|
|
@ -61,6 +62,16 @@ class NewCallFragment : GenericCallFragment() {
|
|||
R.id.call_nav_graph
|
||||
)
|
||||
|
||||
private val bottomSheetCallback = object : BottomSheetBehavior.BottomSheetCallback() {
|
||||
override fun onStateChanged(bottomSheet: View, newState: Int) {
|
||||
if (newState == BottomSheetBehavior.STATE_COLLAPSED || newState == BottomSheetBehavior.STATE_HIDDEN) {
|
||||
viewModel.isNumpadVisible.value = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSlide(bottomSheet: View, slideOffset: Float) { }
|
||||
}
|
||||
|
||||
private lateinit var adapter: ConversationsContactsAndSuggestionsListAdapter
|
||||
|
||||
private val listener = object : ContactNumberOrAddressClickListener {
|
||||
|
|
@ -185,12 +196,15 @@ class NewCallFragment : GenericCallFragment() {
|
|||
}
|
||||
}
|
||||
|
||||
val bottomSheetBehavior = BottomSheetBehavior.from(binding.numpadLayout.root)
|
||||
bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
bottomSheetBehavior.addBottomSheetCallback(bottomSheetCallback)
|
||||
|
||||
viewModel.isNumpadVisible.observe(viewLifecycleOwner) { visible ->
|
||||
val standardBottomSheetBehavior = BottomSheetBehavior.from(binding.numpadLayout.root)
|
||||
if (visible) {
|
||||
standardBottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
bottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
} else {
|
||||
standardBottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -201,6 +215,16 @@ class NewCallFragment : GenericCallFragment() {
|
|||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
coreContext.postOnCoreThread {
|
||||
if (corePreferences.automaticallyShowDialpad) {
|
||||
viewModel.isNumpadVisible.postValue(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
|
||||
|
|
|
|||
|
|
@ -26,10 +26,13 @@ import android.view.ViewGroup
|
|||
import androidx.annotation.UiThread
|
||||
import androidx.core.view.doOnLayout
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
import org.linphone.core.Call
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.databinding.CallOutgoingFragmentBinding
|
||||
import org.linphone.ui.call.viewmodel.CurrentCallViewModel
|
||||
import org.linphone.utils.LinphoneUtils
|
||||
|
||||
@UiThread
|
||||
class OutgoingCallFragment : GenericCallFragment() {
|
||||
|
|
@ -60,15 +63,31 @@ class OutgoingCallFragment : GenericCallFragment() {
|
|||
|
||||
binding.lifecycleOwner = viewLifecycleOwner
|
||||
binding.viewModel = callViewModel
|
||||
binding.numpadModel = callViewModel.numpadModel
|
||||
|
||||
callViewModel.isOutgoingEarlyMedia.observe(viewLifecycleOwner) { earlyMedia ->
|
||||
if (earlyMedia) {
|
||||
coreContext.postOnCoreThread { core ->
|
||||
Log.i("$TAG Outgoing early-media call with video, setting preview surface")
|
||||
core.nativePreviewWindowId = binding.localPreviewVideoSurface
|
||||
val call = core.calls.find {
|
||||
it.state == Call.State.OutgoingEarlyMedia
|
||||
}
|
||||
if (call != null && LinphoneUtils.isVideoEnabled(call)) {
|
||||
Log.i("$TAG Outgoing early-media call with video, setting preview surface")
|
||||
core.nativePreviewWindowId = binding.localPreviewVideoSurface
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val numpadBottomSheetBehavior = BottomSheetBehavior.from(binding.callNumpad.root)
|
||||
numpadBottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
numpadBottomSheetBehavior.skipCollapsed = true
|
||||
|
||||
callViewModel.showNumpadBottomSheetEvent.observe(viewLifecycleOwner) {
|
||||
it.consume {
|
||||
numpadBottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
|
|
|
|||
|
|
@ -33,7 +33,9 @@ import androidx.recyclerview.widget.LinearLayoutManager
|
|||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import kotlin.getValue
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
import org.linphone.LinphoneApplication.Companion.corePreferences
|
||||
import org.linphone.R
|
||||
import org.linphone.core.Address
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.databinding.CallTransferFragmentBinding
|
||||
import org.linphone.ui.call.adapter.CallsListAdapter
|
||||
|
|
@ -43,7 +45,6 @@ import org.linphone.ui.call.viewmodel.CurrentCallViewModel
|
|||
import org.linphone.ui.main.adapter.ConversationsContactsAndSuggestionsListAdapter
|
||||
import org.linphone.ui.main.history.viewmodel.StartCallViewModel
|
||||
import org.linphone.utils.ConfirmationDialogModel
|
||||
import org.linphone.ui.main.model.ConversationContactOrSuggestionModel
|
||||
import org.linphone.utils.AppUtils
|
||||
import org.linphone.utils.DialogUtils
|
||||
import org.linphone.utils.RecyclerViewHeaderDecoration
|
||||
|
|
@ -63,6 +64,16 @@ class TransferCallFragment : GenericCallFragment() {
|
|||
R.id.call_nav_graph
|
||||
)
|
||||
|
||||
private val bottomSheetCallback = object : BottomSheetBehavior.BottomSheetCallback() {
|
||||
override fun onStateChanged(bottomSheet: View, newState: Int) {
|
||||
if (newState == BottomSheetBehavior.STATE_COLLAPSED || newState == BottomSheetBehavior.STATE_HIDDEN) {
|
||||
viewModel.isNumpadVisible.value = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSlide(bottomSheet: View, slideOffset: Float) { }
|
||||
}
|
||||
|
||||
private lateinit var callViewModel: CurrentCallViewModel
|
||||
|
||||
private lateinit var callsViewModel: CallsViewModel
|
||||
|
|
@ -119,18 +130,21 @@ class TransferCallFragment : GenericCallFragment() {
|
|||
binding.callsList.setHasFixedSize(true)
|
||||
binding.contactsAndSuggestionsList.setHasFixedSize(true)
|
||||
|
||||
binding.contactsAndSuggestionsList.layoutManager = LinearLayoutManager(requireContext())
|
||||
binding.callsList.layoutManager = LinearLayoutManager(requireContext())
|
||||
|
||||
val headerItemDecoration = RecyclerViewHeaderDecoration(requireContext(), contactsAdapter)
|
||||
binding.contactsAndSuggestionsList.addItemDecoration(headerItemDecoration)
|
||||
|
||||
callsAdapter.callClickedEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { model ->
|
||||
showConfirmAttendedTransferDialog(model)
|
||||
}
|
||||
}
|
||||
|
||||
val headerItemDecoration = RecyclerViewHeaderDecoration(requireContext(), contactsAdapter)
|
||||
binding.contactsAndSuggestionsList.addItemDecoration(headerItemDecoration)
|
||||
|
||||
contactsAdapter.onClickedEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { model ->
|
||||
showConfirmBlindTransferDialog(model)
|
||||
showConfirmBlindTransferDialog(model.address, model.name)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -145,9 +159,6 @@ class TransferCallFragment : GenericCallFragment() {
|
|||
}
|
||||
}
|
||||
|
||||
binding.contactsAndSuggestionsList.layoutManager = LinearLayoutManager(requireContext())
|
||||
binding.callsList.layoutManager = LinearLayoutManager(requireContext())
|
||||
|
||||
viewModel.modelsList.observe(
|
||||
viewLifecycleOwner
|
||||
) {
|
||||
|
|
@ -208,12 +219,23 @@ class TransferCallFragment : GenericCallFragment() {
|
|||
}
|
||||
}
|
||||
|
||||
val bottomSheetBehavior = BottomSheetBehavior.from(binding.numpadLayout.root)
|
||||
bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
bottomSheetBehavior.addBottomSheetCallback(bottomSheetCallback)
|
||||
|
||||
viewModel.isNumpadVisible.observe(viewLifecycleOwner) { visible ->
|
||||
val standardBottomSheetBehavior = BottomSheetBehavior.from(binding.numpadLayout.root)
|
||||
if (visible) {
|
||||
standardBottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
bottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
} else {
|
||||
standardBottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.initiateBlindTransferEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { pair ->
|
||||
val address = pair.first
|
||||
val displayName = pair.second
|
||||
showConfirmBlindTransferDialog(address, displayName)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -238,13 +260,22 @@ class TransferCallFragment : GenericCallFragment() {
|
|||
R.string.call_transfer_current_call_title,
|
||||
callViewModel.displayedName.value ?: callViewModel.displayedAddress.value
|
||||
)
|
||||
|
||||
coreContext.postOnCoreThread {
|
||||
if (corePreferences.automaticallyShowDialpad) {
|
||||
viewModel.isNumpadVisible.postValue(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showConfirmAttendedTransferDialog(callModel: CallModel) {
|
||||
val from = callViewModel.displayedName.value.orEmpty()
|
||||
val to = callModel.displayName.value.orEmpty()
|
||||
Log.i("$TAG Asking user confirmation before doing attended transfer of call with [$from] to [$to](${callModel.call.remoteAddress.asStringUriOnly()})")
|
||||
val label = AppUtils.getFormattedString(
|
||||
R.string.call_transfer_confirm_dialog_message,
|
||||
callViewModel.displayedName.value.orEmpty(),
|
||||
callModel.displayName.value.orEmpty()
|
||||
from,
|
||||
to
|
||||
)
|
||||
val model = ConfirmationDialogModel(label)
|
||||
val dialog = DialogUtils.getConfirmCallTransferCallDialog(
|
||||
|
|
@ -252,8 +283,9 @@ class TransferCallFragment : GenericCallFragment() {
|
|||
model
|
||||
)
|
||||
|
||||
model.cancelEvent.observe(viewLifecycleOwner) {
|
||||
model.dismissEvent.observe(viewLifecycleOwner) {
|
||||
it.consume {
|
||||
Log.i("$TAG Attended transfer was cancelled by user")
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
|
|
@ -276,11 +308,13 @@ class TransferCallFragment : GenericCallFragment() {
|
|||
dialog.show()
|
||||
}
|
||||
|
||||
private fun showConfirmBlindTransferDialog(contactModel: ConversationContactOrSuggestionModel) {
|
||||
private fun showConfirmBlindTransferDialog(toAddress: Address, toDisplayName: String) {
|
||||
val from = callViewModel.displayedName.value.orEmpty()
|
||||
Log.i("$TAG Asking user confirmation before doing blind transfer of call with [$from] to [$toDisplayName](${toAddress.asStringUriOnly()})")
|
||||
val label = AppUtils.getFormattedString(
|
||||
R.string.call_transfer_confirm_dialog_message,
|
||||
callViewModel.displayedName.value.orEmpty(),
|
||||
contactModel.name
|
||||
from,
|
||||
toDisplayName
|
||||
)
|
||||
val model = ConfirmationDialogModel(label)
|
||||
val dialog = DialogUtils.getConfirmCallTransferCallDialog(
|
||||
|
|
@ -288,8 +322,9 @@ class TransferCallFragment : GenericCallFragment() {
|
|||
model
|
||||
)
|
||||
|
||||
model.cancelEvent.observe(viewLifecycleOwner) {
|
||||
model.dismissEvent.observe(viewLifecycleOwner) {
|
||||
it.consume {
|
||||
Log.i("$TAG Blind transfer was cancelled by user")
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
|
|
@ -297,9 +332,8 @@ class TransferCallFragment : GenericCallFragment() {
|
|||
model.confirmEvent.observe(viewLifecycleOwner) {
|
||||
it.consume {
|
||||
coreContext.postOnCoreThread {
|
||||
val address = contactModel.address
|
||||
Log.i("$TAG Transferring (blind) call to [${address.asStringUriOnly()}]")
|
||||
callViewModel.blindTransferCallTo(address)
|
||||
Log.i("$TAG Transferring (blind) call to [${toAddress.asStringUriOnly()}]")
|
||||
callViewModel.blindTransferCallTo(toAddress)
|
||||
}
|
||||
|
||||
dialog.dismiss()
|
||||
|
|
|
|||
99
app/src/main/java/org/linphone/ui/call/view/VuMeterView.kt
Normal file
99
app/src/main/java/org/linphone/ui/call/view/VuMeterView.kt
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2025 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.call.view
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.BitmapShader
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Shader
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.createBitmap
|
||||
import org.linphone.R
|
||||
|
||||
class VuMeterView : View {
|
||||
companion object {
|
||||
private const val TAG = "[VuMeter View]"
|
||||
}
|
||||
|
||||
private lateinit var paint: Paint
|
||||
private lateinit var matrix: Matrix
|
||||
private lateinit var vuMeterPaint: Paint
|
||||
|
||||
private val color = ContextCompat.getColor(context, R.color.vu_meter)
|
||||
|
||||
private var vuMeterPercentage: Float = 0f
|
||||
|
||||
constructor(context: Context?) : super(context) {
|
||||
init()
|
||||
}
|
||||
|
||||
constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) {
|
||||
init()
|
||||
}
|
||||
|
||||
constructor(context: Context?, attrs: AttributeSet?, defStyle: Int) : super(
|
||||
context,
|
||||
attrs,
|
||||
defStyle
|
||||
) {
|
||||
init()
|
||||
}
|
||||
|
||||
private fun init() {
|
||||
paint = Paint()
|
||||
paint.isAntiAlias = true
|
||||
matrix = Matrix()
|
||||
|
||||
vuMeterPaint = Paint()
|
||||
vuMeterPaint.strokeWidth = 2f
|
||||
vuMeterPaint.isAntiAlias = true
|
||||
vuMeterPaint.color = color
|
||||
}
|
||||
|
||||
fun setVuMeterPercentage(percentage: Float) {
|
||||
vuMeterPercentage = percentage
|
||||
invalidate()
|
||||
}
|
||||
|
||||
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
||||
super.onSizeChanged(w, h, oldw, oldh)
|
||||
createShader()
|
||||
}
|
||||
|
||||
private fun createShader(): Shader {
|
||||
val level = (height - height * vuMeterPercentage)
|
||||
|
||||
val bitmap = createBitmap(width, height)
|
||||
val canvas = Canvas(bitmap)
|
||||
canvas.drawRect(0f, height.toFloat(), width.toFloat(), level, vuMeterPaint)
|
||||
|
||||
val shader = BitmapShader(bitmap, Shader.TileMode.REPEAT, Shader.TileMode.CLAMP)
|
||||
return shader
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
paint.shader = createShader()
|
||||
canvas.drawCircle(width / 2f, height / 2f, width / 2f, paint)
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,8 @@
|
|||
package org.linphone.ui.call.viewmodel
|
||||
|
||||
import android.Manifest
|
||||
import android.app.KeyguardManager
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.annotation.AnyThread
|
||||
import androidx.annotation.UiThread
|
||||
|
|
@ -29,6 +31,9 @@ import androidx.lifecycle.MutableLiveData
|
|||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
|
|
@ -69,6 +74,8 @@ class CurrentCallViewModel
|
|||
constructor() : GenericViewModel() {
|
||||
companion object {
|
||||
private const val TAG = "[Current Call ViewModel]"
|
||||
private const val VU_METER_MIN = -20f
|
||||
private const val VU_METER_MAX = 4
|
||||
}
|
||||
|
||||
val contact = MutableLiveData<ContactAvatarModel>()
|
||||
|
|
@ -107,12 +114,20 @@ class CurrentCallViewModel
|
|||
|
||||
val isMicrophoneMuted = MutableLiveData<Boolean>()
|
||||
|
||||
val microphoneRecordingVolume = MutableLiveData<Float>()
|
||||
|
||||
val playbackVolume = MutableLiveData<Float>()
|
||||
|
||||
val isSpeakerEnabled = MutableLiveData<Boolean>()
|
||||
|
||||
val isHeadsetEnabled = MutableLiveData<Boolean>()
|
||||
|
||||
val isHearingAidEnabled = MutableLiveData<Boolean>()
|
||||
|
||||
val isBluetoothEnabled = MutableLiveData<Boolean>()
|
||||
|
||||
val isHdmiEnabled = MutableLiveData<Boolean>()
|
||||
|
||||
val fullScreenMode = MutableLiveData<Boolean>()
|
||||
|
||||
val pipMode = MutableLiveData<Boolean>()
|
||||
|
|
@ -143,6 +158,8 @@ class CurrentCallViewModel
|
|||
|
||||
val qualityIcon = MutableLiveData<Int>()
|
||||
|
||||
val hideSipAddresses = MutableLiveData<Boolean>()
|
||||
|
||||
var terminatedByUser = false
|
||||
|
||||
val isRemoteRecordingEvent: MutableLiveData<Event<Pair<Boolean, String>>> by lazy {
|
||||
|
|
@ -203,10 +220,6 @@ class CurrentCallViewModel
|
|||
MutableLiveData<Event<String>>()
|
||||
}
|
||||
|
||||
val chatRoomCreationErrorEvent: MutableLiveData<Event<Int>> by lazy {
|
||||
MutableLiveData<Event<Int>>()
|
||||
}
|
||||
|
||||
// Conference
|
||||
|
||||
val conferenceModel = ConferenceViewModel()
|
||||
|
|
@ -247,6 +260,10 @@ class CurrentCallViewModel
|
|||
MutableLiveData<Event<Boolean>>()
|
||||
}
|
||||
|
||||
// Sliding answer/decline button
|
||||
|
||||
val isScreenLocked = MutableLiveData<Boolean>()
|
||||
|
||||
lateinit var currentCall: Call
|
||||
|
||||
private val contactsListener = object : ContactsListener {
|
||||
|
|
@ -278,6 +295,7 @@ class CurrentCallViewModel
|
|||
updateEncryption()
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun onAuthenticationTokenVerified(call: Call, verified: Boolean) {
|
||||
Log.w(
|
||||
"$TAG Notified that authentication token is [${if (verified) "verified" else "not verified!"}]"
|
||||
|
|
@ -291,11 +309,13 @@ class CurrentCallViewModel
|
|||
updateAvatarModelSecurityLevel(verified)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun onRemoteRecording(call: Call, recording: Boolean) {
|
||||
Log.i("$TAG Remote recording changed: $recording")
|
||||
isRemoteRecordingEvent.postValue(Event(Pair(recording, displayedName.value.orEmpty())))
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun onStatsUpdated(call: Call, stats: CallStats) {
|
||||
callStatsModel.update(call, stats)
|
||||
}
|
||||
|
|
@ -320,7 +340,6 @@ class CurrentCallViewModel
|
|||
"$TAG From now on current call will be [${newCurrentCall.remoteAddress.asStringUriOnly()}]"
|
||||
)
|
||||
configureCall(newCurrentCall)
|
||||
updateEncryption()
|
||||
} else {
|
||||
Log.e("$TAG Failed to get a valid call to display")
|
||||
endCall(call)
|
||||
|
|
@ -329,7 +348,7 @@ class CurrentCallViewModel
|
|||
endCall(call)
|
||||
}
|
||||
} else {
|
||||
val videoEnabled = call.currentParams.isVideoEnabled
|
||||
val videoEnabled = LinphoneUtils.isVideoEnabled(call)
|
||||
if (videoEnabled && isVideoEnabled.value == false) {
|
||||
if (isBluetoothEnabled.value == true || isHeadsetEnabled.value == true) {
|
||||
Log.i(
|
||||
|
|
@ -341,7 +360,7 @@ class CurrentCallViewModel
|
|||
}
|
||||
}
|
||||
isVideoEnabled.postValue(videoEnabled)
|
||||
updateVideoDirection(call.currentParams.videoDirection)
|
||||
updateVideoDirection(call.currentParams.videoDirection, skipIfNotStreamsRunning = true)
|
||||
|
||||
if (call.state == Call.State.Connected) {
|
||||
updateCallDuration()
|
||||
|
|
@ -406,14 +425,13 @@ class CurrentCallViewModel
|
|||
Log.e("$TAG Conversation [$id] creation has failed!")
|
||||
chatRoom.removeListener(this)
|
||||
operationInProgress.postValue(false)
|
||||
chatRoomCreationErrorEvent.postValue(
|
||||
Event(R.string.conversation_creation_error_toast)
|
||||
)
|
||||
showRedToast(R.string.conversation_failed_to_create_toast, R.drawable.warning_circle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val coreListener = object : CoreListenerStub() {
|
||||
@WorkerThread
|
||||
override fun onCallStateChanged(
|
||||
core: Core,
|
||||
call: Call,
|
||||
|
|
@ -432,7 +450,6 @@ class CurrentCallViewModel
|
|||
)
|
||||
currentCall.removeListener(callListener)
|
||||
configureCall(call)
|
||||
updateEncryption()
|
||||
} else if (LinphoneUtils.isCallIncoming(call.state)) {
|
||||
Log.w(
|
||||
"$TAG A call is being received [${call.remoteAddress.asStringUriOnly()}], using it as current call unless declined"
|
||||
|
|
@ -495,31 +512,10 @@ class CurrentCallViewModel
|
|||
unreadMessagesCount.postValue(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun updateProximitySensor() {
|
||||
if (::currentCall.isInitialized) {
|
||||
val callState = currentCall.state
|
||||
if (LinphoneUtils.isCallIncoming(callState)) {
|
||||
proximitySensorEnabled.postValue(false)
|
||||
} else if (LinphoneUtils.isCallOutgoing(callState)) {
|
||||
val videoEnabled = currentCall.params.isVideoEnabled
|
||||
proximitySensorEnabled.postValue(!videoEnabled)
|
||||
} else {
|
||||
if (isSendingVideo.value == true || isReceivingVideo.value == true) {
|
||||
proximitySensorEnabled.postValue(false)
|
||||
} else {
|
||||
val outputAudioDevice = currentCall.outputAudioDevice ?: coreContext.core.outputAudioDevice
|
||||
if (outputAudioDevice != null && outputAudioDevice.type == AudioDevice.Type.Earpiece) {
|
||||
proximitySensorEnabled.postValue(true)
|
||||
} else {
|
||||
proximitySensorEnabled.postValue(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
proximitySensorEnabled.postValue(false)
|
||||
@WorkerThread
|
||||
override fun onAudioDevicesListUpdated(core: Core) {
|
||||
Log.i("$TAG Audio devices list has been updated")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -528,8 +524,13 @@ class CurrentCallViewModel
|
|||
operationInProgress.value = false
|
||||
proximitySensorEnabled.value = false
|
||||
videoUpdateInProgress.value = false
|
||||
microphoneRecordingVolume.value = 0f
|
||||
playbackVolume.value = 0f
|
||||
|
||||
refreshKeyguardLockedStatus()
|
||||
|
||||
coreContext.postOnCoreThread { core ->
|
||||
hideSipAddresses.postValue(corePreferences.hideSipAddresses)
|
||||
coreContext.contactsManager.addListener(contactsListener)
|
||||
|
||||
core.addListener(coreListener)
|
||||
|
|
@ -566,6 +567,8 @@ class CurrentCallViewModel
|
|||
},
|
||||
{ // OnCallClicked
|
||||
},
|
||||
{ // OnBlindTransferClicked
|
||||
},
|
||||
{ // OnClearInput
|
||||
}
|
||||
)
|
||||
|
|
@ -589,6 +592,14 @@ class CurrentCallViewModel
|
|||
}
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun refreshKeyguardLockedStatus() {
|
||||
val keyguardManager = coreContext.context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
|
||||
val secure = keyguardManager.isKeyguardLocked
|
||||
isScreenLocked.value = secure
|
||||
Log.i("$TAG Device is [${if (secure) "locked" else "unlocked"}]")
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun answer() {
|
||||
coreContext.postOnCoreThread { core ->
|
||||
|
|
@ -600,6 +611,7 @@ class CurrentCallViewModel
|
|||
coreContext.answerCall(call)
|
||||
} else {
|
||||
Log.e("$TAG No call found in incoming state, can't answer any!")
|
||||
finishActivityEvent.postValue(Event(true))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -611,6 +623,9 @@ class CurrentCallViewModel
|
|||
Log.i("$TAG Terminating call [${currentCall.remoteAddress.asStringUriOnly()}]")
|
||||
terminatedByUser = true
|
||||
coreContext.terminateCall(currentCall)
|
||||
} else {
|
||||
Log.e("$TAG No call to decline!")
|
||||
finishActivityEvent.postValue(Event(true))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -701,6 +716,10 @@ class CurrentCallViewModel
|
|||
@UiThread
|
||||
fun changeAudioOutputDevice() {
|
||||
val routeAudioToSpeaker = isSpeakerEnabled.value != true
|
||||
if (!::currentCall.isInitialized) {
|
||||
Log.w("$TAG Current call not initialized yet, do not attempt to change output audio device")
|
||||
return
|
||||
}
|
||||
|
||||
coreContext.postOnCoreThread { core ->
|
||||
var earpieceFound = false
|
||||
|
|
@ -713,36 +732,17 @@ class CurrentCallViewModel
|
|||
for (device in audioDevices) {
|
||||
// Only list output audio devices
|
||||
if (!device.hasCapability(AudioDevice.Capabilities.CapabilityPlay)) continue
|
||||
|
||||
val name = when (device.type) {
|
||||
when (device.type) {
|
||||
AudioDevice.Type.Earpiece -> {
|
||||
earpieceFound = true
|
||||
AppUtils.getString(R.string.call_audio_device_type_earpiece)
|
||||
}
|
||||
AudioDevice.Type.Speaker -> {
|
||||
speakerFound = true
|
||||
AppUtils.getString(R.string.call_audio_device_type_speaker)
|
||||
}
|
||||
AudioDevice.Type.Headset -> {
|
||||
AppUtils.getString(R.string.call_audio_device_type_headset)
|
||||
}
|
||||
AudioDevice.Type.Headphones -> {
|
||||
AppUtils.getString(R.string.call_audio_device_type_headphones)
|
||||
}
|
||||
AudioDevice.Type.Bluetooth -> {
|
||||
AppUtils.getFormattedString(
|
||||
R.string.call_audio_device_type_bluetooth,
|
||||
device.deviceName
|
||||
)
|
||||
}
|
||||
AudioDevice.Type.HearingAid -> {
|
||||
AppUtils.getFormattedString(
|
||||
R.string.call_audio_device_type_hearing_aid,
|
||||
device.deviceName
|
||||
)
|
||||
}
|
||||
else -> device.deviceName
|
||||
else -> {}
|
||||
}
|
||||
|
||||
val name = LinphoneUtils.getAudioDeviceName(device)
|
||||
val isCurrentlyInUse = device.type == currentDevice?.type && device.deviceName == currentDevice.deviceName
|
||||
val model = AudioDeviceModel(device, name, device.type, isCurrentlyInUse, true) {
|
||||
// onSelected
|
||||
|
|
@ -753,12 +753,18 @@ class CurrentCallViewModel
|
|||
AudioDevice.Type.Headset, AudioDevice.Type.Headphones -> AudioUtils.routeAudioToHeadset(
|
||||
currentCall
|
||||
)
|
||||
AudioDevice.Type.Bluetooth, AudioDevice.Type.HearingAid -> AudioUtils.routeAudioToBluetooth(
|
||||
AudioDevice.Type.Bluetooth -> AudioUtils.routeAudioToBluetooth(
|
||||
currentCall
|
||||
)
|
||||
AudioDevice.Type.HearingAid -> AudioUtils.routeAudioToHearingAid(
|
||||
currentCall
|
||||
)
|
||||
AudioDevice.Type.Speaker -> AudioUtils.routeAudioToSpeaker(
|
||||
currentCall
|
||||
)
|
||||
AudioDevice.Type.Hdmi -> AudioUtils.routeAudioToHdmi(
|
||||
currentCall
|
||||
)
|
||||
else -> AudioUtils.routeAudioToEarpiece(currentCall)
|
||||
}
|
||||
}
|
||||
|
|
@ -775,12 +781,10 @@ class CurrentCallViewModel
|
|||
Log.i(
|
||||
"$TAG Found less than two devices, simply switching between earpiece & speaker"
|
||||
)
|
||||
if (::currentCall.isInitialized) {
|
||||
if (routeAudioToSpeaker) {
|
||||
AudioUtils.routeAudioToSpeaker(currentCall)
|
||||
} else {
|
||||
AudioUtils.routeAudioToEarpiece(currentCall)
|
||||
}
|
||||
if (routeAudioToSpeaker) {
|
||||
AudioUtils.routeAudioToSpeaker(currentCall)
|
||||
} else {
|
||||
AudioUtils.routeAudioToEarpiece(currentCall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -851,15 +855,16 @@ class CurrentCallViewModel
|
|||
fun toggleRecording() {
|
||||
coreContext.postOnCoreThread {
|
||||
if (::currentCall.isInitialized) {
|
||||
if (currentCall.params.isRecording) {
|
||||
val recording = if (currentCall.params.isRecording) {
|
||||
Log.i("$TAG Stopping call recording")
|
||||
currentCall.stopRecording()
|
||||
false
|
||||
} else {
|
||||
Log.i("$TAG Starting call recording")
|
||||
currentCall.startRecording()
|
||||
true
|
||||
}
|
||||
|
||||
val recording = currentCall.params.isRecording
|
||||
isRecording.postValue(recording)
|
||||
if (recording) {
|
||||
showRecordingToast()
|
||||
|
|
@ -924,12 +929,10 @@ class CurrentCallViewModel
|
|||
fun createConversation() {
|
||||
if (::currentCall.isInitialized) {
|
||||
coreContext.postOnCoreThread {
|
||||
val existingConversation = lookupCurrentCallConversation(currentCall)
|
||||
val existingConversation = currentCallConversation ?: lookupCurrentCallConversation(currentCall)
|
||||
if (existingConversation != null) {
|
||||
Log.i(
|
||||
"$TAG Found existing conversation [${
|
||||
LinphoneUtils.getConversationId(existingConversation)
|
||||
}], going to it"
|
||||
"$TAG Found existing conversation [${LinphoneUtils.getConversationId(existingConversation)}], going to it"
|
||||
)
|
||||
goToConversationEvent.postValue(Event(LinphoneUtils.getConversationId(existingConversation)))
|
||||
} else {
|
||||
|
|
@ -943,6 +946,17 @@ class CurrentCallViewModel
|
|||
@WorkerThread
|
||||
fun attendedTransferCallTo(to: Call) {
|
||||
if (::currentCall.isInitialized) {
|
||||
val toCallState = to.state
|
||||
if (LinphoneUtils.isCallEnding(toCallState, considerReleasedAsEnding = true)) {
|
||||
Log.e("$TAG Do not attempt attended transfer to call in state [$toCallState]")
|
||||
return
|
||||
}
|
||||
val currentCallState = currentCall.state
|
||||
if (LinphoneUtils.isCallEnding(currentCallState, considerReleasedAsEnding = true)) {
|
||||
Log.e("$TAG Do not attempt attended transfer of call in state [$currentCallState]")
|
||||
return
|
||||
}
|
||||
|
||||
Log.i(
|
||||
"$TAG Doing an attended transfer between currently displayed call [${currentCall.remoteAddress.asStringUriOnly()}] and paused call [${to.remoteAddress.asStringUriOnly()}]"
|
||||
)
|
||||
|
|
@ -957,6 +971,12 @@ class CurrentCallViewModel
|
|||
@WorkerThread
|
||||
fun blindTransferCallTo(to: Address) {
|
||||
if (::currentCall.isInitialized) {
|
||||
val callState = currentCall.state
|
||||
if (LinphoneUtils.isCallEnding(callState, considerReleasedAsEnding = true)) {
|
||||
Log.e("$TAG Do not attempt blind transfer of call in state [$callState]")
|
||||
return
|
||||
}
|
||||
|
||||
Log.i(
|
||||
"$TAG Call [${currentCall.remoteAddress.asStringUriOnly()}] is being blindly transferred to [${to.asStringUriOnly()}]"
|
||||
)
|
||||
|
|
@ -1058,9 +1078,14 @@ class CurrentCallViewModel
|
|||
callMediaEncryptionModel.update(call)
|
||||
call.addListener(callListener)
|
||||
|
||||
if (call.currentParams.mediaEncryption == MediaEncryption.None) {
|
||||
waitingForEncryptionInfo.postValue(true)
|
||||
isMediaEncrypted.postValue(false)
|
||||
val state = call.state
|
||||
if (LinphoneUtils.isCallOutgoing(state) || LinphoneUtils.isCallIncoming(state)) {
|
||||
if (call.currentParams.mediaEncryption == MediaEncryption.None) {
|
||||
waitingForEncryptionInfo.postValue(true)
|
||||
isMediaEncrypted.postValue(false)
|
||||
} else {
|
||||
updateEncryption()
|
||||
}
|
||||
} else {
|
||||
updateEncryption()
|
||||
}
|
||||
|
|
@ -1079,7 +1104,19 @@ class CurrentCallViewModel
|
|||
if (call.dir == Call.Dir.Incoming) {
|
||||
val isVideo = call.remoteParams?.isVideoEnabled == true && call.remoteParams?.videoDirection != MediaDirection.Inactive
|
||||
if (call.core.accountList.size > 1) {
|
||||
val displayName = LinphoneUtils.getDisplayName(call.toAddress)
|
||||
val localAddress = call.callLog.toAddress
|
||||
Log.i("$TAG Local address for incoming call is [${localAddress.asStringUriOnly()}]")
|
||||
val localAccount = coreContext.core.accountList.find {
|
||||
it.params.identityAddress?.weakEqual(localAddress) == true
|
||||
}
|
||||
val displayName = if (localAccount != null) {
|
||||
LinphoneUtils.getDisplayName(localAccount.params.identityAddress)
|
||||
} else {
|
||||
Log.w("$TAG Matching local account was not found, using TO address display name or username")
|
||||
LinphoneUtils.getDisplayName(localAddress)
|
||||
}
|
||||
Log.i("$TAG Showing account being called as [$displayName]")
|
||||
|
||||
if (isVideo) {
|
||||
incomingCallTitle.postValue(
|
||||
AppUtils.getFormattedString(
|
||||
|
|
@ -1113,7 +1150,7 @@ class CurrentCallViewModel
|
|||
)
|
||||
} else {
|
||||
isVideoEnabled.postValue(call.currentParams.isVideoEnabled)
|
||||
updateVideoDirection(call.currentParams.videoDirection)
|
||||
updateVideoDirection(call.currentParams.videoDirection, skipIfNotStreamsRunning = true)
|
||||
}
|
||||
|
||||
if (ActivityCompat.checkSelfPermission(
|
||||
|
|
@ -1137,7 +1174,6 @@ class CurrentCallViewModel
|
|||
updateOutputAudioDevice(audioDevice)
|
||||
|
||||
isOutgoing.postValue(call.dir == Call.Dir.Outgoing)
|
||||
val state = call.state
|
||||
isOutgoingRinging.postValue(state == Call.State.OutgoingRinging)
|
||||
isIncomingEarlyMedia.postValue(state == Call.State.IncomingEarlyMedia)
|
||||
isOutgoingEarlyMedia.postValue(state == Call.State.OutgoingEarlyMedia)
|
||||
|
|
@ -1157,7 +1193,8 @@ class CurrentCallViewModel
|
|||
val model = if (conferenceInfo != null) {
|
||||
coreContext.contactsManager.getContactAvatarModelForConferenceInfo(conferenceInfo)
|
||||
} else {
|
||||
// Do not use contact avatar model from ContactsManager
|
||||
// Do not use contact avatar model from ContactsManager to be able to show
|
||||
// ZRTP verification status with the device that will answer the call
|
||||
val friend = coreContext.contactsManager.findContactByAddress(address)
|
||||
if (friend != null) {
|
||||
ContactAvatarModel(friend, address)
|
||||
|
|
@ -1165,6 +1202,12 @@ class CurrentCallViewModel
|
|||
val fakeFriend = coreContext.core.createFriend()
|
||||
fakeFriend.name = LinphoneUtils.getDisplayName(address)
|
||||
fakeFriend.address = address
|
||||
val localAccount = coreContext.core.accountList.find {
|
||||
it.params.identityAddress?.weakEqual(address) == true
|
||||
}
|
||||
if (localAccount != null) {
|
||||
fakeFriend.photo = localAccount.params.pictureUri
|
||||
}
|
||||
ContactAvatarModel(fakeFriend, address)
|
||||
}
|
||||
}
|
||||
|
|
@ -1197,6 +1240,19 @@ class CurrentCallViewModel
|
|||
} else {
|
||||
Log.i("$TAG Failed to find an existing 1-1 conversation for current call")
|
||||
}
|
||||
|
||||
if (corePreferences.showMicrophoneAndSpeakerVuMeters) {
|
||||
volumeVuMeterTickerFlow().onEach {
|
||||
coreContext.postOnCoreThread {
|
||||
val call = currentCall
|
||||
val state = call.state
|
||||
if (state == Call.State.End || state == Call.State.Released) return@postOnCoreThread
|
||||
|
||||
microphoneRecordingVolume.postValue(computeVuMeterValue(call.recordVolume))
|
||||
playbackVolume.postValue(computeVuMeterValue(call.playVolume))
|
||||
}
|
||||
}.launchIn(viewModelScope)
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
|
|
@ -1213,7 +1269,9 @@ class CurrentCallViewModel
|
|||
isHeadsetEnabled.postValue(
|
||||
audioDevice?.type == AudioDevice.Type.Headphones || audioDevice?.type == AudioDevice.Type.Headset
|
||||
)
|
||||
isHearingAidEnabled.postValue(audioDevice?.type == AudioDevice.Type.HearingAid)
|
||||
isBluetoothEnabled.postValue(audioDevice?.type == AudioDevice.Type.Bluetooth)
|
||||
isHdmiEnabled.postValue(audioDevice?.type == AudioDevice.Type.Hdmi)
|
||||
|
||||
updateProximitySensor()
|
||||
}
|
||||
|
|
@ -1238,14 +1296,15 @@ class CurrentCallViewModel
|
|||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun updateVideoDirection(direction: MediaDirection) {
|
||||
private fun updateVideoDirection(direction: MediaDirection, skipIfNotStreamsRunning: Boolean = false) {
|
||||
val state = currentCall.state
|
||||
if (state != Call.State.StreamsRunning) {
|
||||
if (skipIfNotStreamsRunning && state != Call.State.StreamsRunning) {
|
||||
return
|
||||
}
|
||||
|
||||
val isSending = direction == MediaDirection.SendRecv || direction == MediaDirection.SendOnly
|
||||
val isReceiving = direction == MediaDirection.SendRecv || direction == MediaDirection.RecvOnly
|
||||
val isConnected = state == Call.State.Connected || state == Call.State.StreamsRunning
|
||||
val isSending = (state == Call.State.OutgoingEarlyMedia || isConnected) && (direction == MediaDirection.SendRecv || direction == MediaDirection.SendOnly)
|
||||
val isReceiving = (state == Call.State.IncomingEarlyMedia || isConnected) && (direction == MediaDirection.SendRecv || direction == MediaDirection.RecvOnly)
|
||||
|
||||
val wasSending = isSendingVideo.value == true
|
||||
val wasReceiving = isReceivingVideo.value == true
|
||||
|
|
@ -1324,16 +1383,13 @@ class CurrentCallViewModel
|
|||
val localAddress = call.callLog.localAddress
|
||||
val remoteAddress = call.remoteAddress
|
||||
|
||||
val params: ConferenceParams? = null
|
||||
val existingConversation = if (call.conference != null) {
|
||||
call.core.searchChatRoom(
|
||||
params,
|
||||
localAddress,
|
||||
remoteAddress,
|
||||
arrayOf()
|
||||
)
|
||||
Log.i("$TAG We're in [${remoteAddress.asStringUriOnly()}] conference, using it as chat room if possible")
|
||||
call.conference?.chatRoom
|
||||
} else {
|
||||
val params = getChatRoomParams(call)
|
||||
val participants = arrayOf(remoteAddress)
|
||||
Log.i("$TAG Looking for conversation with local address [${localAddress.asStringUriOnly()}] and participant [${remoteAddress.asStringUriOnly()}]")
|
||||
call.core.searchChatRoom(
|
||||
params,
|
||||
localAddress,
|
||||
|
|
@ -1380,9 +1436,7 @@ class CurrentCallViewModel
|
|||
"$TAG Failed to create 1-1 conversation with [${remoteAddress.asStringUriOnly()}]!"
|
||||
)
|
||||
operationInProgress.postValue(false)
|
||||
chatRoomCreationErrorEvent.postValue(
|
||||
Event(R.string.conversation_creation_error_toast)
|
||||
)
|
||||
showRedToast(R.string.conversation_failed_to_create_toast, R.drawable.warning_circle)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1468,4 +1522,43 @@ class CurrentCallViewModel
|
|||
private fun showRecordingToast() {
|
||||
showGreenToast(R.string.call_is_being_recorded, R.drawable.record_fill)
|
||||
}
|
||||
|
||||
private fun volumeVuMeterTickerFlow() = flow {
|
||||
while (::currentCall.isInitialized) {
|
||||
emit(Unit)
|
||||
delay(50)
|
||||
}
|
||||
}
|
||||
|
||||
private fun computeVuMeterValue(volume: Float): Float {
|
||||
if (volume < VU_METER_MIN) return 0f
|
||||
if (volume > VU_METER_MAX) return 1f
|
||||
return (volume - VU_METER_MIN) / (VU_METER_MAX - VU_METER_MIN)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun updateProximitySensor() {
|
||||
if (::currentCall.isInitialized) {
|
||||
val callState = currentCall.state
|
||||
if (LinphoneUtils.isCallIncoming(callState)) {
|
||||
proximitySensorEnabled.postValue(false)
|
||||
} else if (LinphoneUtils.isCallOutgoing(callState)) {
|
||||
val videoEnabled = currentCall.params.isVideoEnabled
|
||||
proximitySensorEnabled.postValue(!videoEnabled)
|
||||
} else {
|
||||
if (isSendingVideo.value == true || isReceivingVideo.value == true) {
|
||||
proximitySensorEnabled.postValue(false)
|
||||
} else {
|
||||
val outputAudioDevice = currentCall.outputAudioDevice ?: coreContext.core.outputAudioDevice
|
||||
if (outputAudioDevice != null && outputAudioDevice.type == AudioDevice.Type.Earpiece) {
|
||||
proximitySensorEnabled.postValue(true)
|
||||
} else {
|
||||
proximitySensorEnabled.postValue(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
proximitySensorEnabled.postValue(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package org.linphone.ui.fileviewer
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.util.DisplayMetrics
|
||||
|
|
@ -127,23 +128,13 @@ class FileViewerActivity : GenericActivity() {
|
|||
|
||||
viewModel.exportPlainTextFileEvent.observe(this) {
|
||||
it.consume { name ->
|
||||
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_TITLE, name)
|
||||
}
|
||||
startActivityForResult(intent, EXPORT_FILE_AS_DOCUMENT)
|
||||
exportFile(name, "text/plain")
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.exportPdfEvent.observe(this) {
|
||||
it.consume { name ->
|
||||
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = "application/pdf"
|
||||
putExtra(Intent.EXTRA_TITLE, name)
|
||||
}
|
||||
startActivityForResult(intent, EXPORT_FILE_AS_DOCUMENT)
|
||||
exportFile(name, "application/pdf")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -206,10 +197,27 @@ class FileViewerActivity : GenericActivity() {
|
|||
}
|
||||
|
||||
val shareIntent = Intent.createChooser(sendIntent, null)
|
||||
startActivity(shareIntent)
|
||||
try {
|
||||
startActivity(shareIntent)
|
||||
} catch (anfe: ActivityNotFoundException) {
|
||||
Log.e("$TAG Failed to start intent chooser: $anfe")
|
||||
}
|
||||
} else {
|
||||
Log.e("$TAG Failed to copy file [$filePath] to share!")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun exportFile(name: String, mimeType: String) {
|
||||
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = mimeType
|
||||
putExtra(Intent.EXTRA_TITLE, name)
|
||||
}
|
||||
try {
|
||||
startActivityForResult(intent, EXPORT_FILE_AS_DOCUMENT)
|
||||
} catch (exception: ActivityNotFoundException) {
|
||||
Log.e("$TAG No activity found to handle intent ACTION_CREATE_DOCUMENT: $exception")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package org.linphone.ui.fileviewer
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
|
|
@ -269,7 +270,11 @@ class MediaViewerActivity : GenericActivity() {
|
|||
}
|
||||
|
||||
val shareIntent = Intent.createChooser(sendIntent, null)
|
||||
startActivity(shareIntent)
|
||||
try {
|
||||
startActivity(shareIntent)
|
||||
} catch (anfe: ActivityNotFoundException) {
|
||||
Log.e("$TAG Failed to start intent chooser: $anfe")
|
||||
}
|
||||
} else {
|
||||
Log.e(
|
||||
"$TAG Failed to copy file [$filePath] to share!"
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import android.view.Surface
|
|||
import android.view.TextureView.SurfaceTextureListener
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.SeekBar
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import org.linphone.core.tools.Log
|
||||
|
|
@ -45,6 +46,21 @@ class MediaViewerFragment : GenericMainFragment() {
|
|||
|
||||
private lateinit var viewModel: MediaViewModel
|
||||
|
||||
private val seekBarListener = object : SeekBar.OnSeekBarChangeListener {
|
||||
override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
|
||||
|
||||
}
|
||||
|
||||
override fun onStartTrackingTouch(seekBar: SeekBar) {
|
||||
viewModel.pause()
|
||||
}
|
||||
|
||||
override fun onStopTrackingTouch(seekBar: SeekBar) {
|
||||
val newPosition = seekBar.progress
|
||||
viewModel.seekTo(newPosition)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
|
|
@ -87,6 +103,8 @@ class MediaViewerFragment : GenericMainFragment() {
|
|||
sharedViewModel.mediaViewerFullScreenMode.value = fullScreenMode
|
||||
}
|
||||
|
||||
binding.setSeekBarListener(seekBarListener)
|
||||
|
||||
viewModel.videoSizeChangedEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { pair ->
|
||||
val width = pair.first
|
||||
|
|
@ -109,7 +127,7 @@ class MediaViewerFragment : GenericMainFragment() {
|
|||
val textureView = binding.videoPlayer
|
||||
if (textureView.isAvailable) {
|
||||
Log.i("$TAG Surface created, setting display in mediaPlayer")
|
||||
viewModel.mediaPlayer.setSurface((Surface(textureView.surfaceTexture)))
|
||||
viewModel.setMediaPlayerSurface((Surface(textureView.surfaceTexture)))
|
||||
} else {
|
||||
Log.i("$TAG Surface not available yet, setting listener")
|
||||
textureView.surfaceTextureListener = object : SurfaceTextureListener {
|
||||
|
|
@ -119,7 +137,7 @@ class MediaViewerFragment : GenericMainFragment() {
|
|||
p2: Int
|
||||
) {
|
||||
Log.i("$TAG Surface available, setting display in mediaPlayer")
|
||||
viewModel.mediaPlayer.setSurface(Surface(surfaceTexture))
|
||||
viewModel.setMediaPlayerSurface(Surface(surfaceTexture))
|
||||
}
|
||||
|
||||
override fun onSurfaceTextureSizeChanged(
|
||||
|
|
|
|||
|
|
@ -130,8 +130,7 @@ class FileViewModel
|
|||
val extension = FileUtils.getExtensionFromFileName(file)
|
||||
val mime = FileUtils.getMimeTypeFromExtension(extension)
|
||||
mimeType.postValue(mime)
|
||||
val mimeType = FileUtils.getMimeType(mime)
|
||||
when (mimeType) {
|
||||
when (val mimeType = FileUtils.getMimeType(mime)) {
|
||||
FileUtils.MimeType.Pdf -> {
|
||||
Log.d("$TAG File [$file] seems to be a PDF")
|
||||
loadPdf()
|
||||
|
|
@ -278,13 +277,26 @@ class FileViewModel
|
|||
File(filePath),
|
||||
ParcelFileDescriptor.MODE_READ_ONLY
|
||||
)
|
||||
pdfRenderer = PdfRenderer(input)
|
||||
val count = pdfRenderer.pageCount
|
||||
Log.i("$TAG $count pages in file $filePath")
|
||||
pdfPages.postValue(count.toString())
|
||||
pdfCurrentPage.postValue("1")
|
||||
pdfRendererReadyEvent.postValue(Event(true))
|
||||
fileReadyEvent.postValue(Event(true))
|
||||
try {
|
||||
pdfRenderer = PdfRenderer(input)
|
||||
val count = pdfRenderer.pageCount
|
||||
Log.i("$TAG $count pages in file $filePath")
|
||||
pdfPages.postValue(count.toString())
|
||||
pdfCurrentPage.postValue("1")
|
||||
pdfRendererReadyEvent.postValue(Event(true))
|
||||
fileReadyEvent.postValue(Event(true))
|
||||
} catch (se: SecurityException) {
|
||||
// TODO FIXME: add support for password protected PDFs
|
||||
Log.e("$TAG Can't open PDF, probably protected by a password: $se")
|
||||
pdfCurrentPage.postValue("0")
|
||||
pdfPages.postValue("0")
|
||||
showRedToast(R.string.conversation_pdf_password_protected_file_cant_be_opened_error_toast, R.drawable.warning_circle)
|
||||
} catch (e: Exception) {
|
||||
Log.e("$TAG Can't open PDF, it may be corrupted: $e")
|
||||
pdfCurrentPage.postValue("0")
|
||||
pdfPages.postValue("0")
|
||||
showRedToast(R.string.conversation_pdf_file_error_toast, R.drawable.warning_circle)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ package org.linphone.ui.fileviewer.viewmodel
|
|||
|
||||
import android.media.AudioAttributes
|
||||
import android.media.MediaPlayer
|
||||
import android.view.Surface
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
|
|
@ -160,6 +161,14 @@ class MediaViewModel
|
|||
}
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun seekTo(position: Int) {
|
||||
if (::mediaPlayer.isInitialized) {
|
||||
mediaPlayer.seekTo(position)
|
||||
play()
|
||||
}
|
||||
}
|
||||
|
||||
@UiThread
|
||||
private fun initMediaPlayer() {
|
||||
isMediaPlaying.value = false
|
||||
|
|
@ -225,4 +234,11 @@ class MediaViewModel
|
|||
updatePositionJob?.cancel()
|
||||
updatePositionJob = null
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun setMediaPlayerSurface(surface: Surface) {
|
||||
if (::mediaPlayer.isInitialized) {
|
||||
mediaPlayer.setSurface(surface)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,7 +66,6 @@ import org.linphone.databinding.MainActivityBinding
|
|||
import org.linphone.ui.GenericActivity
|
||||
import org.linphone.ui.assistant.AssistantActivity
|
||||
import org.linphone.ui.main.chat.fragment.ConversationsListFragmentDirections
|
||||
import org.linphone.ui.main.help.fragment.DebugFragmentDirections
|
||||
import org.linphone.utils.PasswordDialogModel
|
||||
import org.linphone.ui.main.sso.fragment.SingleSignOnFragmentDirections
|
||||
import org.linphone.ui.main.viewmodel.MainViewModel
|
||||
|
|
@ -77,6 +76,7 @@ import org.linphone.utils.DialogUtils
|
|||
import org.linphone.utils.Event
|
||||
import org.linphone.utils.FileUtils
|
||||
import org.linphone.utils.LinphoneUtils
|
||||
import androidx.core.content.edit
|
||||
|
||||
@UiThread
|
||||
class MainActivity : GenericActivity() {
|
||||
|
|
@ -120,12 +120,23 @@ class MainActivity : GenericActivity() {
|
|||
) { isGranted ->
|
||||
if (isGranted) {
|
||||
Log.i("$TAG POST_NOTIFICATIONS permission has been granted")
|
||||
viewModel.updatePostNotificationsPermission()
|
||||
viewModel.updateMissingPermissionAlert()
|
||||
} else {
|
||||
Log.w("$TAG POST_NOTIFICATIONS permission has been denied!")
|
||||
}
|
||||
}
|
||||
|
||||
private val fullScreenIntentPermissionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { isGranted ->
|
||||
if (isGranted) {
|
||||
Log.i("$TAG USE_FULL_SCREEN_INTENT permission has been granted")
|
||||
viewModel.updateMissingPermissionAlert()
|
||||
} else {
|
||||
Log.w("$TAG USE_FULL_SCREEN_INTENT permission has been denied!")
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("InlinedApi")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
// Must be done before the setContentView
|
||||
|
|
@ -143,7 +154,8 @@ class MainActivity : GenericActivity() {
|
|||
binding.lifecycleOwner = this
|
||||
setUpToastsArea(binding.toastsArea)
|
||||
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.inCallTopBar.root) { v, windowInsets ->
|
||||
// Will give the device's status bar background color
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.notificationsArea) { v, windowInsets ->
|
||||
val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
v.updatePadding(0, insets.top, 0, 0)
|
||||
windowInsets
|
||||
|
|
@ -204,17 +216,14 @@ class MainActivity : GenericActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
viewModel.defaultAccountRegistrationErrorEvent.observe(this) {
|
||||
it.consume { error ->
|
||||
val tag = "DEFAULT_ACCOUNT_REGISTRATION_ERROR"
|
||||
if (error) {
|
||||
// First remove any already existing connection error toast
|
||||
removePersistentRedToast(tag)
|
||||
|
||||
val message = getString(R.string.default_account_connection_state_error_toast)
|
||||
showPersistentRedToast(message, R.drawable.warning_circle, tag)
|
||||
viewModel.askFullScreenIntentPermissionEvent.observe(this) {
|
||||
it.consume {
|
||||
if (ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.USE_FULL_SCREEN_INTENT)) {
|
||||
Log.w("$TAG Asking for USE_FULL_SCREEN_INTENT permission")
|
||||
fullScreenIntentPermissionLauncher.launch(Manifest.permission.USE_FULL_SCREEN_INTENT)
|
||||
} else {
|
||||
removePersistentRedToast(tag)
|
||||
Log.i("$TAG Permission request for USE_FULL_SCREEN_INTENT will be automatically denied, go to manage app full screen intent android settings instead")
|
||||
Compatibility.requestFullScreenIntentPermission(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -240,6 +249,29 @@ class MainActivity : GenericActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
viewModel.clearFilesOrTextPendingSharingEvent.observe(this) {
|
||||
it.consume {
|
||||
sharedViewModel.filesToShareFromIntent.value = arrayListOf()
|
||||
sharedViewModel.textToShareFromIntent.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
sharedViewModel.filesToShareFromIntent.observe(this) { list ->
|
||||
if (list.isNotEmpty()) {
|
||||
viewModel.addFilesPendingSharing(list)
|
||||
} else {
|
||||
viewModel.filesOrTextPendingSharingListCleared()
|
||||
}
|
||||
}
|
||||
|
||||
sharedViewModel.textToShareFromIntent.observe(this) { text ->
|
||||
if (!text.isEmpty()) {
|
||||
viewModel.addTextPendingSharing()
|
||||
} else {
|
||||
viewModel.filesOrTextPendingSharingListCleared()
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for latest visited fragment to be displayed before hiding the splashscreen
|
||||
binding.root.viewTreeObserver.addOnPreDrawListener(object : ViewTreeObserver.OnPreDrawListener {
|
||||
override fun onPreDraw(): Boolean {
|
||||
|
|
@ -277,7 +309,9 @@ class MainActivity : GenericActivity() {
|
|||
coreContext.digestAuthenticationRequestedEvent.observe(this) {
|
||||
it.consume { identity ->
|
||||
try {
|
||||
showAuthenticationRequestedDialog(identity)
|
||||
if (coreContext.digestAuthInfoPendingPasswordUpdate != null) {
|
||||
showAuthenticationRequestedDialog(identity)
|
||||
}
|
||||
} catch (e: WindowManager.BadTokenException) {
|
||||
Log.e("$TAG Failed to show authentication dialog: $e")
|
||||
}
|
||||
|
|
@ -382,9 +416,8 @@ class MainActivity : GenericActivity() {
|
|||
HISTORY_FRAGMENT_ID
|
||||
}
|
||||
}
|
||||
with(getPreferences(MODE_PRIVATE).edit()) {
|
||||
getPreferences(MODE_PRIVATE).edit {
|
||||
putInt(DEFAULT_FRAGMENT_KEY, defaultFragmentId)
|
||||
apply()
|
||||
}
|
||||
Log.i("$TAG Stored [$defaultFragmentId] as default page")
|
||||
|
||||
|
|
@ -395,9 +428,8 @@ class MainActivity : GenericActivity() {
|
|||
super.onResume()
|
||||
|
||||
viewModel.enableAccountMonitoring(true)
|
||||
viewModel.checkForNewAccount()
|
||||
viewModel.updateNetworkReachability()
|
||||
viewModel.updatePostNotificationsPermission()
|
||||
viewModel.updateMissingPermissionAlert()
|
||||
viewModel.updateAccountsAndNetworkReachability()
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
|
|
@ -688,8 +720,13 @@ class MainActivity : GenericActivity() {
|
|||
sharedViewModel.showConversationEvent.value = Event(conversationId)
|
||||
}
|
||||
|
||||
val action = DebugFragmentDirections.actionDebugFragmentToConversationsListFragment()
|
||||
findNavController().navigate(action)
|
||||
val action = ConversationsListFragmentDirections.actionGlobalConversationsListFragment()
|
||||
val options = NavOptions.Builder()
|
||||
options.apply {
|
||||
setPopUpTo(R.id.helpFragment, true)
|
||||
setLaunchSingleTop(true)
|
||||
}
|
||||
findNavController().navigate(action, options.build())
|
||||
} else {
|
||||
val conversationId = parseShortcutIfAny(intent)
|
||||
if (conversationId != null) {
|
||||
|
|
@ -753,11 +790,11 @@ class MainActivity : GenericActivity() {
|
|||
}
|
||||
|
||||
private fun handleConfigIntent(uri: String) {
|
||||
val remoteConfigUri = uri.substring("linphone-config:".length)
|
||||
val url = when {
|
||||
remoteConfigUri.startsWith("http://") || remoteConfigUri.startsWith("https://") -> remoteConfigUri
|
||||
remoteConfigUri.startsWith("file://") -> remoteConfigUri
|
||||
else -> "https://$remoteConfigUri"
|
||||
Log.i("$TAG Trying to parse config intent [$uri] as remote provisioning URL")
|
||||
val url = LinphoneUtils.getRemoteProvisioningUrlFromUri(uri)
|
||||
if (url == null) {
|
||||
Log.e("$TAG Couldn't parse URI [$uri] into a valid remote provisioning URL, aborting")
|
||||
return
|
||||
}
|
||||
|
||||
coreContext.postOnCoreThread { core ->
|
||||
|
|
|
|||
|
|
@ -161,7 +161,7 @@ class ConversationsContactsAndSuggestionsListAdapter :
|
|||
}
|
||||
}
|
||||
|
||||
inner class ConversationViewHolder(
|
||||
class ConversationViewHolder(
|
||||
val binding: GenericAddressPickerConversationListCellBinding
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
@UiThread
|
||||
|
|
@ -198,7 +198,7 @@ class ConversationsContactsAndSuggestionsListAdapter :
|
|||
}
|
||||
}
|
||||
|
||||
inner class SuggestionViewHolder(
|
||||
class SuggestionViewHolder(
|
||||
val binding: GenericAddressPickerSuggestionListCellBinding
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
@UiThread
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2020 Belledonne Communications SARL.
|
||||
* Copyright (c) 2010-2025 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
|
|
@ -21,13 +21,12 @@ package org.linphone.ui.main.chat
|
|||
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.linphone.core.tools.Log
|
||||
|
||||
internal abstract class ConversationScrollListener(private val mLayoutManager: LinearLayoutManager) :
|
||||
internal abstract class RecyclerViewScrollListener(private val layoutManager: LinearLayoutManager, private val visibleThreshold: Int, private val scrollingTopToBottom: Boolean) :
|
||||
RecyclerView.OnScrollListener() {
|
||||
companion object {
|
||||
// The minimum amount of items to have below your current scroll position
|
||||
// before loading more.
|
||||
private const val VISIBLE_THRESHOLD = 5
|
||||
private const val TAG = "[RecyclerView Scroll Listener]"
|
||||
}
|
||||
|
||||
// The total number of items in the data set after the last load
|
||||
|
|
@ -40,9 +39,9 @@ internal abstract class ConversationScrollListener(private val mLayoutManager: L
|
|||
// We are given a few useful parameters to help us work out if we need to load some more data,
|
||||
// but first we check if we are waiting for the previous load to finish.
|
||||
override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) {
|
||||
val totalItemCount = mLayoutManager.itemCount
|
||||
val firstVisibleItemPosition: Int = mLayoutManager.findFirstVisibleItemPosition()
|
||||
val lastVisibleItemPosition: Int = mLayoutManager.findLastVisibleItemPosition()
|
||||
val totalItemCount = layoutManager.itemCount
|
||||
val firstVisibleItemPosition: Int = layoutManager.findFirstVisibleItemPosition()
|
||||
val lastVisibleItemPosition: Int = layoutManager.findLastVisibleItemPosition()
|
||||
|
||||
// If the total item count is zero and the previous isn't, assume the
|
||||
// list is invalidated and should be reset back to initial state
|
||||
|
|
@ -64,21 +63,34 @@ internal abstract class ConversationScrollListener(private val mLayoutManager: L
|
|||
val userHasScrolledUp = lastVisibleItemPosition != totalItemCount - 1
|
||||
if (userHasScrolledUp) {
|
||||
onScrolledUp()
|
||||
Log.d("$TAG Scrolled up")
|
||||
} else {
|
||||
onScrolledToEnd()
|
||||
Log.d("$TAG Scrolled to end")
|
||||
}
|
||||
|
||||
// If it isn’t currently loading, we check to see if we have breached
|
||||
// the mVisibleThreshold and need to reload more data.
|
||||
// the visibleThreshold and need to reload more data.
|
||||
// If we do need to reload some more data, we execute onLoadMore to fetch the data.
|
||||
// threshold should reflect how many total columns there are too
|
||||
if (!loading &&
|
||||
firstVisibleItemPosition < VISIBLE_THRESHOLD &&
|
||||
firstVisibleItemPosition >= 0 &&
|
||||
lastVisibleItemPosition < totalItemCount - VISIBLE_THRESHOLD
|
||||
) {
|
||||
onLoadMore(totalItemCount)
|
||||
loading = true
|
||||
if (!loading) {
|
||||
if (scrollingTopToBottom) {
|
||||
if (lastVisibleItemPosition >= totalItemCount - visibleThreshold) {
|
||||
Log.d(
|
||||
"$TAG Last visible item position [$lastVisibleItemPosition] reached [${totalItemCount - visibleThreshold}], loading more (current total items is [$totalItemCount])"
|
||||
)
|
||||
loading = true
|
||||
onLoadMore(totalItemCount)
|
||||
}
|
||||
} else {
|
||||
if (firstVisibleItemPosition < visibleThreshold) {
|
||||
Log.d(
|
||||
"$TAG First visible item position [$firstVisibleItemPosition] < visibleThreshold [$visibleThreshold], loading more (current total items is [$totalItemCount])"
|
||||
)
|
||||
loading = true
|
||||
onLoadMore(totalItemCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -36,6 +36,7 @@ import org.linphone.databinding.ChatBubbleIncomingBinding
|
|||
import org.linphone.databinding.ChatBubbleOutgoingBinding
|
||||
import org.linphone.databinding.ChatConversationEventBinding
|
||||
import org.linphone.databinding.ChatConversationE2eEncryptedFirstEventBinding
|
||||
import org.linphone.databinding.ChatConversationUnsafeFirstEventBinding
|
||||
import org.linphone.ui.main.chat.model.EventLogModel
|
||||
import org.linphone.ui.main.chat.model.EventModel
|
||||
import org.linphone.ui.main.chat.model.MessageModel
|
||||
|
|
@ -82,7 +83,11 @@ class ConversationEventAdapter :
|
|||
}
|
||||
|
||||
override fun getHeaderViewForPosition(context: Context, position: Int): View {
|
||||
val binding = ChatConversationE2eEncryptedFirstEventBinding.inflate(LayoutInflater.from(context))
|
||||
val binding = if (isConversationSecured) {
|
||||
ChatConversationE2eEncryptedFirstEventBinding.inflate(LayoutInflater.from(context))
|
||||
} else {
|
||||
ChatConversationUnsafeFirstEventBinding.inflate(LayoutInflater.from(context))
|
||||
}
|
||||
return binding.root
|
||||
}
|
||||
|
||||
|
|
@ -197,7 +202,7 @@ class ConversationEventAdapter :
|
|||
}
|
||||
}
|
||||
|
||||
inner class IncomingBubbleViewHolder(
|
||||
class IncomingBubbleViewHolder(
|
||||
val binding: ChatBubbleIncomingBinding
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
fun bind(message: MessageModel) {
|
||||
|
|
@ -212,7 +217,7 @@ class ConversationEventAdapter :
|
|||
}
|
||||
}
|
||||
|
||||
inner class OutgoingBubbleViewHolder(
|
||||
class OutgoingBubbleViewHolder(
|
||||
val binding: ChatBubbleOutgoingBinding
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
fun bind(message: MessageModel) {
|
||||
|
|
@ -227,7 +232,7 @@ class ConversationEventAdapter :
|
|||
}
|
||||
}
|
||||
|
||||
inner class EventViewHolder(
|
||||
class EventViewHolder(
|
||||
val binding: ChatConversationEventBinding
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
fun bind(event: EventModel) {
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ class ConversationParticipantsAdapter : ListAdapter<ParticipantModel, RecyclerVi
|
|||
(holder as ViewHolder).bind(getItem(position))
|
||||
}
|
||||
|
||||
inner class ViewHolder(
|
||||
class ViewHolder(
|
||||
val binding: ChatParticipantListCellBinding
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
@UiThread
|
||||
|
|
|
|||
|
|
@ -30,10 +30,11 @@ import androidx.recyclerview.widget.DiffUtil
|
|||
import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.linphone.R
|
||||
import org.linphone.databinding.ChatDocumentContentListCellBinding
|
||||
import org.linphone.databinding.ChatBubbleSingleFileContentBinding
|
||||
import org.linphone.databinding.ChatMediaContentGridCellBinding
|
||||
import org.linphone.databinding.MeetingsListDecorationBinding
|
||||
import org.linphone.ui.main.chat.model.FileModel
|
||||
import org.linphone.utils.AppUtils
|
||||
import org.linphone.utils.HeaderAdapter
|
||||
|
||||
class ConversationsFilesAdapter :
|
||||
|
|
@ -46,6 +47,9 @@ class ConversationsFilesAdapter :
|
|||
const val DOCUMENT_FILE = 2
|
||||
}
|
||||
|
||||
private val topBottomPadding = AppUtils.getDimension(R.dimen.chat_documents_list_padding_top_bottom).toInt()
|
||||
private val startEndPadding = AppUtils.getDimension(R.dimen.chat_documents_list_padding_start_end).toInt()
|
||||
|
||||
override fun displayHeaderForPosition(position: Int): Boolean {
|
||||
if (position == 0) return true
|
||||
|
||||
|
|
@ -89,15 +93,16 @@ class ConversationsFilesAdapter :
|
|||
}
|
||||
|
||||
private fun createDocumentFileViewHolder(parent: ViewGroup): RecyclerView.ViewHolder {
|
||||
val binding: ChatDocumentContentListCellBinding = DataBindingUtil.inflate(
|
||||
val binding: ChatBubbleSingleFileContentBinding = DataBindingUtil.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
R.layout.chat_document_content_list_cell,
|
||||
R.layout.chat_bubble_single_file_content,
|
||||
parent,
|
||||
false
|
||||
)
|
||||
val viewHolder = DocumentFileViewHolder(binding)
|
||||
binding.apply {
|
||||
lifecycleOwner = parent.findViewTreeLifecycleOwner()
|
||||
root.setPadding(startEndPadding, topBottomPadding, startEndPadding, topBottomPadding)
|
||||
}
|
||||
return viewHolder
|
||||
}
|
||||
|
|
@ -110,7 +115,7 @@ class ConversationsFilesAdapter :
|
|||
}
|
||||
}
|
||||
|
||||
inner class MediaFileViewHolder(
|
||||
class MediaFileViewHolder(
|
||||
val binding: ChatMediaContentGridCellBinding
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
@UiThread
|
||||
|
|
@ -122,8 +127,8 @@ class ConversationsFilesAdapter :
|
|||
}
|
||||
}
|
||||
|
||||
inner class DocumentFileViewHolder(
|
||||
val binding: ChatDocumentContentListCellBinding
|
||||
class DocumentFileViewHolder(
|
||||
val binding: ChatBubbleSingleFileContentBinding
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
@UiThread
|
||||
fun bind(fileModel: FileModel) {
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ class MessageBottomSheetAdapter : ListAdapter<MessageBottomSheetParticipantModel
|
|||
(holder as ViewHolder).bind(getItem(position))
|
||||
}
|
||||
|
||||
inner class ViewHolder(
|
||||
class ViewHolder(
|
||||
val binding: ChatMessageBottomSheetListCellBinding
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
@UiThread
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import androidx.navigation.fragment.navArgs
|
|||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.databinding.ChatDocumentsFragmentBinding
|
||||
import org.linphone.ui.main.chat.RecyclerViewScrollListener
|
||||
import org.linphone.ui.main.chat.adapter.ConversationsFilesAdapter
|
||||
import org.linphone.ui.main.chat.model.FileModel
|
||||
import org.linphone.ui.main.chat.viewmodel.ConversationDocumentsListViewModel
|
||||
|
|
@ -57,6 +58,8 @@ class ConversationDocumentsListFragment : SlidingPaneChildFragment() {
|
|||
|
||||
private val args: ConversationMediaListFragmentArgs by navArgs()
|
||||
|
||||
private lateinit var scrollListener: RecyclerViewScrollListener
|
||||
|
||||
override fun goBack(): Boolean {
|
||||
try {
|
||||
return findNavController().popBackStack()
|
||||
|
|
@ -130,6 +133,40 @@ class ConversationDocumentsListFragment : SlidingPaneChildFragment() {
|
|||
goToFileViewer(model)
|
||||
}
|
||||
}
|
||||
|
||||
scrollListener = object : RecyclerViewScrollListener(layoutManager, 4, true) {
|
||||
@UiThread
|
||||
override fun onLoadMore(totalItemsCount: Int) {
|
||||
Log.i("$TAG Asking for more data to display, currently displayed items count is [$totalItemsCount]")
|
||||
viewModel.loadMoreData(totalItemsCount)
|
||||
}
|
||||
|
||||
@UiThread
|
||||
override fun onScrolledUp() {
|
||||
|
||||
}
|
||||
|
||||
@UiThread
|
||||
override fun onScrolledToEnd() {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
if (::scrollListener.isInitialized) {
|
||||
binding.documentsList.addOnScrollListener(scrollListener)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
|
||||
if (::scrollListener.isInitialized) {
|
||||
binding.documentsList.removeOnScrollListener(scrollListener)
|
||||
}
|
||||
}
|
||||
|
||||
private fun goToFileViewer(fileModel: FileModel) {
|
||||
|
|
@ -181,12 +218,6 @@ class ConversationDocumentsListFragment : SlidingPaneChildFragment() {
|
|||
}
|
||||
}
|
||||
|
||||
model.cancelEvent.observe(viewLifecycleOwner) {
|
||||
it.consume {
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
model.confirmEvent.observe(viewLifecycleOwner) {
|
||||
it.consume {
|
||||
sharedViewModel.displayFileEvent.value = Event(bundle)
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ class ConversationEphemeralLifetimeFragment : SlidingPaneChildFragment() {
|
|||
}
|
||||
|
||||
override fun onPause() {
|
||||
sharedViewModel.newChatMessageEphemeralLifetimeToSet.value = Event(
|
||||
sharedViewModel.newChatMessageEphemeralLifetimeToSetEvent.value = Event(
|
||||
viewModel.currentlySelectedValue.value ?: 0L
|
||||
)
|
||||
super.onPause()
|
||||
|
|
|
|||
|
|
@ -149,7 +149,7 @@ class ConversationForwardMessageFragment : SlidingPaneChildFragment() {
|
|||
}
|
||||
}
|
||||
|
||||
viewModel.hideNumberOrAddressPickerDialogEvent.observe(viewLifecycleOwner) {
|
||||
viewModel.dismissNumberOrAddressPickerDialogEvent.observe(viewLifecycleOwner) {
|
||||
it.consume {
|
||||
numberOrAddressPickerDialog?.dismiss()
|
||||
numberOrAddressPickerDialog = null
|
||||
|
|
@ -172,7 +172,7 @@ class ConversationForwardMessageFragment : SlidingPaneChildFragment() {
|
|||
}
|
||||
}
|
||||
|
||||
private fun showNumberOrAddressPickerDialog(list: ArrayList<ContactNumberOrAddressModel>) {
|
||||
private fun showNumberOrAddressPickerDialog(list: List<ContactNumberOrAddressModel>) {
|
||||
val numberOrAddressModel = NumberOrAddressPickerDialogModel(list)
|
||||
val dialog =
|
||||
DialogUtils.getNumberOrAddressPickerDialog(
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ import org.linphone.core.tools.Log
|
|||
import org.linphone.databinding.ChatConversationFragmentBinding
|
||||
import org.linphone.databinding.ChatConversationPopupMenuBinding
|
||||
import org.linphone.ui.GenericActivity
|
||||
import org.linphone.ui.main.chat.ConversationScrollListener
|
||||
import org.linphone.ui.main.chat.RecyclerViewScrollListener
|
||||
import org.linphone.ui.main.chat.adapter.ConversationEventAdapter
|
||||
import org.linphone.ui.main.chat.adapter.MessageBottomSheetAdapter
|
||||
import org.linphone.ui.main.chat.model.FileModel
|
||||
|
|
@ -94,6 +94,10 @@ import org.linphone.utils.hideKeyboard
|
|||
import org.linphone.utils.setKeyboardInsetListener
|
||||
import org.linphone.utils.showKeyboard
|
||||
import androidx.core.net.toUri
|
||||
import org.linphone.ui.main.chat.adapter.ConversationParticipantsAdapter
|
||||
import org.linphone.ui.main.chat.model.MessageDeleteDialogModel
|
||||
import org.linphone.utils.ShortcutUtils
|
||||
import kotlin.collections.arrayListOf
|
||||
|
||||
@UiThread
|
||||
open class ConversationFragment : SlidingPaneChildFragment() {
|
||||
|
|
@ -113,6 +117,8 @@ open class ConversationFragment : SlidingPaneChildFragment() {
|
|||
|
||||
private lateinit var adapter: ConversationEventAdapter
|
||||
|
||||
private lateinit var participantsAdapter: ConversationParticipantsAdapter
|
||||
|
||||
private lateinit var bottomSheetAdapter: MessageBottomSheetAdapter
|
||||
|
||||
private val args: ConversationFragmentArgs by navArgs()
|
||||
|
|
@ -127,22 +133,20 @@ open class ConversationFragment : SlidingPaneChildFragment() {
|
|||
)
|
||||
) { list ->
|
||||
sendMessageViewModel.closeFilePickerBottomSheet()
|
||||
if (list.isNotEmpty()) {
|
||||
val filesToAttach = arrayListOf<String>()
|
||||
lifecycleScope.launch {
|
||||
for (uri in list) {
|
||||
lifecycleScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
val path = FileUtils.getFilePath(requireContext(), uri, false)
|
||||
Log.i("$TAG Picked file [$uri] matching path is [$path]")
|
||||
if (path != null) {
|
||||
withContext(Dispatchers.Main) {
|
||||
sendMessageViewModel.addAttachment(path)
|
||||
}
|
||||
}
|
||||
withContext(Dispatchers.IO) {
|
||||
val path = FileUtils.getFilePath(requireContext(), uri, false)
|
||||
Log.i("$TAG Picked file [$uri] matching path is [$path]")
|
||||
if (path != null) {
|
||||
filesToAttach.add(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.w("$TAG No file picked")
|
||||
withContext(Dispatchers.Main) {
|
||||
sendMessageViewModel.addAttachments(filesToAttach)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -152,16 +156,20 @@ open class ConversationFragment : SlidingPaneChildFragment() {
|
|||
ActivityResultContracts.OpenMultipleDocuments()
|
||||
) { files ->
|
||||
sendMessageViewModel.closeFilePickerBottomSheet()
|
||||
for (fileUri in files) {
|
||||
lifecycleScope.launch {
|
||||
val filesToAttach = arrayListOf<String>()
|
||||
lifecycleScope.launch {
|
||||
for (fileUri in files) {
|
||||
val path = FileUtils.getFilePath(requireContext(), fileUri, false).orEmpty()
|
||||
if (path.isNotEmpty()) {
|
||||
Log.i("$TAG Picked file [$path]")
|
||||
sendMessageViewModel.addAttachment(path)
|
||||
filesToAttach.add(path)
|
||||
} else {
|
||||
Log.e("$TAG Failed to pick file [$fileUri]")
|
||||
}
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
sendMessageViewModel.addAttachments(filesToAttach)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -173,7 +181,7 @@ open class ConversationFragment : SlidingPaneChildFragment() {
|
|||
if (path != null) {
|
||||
if (captured) {
|
||||
Log.i("$TAG Image was captured and saved in [$path]")
|
||||
sendMessageViewModel.addAttachment(path)
|
||||
sendMessageViewModel.addAttachments(arrayListOf(path))
|
||||
} else {
|
||||
Log.w("$TAG Image capture was aborted")
|
||||
lifecycleScope.launch {
|
||||
|
|
@ -278,26 +286,33 @@ open class ConversationFragment : SlidingPaneChildFragment() {
|
|||
override fun onTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {
|
||||
}
|
||||
|
||||
override fun afterTextChanged(p0: Editable?) {
|
||||
override fun afterTextChanged(editable: Editable?) {
|
||||
if (viewModel.isGroup.value == true) {
|
||||
sendMessageViewModel.closeParticipantsList()
|
||||
val split = editable.toString().split(" ")
|
||||
if (split.isNotEmpty()) {
|
||||
val lastPart = split.last()
|
||||
if (lastPart.isNotEmpty() && lastPart.startsWith("@")) {
|
||||
coreContext.postOnCoreThread {
|
||||
val filter = if (lastPart.length > 1) lastPart.substring(1) else ""
|
||||
sendMessageViewModel.filterParticipantsList(filter)
|
||||
}
|
||||
|
||||
val split = p0.toString().split(" ")
|
||||
for (part in split) {
|
||||
if (part == "@") {
|
||||
Log.i("$TAG '@' found, opening participants list")
|
||||
sendMessageViewModel.openParticipantsList()
|
||||
if (sendMessageViewModel.isParticipantsListOpen.value == false) {
|
||||
Log.i("$TAG '@' found, opening participants list")
|
||||
sendMessageViewModel.openParticipantsList()
|
||||
}
|
||||
} else if (sendMessageViewModel.isParticipantsListOpen.value == true) {
|
||||
Log.i("$TAG Closing participants list")
|
||||
sendMessageViewModel.closeParticipantsList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (p0.toString().isNotEmpty()) {
|
||||
sendMessageViewModel.notifyChatMessageIsBeingComposed()
|
||||
}
|
||||
sendMessageViewModel.notifyComposing(editable.toString().isNotEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
private lateinit var scrollListener: ConversationScrollListener
|
||||
private lateinit var scrollListener: RecyclerViewScrollListener
|
||||
|
||||
private lateinit var headerItemDecoration: RecyclerViewHeaderDecoration
|
||||
|
||||
|
|
@ -313,6 +328,8 @@ open class ConversationFragment : SlidingPaneChildFragment() {
|
|||
if (e.y >= 0 && e.y <= headerItemDecoration.getDecorationHeight(0)) {
|
||||
if (viewModel.isEndToEndEncrypted.value == true) {
|
||||
showEndToEndEncryptionDetailsBottomSheet()
|
||||
} else {
|
||||
showUnsafeConversationDisabledDetailsBottomSheet()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
|
@ -383,6 +400,7 @@ open class ConversationFragment : SlidingPaneChildFragment() {
|
|||
super.onCreate(savedInstanceState)
|
||||
|
||||
adapter = ConversationEventAdapter()
|
||||
participantsAdapter = ConversationParticipantsAdapter()
|
||||
headerItemDecoration = RecyclerViewHeaderDecoration(
|
||||
requireContext(),
|
||||
adapter,
|
||||
|
|
@ -454,6 +472,10 @@ open class ConversationFragment : SlidingPaneChildFragment() {
|
|||
layoutManager.stackFromEnd = true
|
||||
binding.eventsList.layoutManager = layoutManager
|
||||
|
||||
binding.sendArea.participants.participantsList.setHasFixedSize(true)
|
||||
val participantsLayoutManager = LinearLayoutManager(requireContext())
|
||||
binding.sendArea.participants.participantsList.layoutManager = participantsLayoutManager
|
||||
|
||||
val callbacks = RecyclerViewSwipeUtilsCallback(
|
||||
R.drawable.reply,
|
||||
ConversationEventAdapter.EventViewHolder::class.java
|
||||
|
|
@ -471,9 +493,14 @@ open class ConversationFragment : SlidingPaneChildFragment() {
|
|||
val chatMessageEventLog = adapter.currentList[index]
|
||||
val chatMessageModel = (chatMessageEventLog.model as? MessageModel)
|
||||
if (chatMessageModel != null) {
|
||||
sendMessageViewModel.replyToMessage(chatMessageModel)
|
||||
// Open keyboard & focus edit text
|
||||
binding.sendArea.messageToSend.showKeyboard()
|
||||
if (chatMessageModel.hasBeenRetracted.value == true) { // Don't allow to reply to retracted messages
|
||||
// TODO: notify user?
|
||||
} else {
|
||||
viewModel.closeSearchBar()
|
||||
sendMessageViewModel.replyToMessage(chatMessageModel)
|
||||
// Open keyboard & focus edit text
|
||||
binding.sendArea.messageToSend.showKeyboard()
|
||||
}
|
||||
} else {
|
||||
Log.e(
|
||||
"$TAG Can't reply, failed to get a ChatMessageModel from adapter item #[$index]"
|
||||
|
|
@ -502,6 +529,9 @@ open class ConversationFragment : SlidingPaneChildFragment() {
|
|||
)
|
||||
}
|
||||
} else {
|
||||
sharedViewModel.displayedChatRoom = viewModel.chatRoom
|
||||
ShortcutUtils.reportChatRoomShortcutHasBeenUsed(requireContext(), viewModel.conversationId)
|
||||
|
||||
sendMessageViewModel.configureChatRoom(viewModel.chatRoom)
|
||||
adapter.setIsConversationSecured(viewModel.isEndToEndEncrypted.value == true)
|
||||
|
||||
|
|
@ -542,8 +572,8 @@ open class ConversationFragment : SlidingPaneChildFragment() {
|
|||
"$TAG Voice record playback finished, looking for voice record in next message"
|
||||
)
|
||||
val list = viewModel.eventsList
|
||||
val model = list.find {
|
||||
(it.model as? MessageModel)?.id == id
|
||||
val model = list.find { eventLogModel ->
|
||||
(eventLogModel.model as? MessageModel)?.id == id
|
||||
}
|
||||
if (model != null) {
|
||||
val index = list.indexOf(model)
|
||||
|
|
@ -586,8 +616,7 @@ open class ConversationFragment : SlidingPaneChildFragment() {
|
|||
|
||||
viewModel.isEndToEndEncrypted.observe(viewLifecycleOwner) { encrypted ->
|
||||
adapter.setIsConversationSecured(encrypted)
|
||||
|
||||
if (encrypted) {
|
||||
if (encrypted || (!encrypted && viewModel.isEndToEndEncryptionAvailable.value == true)) {
|
||||
binding.eventsList.addItemDecoration(headerItemDecoration)
|
||||
binding.eventsList.addOnItemTouchListener(listItemTouchListener)
|
||||
}
|
||||
|
|
@ -714,6 +743,12 @@ open class ConversationFragment : SlidingPaneChildFragment() {
|
|||
false
|
||||
}
|
||||
|
||||
sendMessageViewModel.messageSentEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { message ->
|
||||
viewModel.addSentMessageToEventsList(message)
|
||||
}
|
||||
}
|
||||
|
||||
sendMessageViewModel.emojiToAddEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { emoji ->
|
||||
binding.sendArea.messageToSend.addCharacterAtPosition(emoji)
|
||||
|
|
@ -747,9 +782,20 @@ open class ConversationFragment : SlidingPaneChildFragment() {
|
|||
}
|
||||
}
|
||||
|
||||
sendMessageViewModel.participants.observe(viewLifecycleOwner) {
|
||||
participantsAdapter.submitList(it)
|
||||
|
||||
if (binding.sendArea.participants.participantsList.adapter != participantsAdapter) {
|
||||
binding.sendArea.participants.participantsList.adapter = participantsAdapter
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.focusSearchBarEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { show ->
|
||||
if (show) {
|
||||
val bottomSheetBehavior = BottomSheetBehavior.from(binding.messageBottomSheet.root)
|
||||
bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
|
||||
// To automatically open keyboard
|
||||
binding.search.showKeyboard()
|
||||
} else {
|
||||
|
|
@ -766,6 +812,21 @@ open class ConversationFragment : SlidingPaneChildFragment() {
|
|||
}
|
||||
}
|
||||
|
||||
viewModel.sipUriToCallEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { sipUri ->
|
||||
coreContext.postOnCoreThread {
|
||||
if (messageLongPressViewModel.visible.value == true) return@postOnCoreThread
|
||||
val address = coreContext.core.interpretUrl(sipUri, false)
|
||||
if (address != null) {
|
||||
Log.i("$TAG Starting audio call to parsed SIP URI [${address.asStringUriOnly()}]")
|
||||
coreContext.startAudioCall(address)
|
||||
} else {
|
||||
Log.w("$TAG Failed to parse [$sipUri] as SIP URI")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.conferenceToJoinEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { conferenceUri ->
|
||||
if (messageLongPressViewModel.visible.value == true) return@consume
|
||||
|
|
@ -781,6 +842,14 @@ open class ConversationFragment : SlidingPaneChildFragment() {
|
|||
try {
|
||||
val browserIntent = Intent(Intent.ACTION_VIEW, url.toUri())
|
||||
startActivity(browserIntent)
|
||||
} catch (ise: IllegalStateException) {
|
||||
Log.e(
|
||||
"$TAG Can't start ACTION_VIEW intent for URL [$url], IllegalStateException: $ise"
|
||||
)
|
||||
} catch (anfe: ActivityNotFoundException) {
|
||||
Log.e(
|
||||
"$TAG Can't start ACTION_VIEW intent for URL [$url], ActivityNotFoundException: $anfe"
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(
|
||||
"$TAG Can't start ACTION_VIEW intent for URL [$url]: $e"
|
||||
|
|
@ -803,15 +872,27 @@ open class ConversationFragment : SlidingPaneChildFragment() {
|
|||
val message = getString(R.string.conversation_message_deleted_toast)
|
||||
val icon = R.drawable.trash_simple
|
||||
(requireActivity() as GenericActivity).showGreenToast(message, icon)
|
||||
sharedViewModel.forceRefreshConversations.value = Event(true)
|
||||
sharedViewModel.updateConversationLastMessageEvent.value = Event(viewModel.conversationId)
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.itemToScrollTo.observe(viewLifecycleOwner) { position ->
|
||||
if (position >= 0) {
|
||||
Log.i("$TAG Scrolling to message/event at position [$position]")
|
||||
val recyclerView = binding.eventsList
|
||||
recyclerView.scrollToPosition(position)
|
||||
val layoutManager = recyclerView.layoutManager as LinearLayoutManager
|
||||
val firstDisplayedItemPosition = layoutManager.findFirstVisibleItemPosition()
|
||||
val lastDisplayedItemPosition = layoutManager.findLastVisibleItemPosition()
|
||||
Log.i(
|
||||
"$TAG Scrolling to message/event at position [$position], " +
|
||||
"display show events between positions [$firstDisplayedItemPosition] and [$lastDisplayedItemPosition]"
|
||||
)
|
||||
if (firstDisplayedItemPosition > position && position > 0) {
|
||||
recyclerView.scrollToPosition(position - 1)
|
||||
} else if (lastDisplayedItemPosition < position && position < layoutManager.itemCount - 1) {
|
||||
recyclerView.scrollToPosition(position + 1)
|
||||
} else {
|
||||
recyclerView.scrollToPosition(position)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -824,10 +905,28 @@ open class ConversationFragment : SlidingPaneChildFragment() {
|
|||
}
|
||||
}
|
||||
|
||||
messageLongPressViewModel.editMessageEvent.observe(viewLifecycleOwner) {
|
||||
it.consume {
|
||||
val model = messageLongPressViewModel.messageModel.value
|
||||
if (model != null) {
|
||||
viewModel.closeSearchBar()
|
||||
sendMessageViewModel.editMessage(model)
|
||||
|
||||
// Open keyboard & focus edit text
|
||||
binding.sendArea.messageToSend.showKeyboard()
|
||||
// Put cursor at the end
|
||||
coreContext.postOnMainThread {
|
||||
binding.sendArea.messageToSend.setSelection(binding.sendArea.messageToSend.length())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
messageLongPressViewModel.replyToMessageEvent.observe(viewLifecycleOwner) {
|
||||
it.consume {
|
||||
val model = messageLongPressViewModel.messageModel.value
|
||||
if (model != null) {
|
||||
viewModel.closeSearchBar()
|
||||
sendMessageViewModel.replyToMessage(model)
|
||||
// Open keyboard & focus edit text
|
||||
binding.sendArea.messageToSend.showKeyboard()
|
||||
|
|
@ -839,7 +938,13 @@ open class ConversationFragment : SlidingPaneChildFragment() {
|
|||
it.consume {
|
||||
val model = messageLongPressViewModel.messageModel.value
|
||||
if (model != null) {
|
||||
viewModel.deleteChatMessage(model)
|
||||
if (model.isOutgoing && !(model.hasBeenRetracted.value ?: false)) {
|
||||
// For sent messages let user choose between delete locally / delete for everyone
|
||||
showHowToDeleteMessageDialog(model)
|
||||
} else {
|
||||
// For received messages or retracted sent ones you can only delete locally
|
||||
viewModel.deleteChatMessage(model)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -848,6 +953,9 @@ open class ConversationFragment : SlidingPaneChildFragment() {
|
|||
it.consume {
|
||||
val model = messageLongPressViewModel.messageModel.value
|
||||
if (model != null) {
|
||||
viewModel.closeSearchBar()
|
||||
sendMessageViewModel.cancelReply()
|
||||
|
||||
// Remove observer before setting the message to forward
|
||||
// as we don't want to forward it in this chat room
|
||||
sharedViewModel.messageToForwardEvent.removeObservers(viewLifecycleOwner)
|
||||
|
|
@ -878,7 +986,7 @@ open class ConversationFragment : SlidingPaneChildFragment() {
|
|||
Log.i("$TAG Rich content URI [$uri] matching path is [$path]")
|
||||
if (path != null) {
|
||||
withContext(Dispatchers.Main) {
|
||||
sendMessageViewModel.addAttachment(path)
|
||||
sendMessageViewModel.addAttachments(arrayListOf(path))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -906,14 +1014,14 @@ open class ConversationFragment : SlidingPaneChildFragment() {
|
|||
if (files.isNotEmpty()) {
|
||||
Log.i("$TAG Found [${files.size}] files to share from intent")
|
||||
for (path in files) {
|
||||
sendMessageViewModel.addAttachment(path)
|
||||
sendMessageViewModel.addAttachments(arrayListOf(path))
|
||||
}
|
||||
|
||||
sharedViewModel.filesToShareFromIntent.value = arrayListOf()
|
||||
}
|
||||
}
|
||||
|
||||
sharedViewModel.forceRefreshConversationInfo.observe(viewLifecycleOwner) {
|
||||
sharedViewModel.forceRefreshConversationInfoEvent.observe(viewLifecycleOwner) {
|
||||
it.consume {
|
||||
Log.i("$TAG Force refreshing conversation info")
|
||||
viewModel.refresh()
|
||||
|
|
@ -927,7 +1035,7 @@ open class ConversationFragment : SlidingPaneChildFragment() {
|
|||
}
|
||||
}
|
||||
|
||||
sharedViewModel.newChatMessageEphemeralLifetimeToSet.observe(viewLifecycleOwner) {
|
||||
sharedViewModel.newChatMessageEphemeralLifetimeToSetEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { ephemeralLifetime ->
|
||||
Log.i(
|
||||
"$TAG Setting [$ephemeralLifetime] as new ephemeral lifetime for messages"
|
||||
|
|
@ -946,7 +1054,7 @@ open class ConversationFragment : SlidingPaneChildFragment() {
|
|||
|
||||
binding.sendArea.messageToSend.addTextChangedListener(textObserver)
|
||||
|
||||
scrollListener = object : ConversationScrollListener(layoutManager) {
|
||||
scrollListener = object : RecyclerViewScrollListener(layoutManager, 5, false) {
|
||||
@UiThread
|
||||
override fun onLoadMore(totalItemsCount: Int) {
|
||||
if (viewModel.searchInProgress.value == false) {
|
||||
|
|
@ -1174,14 +1282,14 @@ open class ConversationFragment : SlidingPaneChildFragment() {
|
|||
Log.i("$TAG Muting conversation")
|
||||
viewModel.mute()
|
||||
popupWindow.dismiss()
|
||||
sharedViewModel.forceRefreshDisplayedConversation.value = Event(true)
|
||||
sharedViewModel.forceRefreshDisplayedConversationEvent.value = Event(true)
|
||||
}
|
||||
|
||||
popupView.setUnmuteClickListener {
|
||||
Log.i("$TAG Un-muting conversation")
|
||||
viewModel.unMute()
|
||||
popupWindow.dismiss()
|
||||
sharedViewModel.forceRefreshDisplayedConversation.value = Event(true)
|
||||
sharedViewModel.forceRefreshDisplayedConversationEvent.value = Event(true)
|
||||
}
|
||||
|
||||
popupView.setConfigureEphemeralMessagesClickListener {
|
||||
|
|
@ -1244,6 +1352,7 @@ open class ConversationFragment : SlidingPaneChildFragment() {
|
|||
showDelivery: Boolean = false,
|
||||
showReactions: Boolean = false
|
||||
) {
|
||||
viewModel.closeSearchBar()
|
||||
binding.sendArea.messageToSend.hideKeyboard()
|
||||
backPressedCallback.isEnabled = true
|
||||
|
||||
|
|
@ -1301,7 +1410,7 @@ open class ConversationFragment : SlidingPaneChildFragment() {
|
|||
|
||||
val model = MessageReactionsModel(chatMessageModel.chatMessage) { reactionsModel ->
|
||||
coreContext.postOnMainThread {
|
||||
if (reactionsModel.allReactions.isEmpty) {
|
||||
if (reactionsModel.allReactions.value.orEmpty().isEmpty()) {
|
||||
Log.i("$TAG No reaction to display, closing bottom sheet")
|
||||
val bottomSheetBehavior = BottomSheetBehavior.from(
|
||||
binding.messageBottomSheet.root
|
||||
|
|
@ -1320,54 +1429,64 @@ open class ConversationFragment : SlidingPaneChildFragment() {
|
|||
private fun displayDeliveryStatuses(model: MessageDeliveryModel) {
|
||||
val tabs = binding.messageBottomSheet.tabs
|
||||
tabs.removeAllTabs()
|
||||
tabs.addTab(
|
||||
tabs.newTab().setText(model.readLabel.value).setId(
|
||||
ChatMessage.State.Displayed.toInt()
|
||||
)
|
||||
|
||||
val displayedTab = tabs.newTab().setText(model.readLabel.value).setId(
|
||||
ChatMessage.State.Displayed.toInt()
|
||||
)
|
||||
tabs.addTab(
|
||||
tabs.newTab().setText(
|
||||
model.receivedLabel.value
|
||||
).setId(
|
||||
ChatMessage.State.DeliveredToUser.toInt()
|
||||
)
|
||||
val deliveredTab = tabs.newTab().setText(model.receivedLabel.value).setId(
|
||||
ChatMessage.State.DeliveredToUser.toInt()
|
||||
)
|
||||
tabs.addTab(
|
||||
tabs.newTab().setText(model.sentLabel.value).setId(
|
||||
ChatMessage.State.Delivered.toInt()
|
||||
)
|
||||
val sentTab = tabs.newTab().setText(model.sentLabel.value).setId(
|
||||
ChatMessage.State.Delivered.toInt()
|
||||
)
|
||||
tabs.addTab(
|
||||
tabs.newTab().setText(
|
||||
model.errorLabel.value
|
||||
).setId(
|
||||
ChatMessage.State.NotDelivered.toInt()
|
||||
)
|
||||
val errorTab = tabs.newTab().setText(model.errorLabel.value).setId(
|
||||
ChatMessage.State.NotDelivered.toInt()
|
||||
)
|
||||
// Tabs must be added first otherwise select() will do nothing
|
||||
tabs.addTab(displayedTab)
|
||||
tabs.addTab(deliveredTab)
|
||||
tabs.addTab(sentTab)
|
||||
tabs.addTab(errorTab)
|
||||
|
||||
if (model.displayedModels.isNotEmpty()) {
|
||||
bottomSheetAdapter.submitList(model.displayedModels)
|
||||
displayedTab.select()
|
||||
} else {
|
||||
if (model.deliveredModels.isNotEmpty()) {
|
||||
bottomSheetAdapter.submitList(model.deliveredModels)
|
||||
deliveredTab.select()
|
||||
} else {
|
||||
if (model.sentModels.isNotEmpty()) {
|
||||
bottomSheetAdapter.submitList(model.sentModels)
|
||||
sentTab.select()
|
||||
} else {
|
||||
if (model.errorModels.isNotEmpty()) {
|
||||
bottomSheetAdapter.submitList(model.errorModels)
|
||||
errorTab.select()
|
||||
} else {
|
||||
// TODO FIXME: remove all tabs and show error message?
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tabs.setOnTabSelectedListener(object : OnTabSelectedListener {
|
||||
override fun onTabSelected(tab: TabLayout.Tab?) {
|
||||
val state = tab?.id ?: ChatMessage.State.Displayed.toInt()
|
||||
bottomSheetAdapter.submitList(
|
||||
model.computeListForState(ChatMessage.State.fromInt(state))
|
||||
model.getListForState(ChatMessage.State.fromInt(state))
|
||||
)
|
||||
}
|
||||
|
||||
override fun onTabUnselected(tab: TabLayout.Tab?) {
|
||||
}
|
||||
override fun onTabUnselected(tab: TabLayout.Tab?) { }
|
||||
|
||||
override fun onTabReselected(tab: TabLayout.Tab?) {
|
||||
}
|
||||
override fun onTabReselected(tab: TabLayout.Tab?) { }
|
||||
})
|
||||
|
||||
val initialList = model.displayedModels
|
||||
bottomSheetAdapter.submitList(initialList)
|
||||
Log.i("$TAG Submitted [${initialList.size}] items for default delivery status list")
|
||||
}
|
||||
|
||||
@UiThread
|
||||
private fun displayReactions(model: MessageReactionsModel) {
|
||||
val totalCount = model.allReactions.size
|
||||
val totalCount = model.allReactions.value.orEmpty().size
|
||||
val label = getString(R.string.message_reactions_info_all_title, totalCount.toString())
|
||||
|
||||
val tabs = binding.messageBottomSheet.tabs
|
||||
|
|
@ -1377,7 +1496,7 @@ open class ConversationFragment : SlidingPaneChildFragment() {
|
|||
)
|
||||
|
||||
var index = 1
|
||||
for (reaction in model.differentReactions.value.orEmpty()) {
|
||||
for (reaction in model.differentReactions) {
|
||||
val count = model.reactionsMap[reaction]
|
||||
val tabLabel = getString(
|
||||
R.string.message_reactions_info_emoji_title,
|
||||
|
|
@ -1394,7 +1513,7 @@ open class ConversationFragment : SlidingPaneChildFragment() {
|
|||
override fun onTabSelected(tab: TabLayout.Tab?) {
|
||||
val filter = tab?.tag.toString()
|
||||
if (filter.isEmpty()) {
|
||||
bottomSheetAdapter.submitList(model.allReactions)
|
||||
bottomSheetAdapter.submitList(model.allReactions.value.orEmpty())
|
||||
} else {
|
||||
bottomSheetAdapter.submitList(model.filterReactions(filter))
|
||||
}
|
||||
|
|
@ -1407,7 +1526,7 @@ open class ConversationFragment : SlidingPaneChildFragment() {
|
|||
}
|
||||
})
|
||||
|
||||
val initialList = model.allReactions
|
||||
val initialList = model.allReactions.value.orEmpty()
|
||||
bottomSheetAdapter.submitList(initialList)
|
||||
Log.i("$TAG Submitted [${initialList.size}] items for default reactions list")
|
||||
}
|
||||
|
|
@ -1443,7 +1562,7 @@ open class ConversationFragment : SlidingPaneChildFragment() {
|
|||
}
|
||||
}
|
||||
|
||||
model.cancelEvent.observe(viewLifecycleOwner) {
|
||||
model.alternativeChoiceEvent.observe(viewLifecycleOwner) {
|
||||
it.consume {
|
||||
openFileInAnotherApp(path, mime, bundle)
|
||||
dialog.dismiss()
|
||||
|
|
@ -1511,12 +1630,6 @@ open class ConversationFragment : SlidingPaneChildFragment() {
|
|||
}
|
||||
}
|
||||
|
||||
model.cancelEvent.observe(viewLifecycleOwner) {
|
||||
it.consume {
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
model.confirmEvent.observe(viewLifecycleOwner) {
|
||||
it.consume {
|
||||
sharedViewModel.displayFileEvent.value = Event(bundle)
|
||||
|
|
@ -1537,6 +1650,50 @@ open class ConversationFragment : SlidingPaneChildFragment() {
|
|||
type = mime
|
||||
putExtra(Intent.EXTRA_TITLE, name)
|
||||
}
|
||||
startActivityForResult(intent, EXPORT_FILE_AS_DOCUMENT)
|
||||
try {
|
||||
startActivityForResult(intent, EXPORT_FILE_AS_DOCUMENT)
|
||||
} catch (exception: ActivityNotFoundException) {
|
||||
Log.e("$TAG No activity found to handle intent ACTION_CREATE_DOCUMENT: $exception")
|
||||
}
|
||||
}
|
||||
|
||||
private fun showHowToDeleteMessageDialog(model: MessageModel) {
|
||||
val canBeRetracted = messageLongPressViewModel.canBeRemotelyDeleted.value == true
|
||||
val dialogModel = MessageDeleteDialogModel(canBeRetracted)
|
||||
|
||||
val dialog = DialogUtils.getHowToDeleteMessageDialog(
|
||||
requireActivity(),
|
||||
dialogModel
|
||||
)
|
||||
|
||||
dialogModel.dismissEvent.observe(viewLifecycleOwner) {
|
||||
it.consume {
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
dialogModel.cancelEvent.observe(viewLifecycleOwner) {
|
||||
it.consume {
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
dialogModel.deleteLocallyEvent.observe(viewLifecycleOwner) {
|
||||
it.consume {
|
||||
Log.i("$TAG Deleting chat message locally")
|
||||
viewModel.deleteChatMessage(model)
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
dialogModel.deleteForEveryoneEvent.observe(viewLifecycleOwner) {
|
||||
it.consume {
|
||||
Log.i("$TAG Deleting chat message (content) for everyone")
|
||||
viewModel.deleteChatMessageForEveryone(model)
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
dialog.show()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,9 +19,6 @@
|
|||
*/
|
||||
package org.linphone.ui.main.chat.fragment
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.Gravity
|
||||
import android.view.LayoutInflater
|
||||
|
|
@ -34,6 +31,7 @@ import androidx.lifecycle.ViewModelProvider
|
|||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import org.linphone.LinphoneApplication.Companion.corePreferences
|
||||
import org.linphone.R
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.databinding.ChatInfoFragmentBinding
|
||||
|
|
@ -45,6 +43,7 @@ import org.linphone.ui.main.chat.viewmodel.ConversationInfoViewModel
|
|||
import org.linphone.ui.main.fragment.SlidingPaneChildFragment
|
||||
import org.linphone.utils.ConfirmationDialogModel
|
||||
import org.linphone.ui.main.model.GroupSetOrEditSubjectDialogModel
|
||||
import org.linphone.utils.AppUtils
|
||||
import org.linphone.utils.DialogUtils
|
||||
import org.linphone.utils.Event
|
||||
|
||||
|
|
@ -136,7 +135,7 @@ class ConversationInfoFragment : SlidingPaneChildFragment() {
|
|||
viewModel.groupLeftEvent.observe(viewLifecycleOwner) {
|
||||
it.consume {
|
||||
Log.i("$TAG Group has been left, leaving conversation info...")
|
||||
sharedViewModel.forceRefreshConversationInfo.value = Event(true)
|
||||
sharedViewModel.forceRefreshConversationInfoEvent.value = Event(true)
|
||||
goBack()
|
||||
val message = getString(R.string.conversation_group_left_toast)
|
||||
(requireActivity() as GenericActivity).showGreenToast(
|
||||
|
|
@ -149,6 +148,7 @@ class ConversationInfoFragment : SlidingPaneChildFragment() {
|
|||
viewModel.historyDeletedEvent.observe(viewLifecycleOwner) {
|
||||
it.consume {
|
||||
Log.i("$TAG History has been deleted, leaving conversation info...")
|
||||
sharedViewModel.updateConversationLastMessageEvent.value = Event(viewModel.conversationId)
|
||||
sharedViewModel.forceRefreshConversationEvents.value = Event(true)
|
||||
goBack()
|
||||
val message = getString(R.string.conversation_info_history_deleted_toast)
|
||||
|
|
@ -179,7 +179,7 @@ class ConversationInfoFragment : SlidingPaneChildFragment() {
|
|||
|
||||
viewModel.infoChangedEvent.observe(viewLifecycleOwner) {
|
||||
it.consume {
|
||||
sharedViewModel.forceRefreshConversationInfo.postValue(Event(true))
|
||||
sharedViewModel.forceRefreshConversationInfoEvent.postValue(Event(true))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -196,7 +196,7 @@ class ConversationInfoFragment : SlidingPaneChildFragment() {
|
|||
}
|
||||
}
|
||||
|
||||
sharedViewModel.newChatMessageEphemeralLifetimeToSet.observe(viewLifecycleOwner) {
|
||||
sharedViewModel.newChatMessageEphemeralLifetimeToSetEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { ephemeralLifetime ->
|
||||
Log.i(
|
||||
"$TAG Setting [$ephemeralLifetime] as new ephemeral lifetime for messages"
|
||||
|
|
@ -366,6 +366,7 @@ class ConversationInfoFragment : SlidingPaneChildFragment() {
|
|||
popupView.isMeAdmin = participantModel.isMyselfAdmin
|
||||
val friendRefKey = participantModel.refKey
|
||||
popupView.isParticipantContact = participantModel.friendAvailable
|
||||
popupView.disableAddContact = corePreferences.disableAddContact
|
||||
|
||||
popupView.setRemoveParticipantClickListener {
|
||||
Log.i("$TAG Trying to remove participant [$address]")
|
||||
|
|
@ -425,14 +426,13 @@ class ConversationInfoFragment : SlidingPaneChildFragment() {
|
|||
|
||||
popupView.setCopySipUriClickListener {
|
||||
val sipUri = participantModel.sipUri
|
||||
val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("SIP address", sipUri))
|
||||
|
||||
val message = getString(R.string.sip_address_copied_to_clipboard_toast)
|
||||
(requireActivity() as GenericActivity).showGreenToast(
|
||||
message,
|
||||
R.drawable.check
|
||||
)
|
||||
if (AppUtils.copyToClipboard(requireContext(), "SIP address", sipUri)) {
|
||||
val message = getString(R.string.sip_address_copied_to_clipboard_toast)
|
||||
(requireActivity() as GenericActivity).showGreenToast(
|
||||
message,
|
||||
R.drawable.check
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Elevation is for showing a shadow around the popup
|
||||
|
|
@ -487,12 +487,9 @@ class ConversationInfoFragment : SlidingPaneChildFragment() {
|
|||
}
|
||||
|
||||
private fun copyAddressToClipboard(value: String) {
|
||||
val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("SIP address", value))
|
||||
val message = getString(R.string.sip_address_copied_to_clipboard_toast)
|
||||
(requireActivity() as GenericActivity).showGreenToast(
|
||||
message,
|
||||
R.drawable.check
|
||||
)
|
||||
if (AppUtils.copyToClipboard(requireContext(), "SIP address", value)) {
|
||||
val message = getString(R.string.sip_address_copied_to_clipboard_toast)
|
||||
(requireActivity() as GenericActivity).showGreenToast(message, R.drawable.check)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import org.linphone.R
|
|||
import org.linphone.core.tools.Log
|
||||
import org.linphone.databinding.ChatMediaFragmentBinding
|
||||
import org.linphone.ui.GenericActivity
|
||||
import org.linphone.ui.main.chat.RecyclerViewScrollListener
|
||||
import org.linphone.ui.main.chat.adapter.ConversationsFilesAdapter
|
||||
import org.linphone.ui.main.chat.model.FileModel
|
||||
import org.linphone.ui.main.chat.viewmodel.ConversationMediaListViewModel
|
||||
|
|
@ -58,6 +59,8 @@ class ConversationMediaListFragment : SlidingPaneChildFragment() {
|
|||
|
||||
private val args: ConversationMediaListFragmentArgs by navArgs()
|
||||
|
||||
private lateinit var scrollListener: RecyclerViewScrollListener
|
||||
|
||||
override fun goBack(): Boolean {
|
||||
try {
|
||||
return findNavController().popBackStack()
|
||||
|
|
@ -103,7 +106,7 @@ class ConversationMediaListFragment : SlidingPaneChildFragment() {
|
|||
binding.mediaList.addItemDecoration(headerItemDecoration)
|
||||
|
||||
binding.mediaList.setHasFixedSize(true)
|
||||
val spanCount = 4
|
||||
val spanCount = requireContext().resources.getInteger(R.integer.media_columns)
|
||||
val layoutManager = object : GridLayoutManager(requireContext(), spanCount) {
|
||||
override fun checkLayoutParams(lp: RecyclerView.LayoutParams): Boolean {
|
||||
lp.width = width / spanCount
|
||||
|
|
@ -159,6 +162,40 @@ class ConversationMediaListFragment : SlidingPaneChildFragment() {
|
|||
goToFileViewer(model)
|
||||
}
|
||||
}
|
||||
|
||||
scrollListener = object : RecyclerViewScrollListener(layoutManager, spanCount, true) {
|
||||
@UiThread
|
||||
override fun onLoadMore(totalItemsCount: Int) {
|
||||
Log.i("$TAG Asking for more data to display, currently displayed items count is [$totalItemsCount]")
|
||||
viewModel.loadMoreData(totalItemsCount)
|
||||
}
|
||||
|
||||
@UiThread
|
||||
override fun onScrolledUp() {
|
||||
|
||||
}
|
||||
|
||||
@UiThread
|
||||
override fun onScrolledToEnd() {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
if (::scrollListener.isInitialized) {
|
||||
binding.mediaList.addOnScrollListener(scrollListener)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
|
||||
if (::scrollListener.isInitialized) {
|
||||
binding.mediaList.removeOnScrollListener(scrollListener)
|
||||
}
|
||||
}
|
||||
|
||||
private fun goToFileViewer(fileModel: FileModel) {
|
||||
|
|
|
|||
|
|
@ -36,7 +36,6 @@ import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
|||
import org.linphone.R
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.databinding.ChatListFragmentBinding
|
||||
import org.linphone.ui.GenericActivity
|
||||
import org.linphone.ui.fileviewer.FileViewerActivity
|
||||
import org.linphone.ui.fileviewer.MediaViewerActivity
|
||||
import org.linphone.ui.main.MainActivity.Companion.ARGUMENTS_CONVERSATION_ID
|
||||
|
|
@ -44,7 +43,6 @@ import org.linphone.ui.main.chat.adapter.ConversationsListAdapter
|
|||
import org.linphone.ui.main.chat.viewmodel.ConversationsListViewModel
|
||||
import org.linphone.ui.main.fragment.AbstractMainFragment
|
||||
import org.linphone.ui.main.history.fragment.HistoryMenuDialogFragment
|
||||
import org.linphone.utils.AppUtils
|
||||
import org.linphone.utils.Event
|
||||
import org.linphone.utils.LinphoneUtils
|
||||
|
||||
|
|
@ -251,36 +249,16 @@ class ConversationsListFragment : AbstractMainFragment() {
|
|||
}
|
||||
}
|
||||
|
||||
sharedViewModel.filesToShareFromIntent.observe(viewLifecycleOwner) { filesToShare ->
|
||||
val count = filesToShare.size
|
||||
if (count > 0) {
|
||||
val message = AppUtils.getStringWithPlural(
|
||||
R.plurals.conversations_files_waiting_to_be_shared_toast,
|
||||
count,
|
||||
filesToShare.size.toString()
|
||||
)
|
||||
val icon = R.drawable.file
|
||||
(requireActivity() as GenericActivity).showGreenToast(message, icon)
|
||||
Log.i("$TAG Found [$count] files waiting to be shared")
|
||||
sharedViewModel.updateConversationLastMessageEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { conversationId ->
|
||||
val model = listViewModel.conversations.value.orEmpty().find { conversationModel ->
|
||||
conversationModel.id == conversationId
|
||||
}
|
||||
model?.updateLastMessageInfo()
|
||||
}
|
||||
}
|
||||
|
||||
sharedViewModel.textToShareFromIntent.observe(viewLifecycleOwner) { textToShare ->
|
||||
if (textToShare.isNotEmpty()) {
|
||||
val message = getString(R.string.conversations_text_waiting_to_be_shared_toast)
|
||||
val icon = R.drawable.file_text
|
||||
(requireActivity() as GenericActivity).showGreenToast(message, icon)
|
||||
Log.i("$TAG Found text waiting to be shared")
|
||||
}
|
||||
}
|
||||
|
||||
sharedViewModel.forceRefreshConversations.observe(viewLifecycleOwner) {
|
||||
it.consume {
|
||||
listViewModel.filter()
|
||||
}
|
||||
}
|
||||
|
||||
sharedViewModel.forceRefreshDisplayedConversation.observe(viewLifecycleOwner) {
|
||||
sharedViewModel.forceRefreshDisplayedConversationEvent.observe(viewLifecycleOwner) {
|
||||
it.consume {
|
||||
val displayChatRoom = sharedViewModel.displayedChatRoom
|
||||
if (displayChatRoom != null) {
|
||||
|
|
@ -346,6 +324,11 @@ class ConversationsListFragment : AbstractMainFragment() {
|
|||
} catch (e: IllegalStateException) {
|
||||
Log.e("$TAG Failed to unregister data observer to adapter: $e")
|
||||
}
|
||||
|
||||
if (shouldRefreshDataInOnResume()) {
|
||||
Log.i("$TAG Keep app alive setting is enabled, refreshing view just in case")
|
||||
listViewModel.filter()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
|
|
|
|||
|
|
@ -24,12 +24,9 @@ import android.view.LayoutInflater
|
|||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.core.view.doOnPreDraw
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import org.linphone.R
|
||||
import org.linphone.core.Address
|
||||
import org.linphone.core.Friend
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.databinding.StartChatFragmentBinding
|
||||
import org.linphone.ui.GenericActivity
|
||||
|
|
@ -102,16 +99,6 @@ class StartConversationFragment : GenericAddressPickerFragment() {
|
|||
}
|
||||
}
|
||||
|
||||
viewModel.chatRoomCreationErrorEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { error ->
|
||||
Log.i("$TAG Conversation creation error, showing red toast")
|
||||
(requireActivity() as GenericActivity).showRedToast(
|
||||
getString(error),
|
||||
R.drawable.warning_circle
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.defaultAccountChangedEvent.observe(viewLifecycleOwner) {
|
||||
it.consume {
|
||||
viewModel.updateGroupChatButtonVisibility()
|
||||
|
|
@ -119,11 +106,6 @@ class StartConversationFragment : GenericAddressPickerFragment() {
|
|||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun onSingleAddressSelected(address: Address, friend: Friend) {
|
||||
viewModel.createOneToOneChatRoomWith(address)
|
||||
}
|
||||
|
||||
private fun showGroupConversationSubjectDialog() {
|
||||
val model = GroupSetOrEditSubjectDialogModel("", isGroupConversation = true)
|
||||
|
||||
|
|
|
|||
|
|
@ -20,8 +20,10 @@
|
|||
package org.linphone.ui.main.chat.model
|
||||
|
||||
import android.text.Spannable
|
||||
import android.text.SpannableStringBuilder
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.core.text.toSpannable
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
import org.linphone.R
|
||||
|
|
@ -80,6 +82,8 @@ class ConversationModel
|
|||
|
||||
val lastMessageContentIcon = MutableLiveData<Int>()
|
||||
|
||||
val composingIcon = MutableLiveData<Int>()
|
||||
|
||||
val isLastMessageOutgoing = MutableLiveData<Boolean>()
|
||||
|
||||
val dateTime = MutableLiveData<String>()
|
||||
|
|
@ -105,6 +109,7 @@ class ConversationModel
|
|||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun onConferenceJoined(chatRoom: ChatRoom, eventLog: EventLog) {
|
||||
// This is required as a Created chat room may not have the participants list yet
|
||||
Log.i("$TAG Conversation has been joined")
|
||||
|
|
@ -114,8 +119,8 @@ class ConversationModel
|
|||
|
||||
@WorkerThread
|
||||
override fun onConferenceLeft(chatRoom: ChatRoom, eventLog: EventLog) {
|
||||
Log.w("TAG Conversation has been left")
|
||||
isReadOnly.postValue(true)
|
||||
Log.w("$TAG Conversation has been left")
|
||||
isReadOnly.postValue(chatRoom.isReadOnly)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
|
|
@ -127,10 +132,12 @@ class ConversationModel
|
|||
computeComposingLabel()
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun onNewEvent(chatRoom: ChatRoom, eventLog: EventLog) {
|
||||
updateLastUpdatedTime()
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun onNewEvents(chatRoom: ChatRoom, eventLogs: Array<out EventLog>) {
|
||||
updateLastMessage()
|
||||
updateLastUpdatedTime()
|
||||
|
|
@ -151,6 +158,7 @@ class ConversationModel
|
|||
override fun onSubjectChanged(chatRoom: ChatRoom, eventLog: EventLog) {
|
||||
Log.i("$TAG Conversation subject changed [${chatRoom.subject}]")
|
||||
subject.postValue(chatRoom.subject)
|
||||
computeParticipants()
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
|
|
@ -163,6 +171,23 @@ class ConversationModel
|
|||
Log.i("$TAG An ephemeral message lifetime has expired, updating last displayed message")
|
||||
updateLastMessage()
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun onMessageRetracted(chatRoom: ChatRoom, message: ChatMessage) {
|
||||
if (lastMessage == null || message.messageId == lastMessage?.messageId) {
|
||||
Log.i("$TAG Last message [${message.messageId}] has been retracted")
|
||||
updateLastMessage()
|
||||
}
|
||||
unreadMessageCount.postValue(chatRoom.unreadMessagesCount)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun onMessageContentEdited(chatRoom: ChatRoom, message: ChatMessage) {
|
||||
if (lastMessage == null || message.messageId == lastMessage?.messageId) {
|
||||
Log.i("$TAG Last message [${message.messageId}] has been edited")
|
||||
updateLastMessage()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val chatMessageListener = object : ChatMessageListenerStub() {
|
||||
|
|
@ -272,6 +297,13 @@ class ConversationModel
|
|||
}
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun updateLastMessageInfo() {
|
||||
coreContext.postOnCoreThread {
|
||||
updateLastMessage()
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun updateLastMessageStatus(message: ChatMessage) {
|
||||
val isOutgoing = message.isOutgoing
|
||||
|
|
@ -295,7 +327,9 @@ class ConversationModel
|
|||
lastMessageDeliveryIcon.postValue(LinphoneUtils.getChatIconResId(message.state))
|
||||
}
|
||||
|
||||
if (message.isForward) {
|
||||
if (message.isRetracted) {
|
||||
lastMessageContentIcon.postValue(R.drawable.trash)
|
||||
} else if (message.isForward) {
|
||||
lastMessageContentIcon.postValue(R.drawable.forward)
|
||||
} else {
|
||||
val firstContent = message.contents.firstOrNull()
|
||||
|
|
@ -331,16 +365,35 @@ class ConversationModel
|
|||
|
||||
val message = chatRoom.lastMessageInHistory
|
||||
if (message != null) {
|
||||
lastMessage = message
|
||||
updateLastMessageStatus(message)
|
||||
|
||||
if (message.isOutgoing && message.state != ChatMessage.State.Displayed) {
|
||||
message.addListener(chatMessageListener)
|
||||
lastMessage = message
|
||||
} else if (message.contents.find { it.isFileTransfer == true } != null) {
|
||||
} else if (message.contents.find { it.isFileTransfer } != null) {
|
||||
message.addListener(chatMessageListener)
|
||||
lastMessage = message
|
||||
}
|
||||
|
||||
val timestamp = message.time
|
||||
val humanReadableTimestamp = when {
|
||||
TimestampUtils.isToday(timestamp) -> {
|
||||
TimestampUtils.timeToString(timestamp)
|
||||
}
|
||||
TimestampUtils.isYesterday(timestamp) -> {
|
||||
AppUtils.getString(R.string.yesterday)
|
||||
}
|
||||
else -> {
|
||||
TimestampUtils.toString(timestamp, onlyDate = true)
|
||||
}
|
||||
}
|
||||
dateTime.postValue(humanReadableTimestamp)
|
||||
} else {
|
||||
lastMessage = null
|
||||
lastMessageTextSender.postValue("")
|
||||
lastMessageContentIcon.postValue(0)
|
||||
lastMessageText.postValue(SpannableStringBuilder("").toSpannable())
|
||||
isLastMessageOutgoing.postValue(false)
|
||||
dateTime.postValue("")
|
||||
Log.w("$TAG No last message to display for conversation [$id]")
|
||||
}
|
||||
}
|
||||
|
|
@ -348,18 +401,6 @@ class ConversationModel
|
|||
@WorkerThread
|
||||
private fun updateLastUpdatedTime() {
|
||||
val timestamp = chatRoom.lastUpdateTime
|
||||
val humanReadableTimestamp = when {
|
||||
TimestampUtils.isToday(timestamp) -> {
|
||||
TimestampUtils.timeToString(chatRoom.lastUpdateTime)
|
||||
}
|
||||
TimestampUtils.isYesterday(timestamp) -> {
|
||||
AppUtils.getString(R.string.yesterday)
|
||||
}
|
||||
else -> {
|
||||
TimestampUtils.toString(chatRoom.lastUpdateTime, onlyDate = true)
|
||||
}
|
||||
}
|
||||
dateTime.postValue(humanReadableTimestamp)
|
||||
lastUpdateTime.postValue(timestamp)
|
||||
}
|
||||
|
||||
|
|
@ -392,16 +433,20 @@ class ConversationModel
|
|||
}
|
||||
|
||||
if (isGroup) {
|
||||
val fakeFriend = coreContext.core.createFriend()
|
||||
fakeFriend.name = chatRoom.subject
|
||||
val model = ContactAvatarModel(fakeFriend)
|
||||
model.defaultToConversationIcon.postValue(true)
|
||||
model.updateSecurityLevelUsingConversation(chatRoom)
|
||||
avatarModel.postValue(model)
|
||||
if (avatarModel.value == null || avatarModel.value?.contactName != chatRoom.subject) {
|
||||
val fakeFriend = coreContext.core.createFriend()
|
||||
fakeFriend.name = chatRoom.subject
|
||||
val model = ContactAvatarModel(fakeFriend)
|
||||
model.defaultToConversationIcon.postValue(true)
|
||||
model.updateSecurityLevelUsingConversation(chatRoom)
|
||||
avatarModel.postValue(model)
|
||||
}
|
||||
} else {
|
||||
avatarModel.postValue(
|
||||
coreContext.contactsManager.getContactAvatarModelForAddress(address)
|
||||
)
|
||||
val model = coreContext.contactsManager.getContactAvatarModelForAddress(address)
|
||||
val oldModel = avatarModel.value
|
||||
if (!model.compare(oldModel)) {
|
||||
avatarModel.postValue(model)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -409,30 +454,10 @@ class ConversationModel
|
|||
private fun computeComposingLabel() {
|
||||
val composing = chatRoom.isRemoteComposing
|
||||
isComposing.postValue(composing)
|
||||
if (!composing) {
|
||||
composingLabel.postValue("")
|
||||
return
|
||||
}
|
||||
|
||||
val composingFriends = arrayListOf<String>()
|
||||
var label = ""
|
||||
for (address in chatRoom.composingAddresses) {
|
||||
val avatar = coreContext.contactsManager.getContactAvatarModelForAddress(address)
|
||||
val name = avatar.name.value ?: LinphoneUtils.getDisplayName(address)
|
||||
composingFriends.add(name)
|
||||
label += "$name, "
|
||||
}
|
||||
if (composingFriends.isNotEmpty()) {
|
||||
label = label.dropLast(2)
|
||||
|
||||
val format = AppUtils.getStringWithPlural(
|
||||
R.plurals.conversation_composing_label,
|
||||
composingFriends.size,
|
||||
label
|
||||
)
|
||||
composingLabel.postValue(format)
|
||||
} else {
|
||||
composingLabel.postValue("")
|
||||
}
|
||||
val pair = LinphoneUtils.getComposingIconAndText(chatRoom)
|
||||
val icon = pair.first
|
||||
composingIcon.postValue(icon)
|
||||
val label = pair.second
|
||||
composingLabel.postValue(label)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,10 +20,7 @@
|
|||
package org.linphone.ui.main.chat.model
|
||||
|
||||
import androidx.annotation.WorkerThread
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
import org.linphone.core.EventLog
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.utils.LinphoneUtils
|
||||
|
||||
class EventLogModel
|
||||
@WorkerThread
|
||||
|
|
@ -34,6 +31,7 @@ class EventLogModel
|
|||
isGroupedWithNextOne: Boolean = false,
|
||||
currentFilter: String = "",
|
||||
onContentClicked: ((fileModel: FileModel) -> Unit)? = null,
|
||||
onSipUriClicked: ((uri: String) -> Unit)? = null,
|
||||
onJoinConferenceClicked: ((uri: String) -> Unit)? = null,
|
||||
onWebUrlClicked: ((url: String) -> Unit)? = null,
|
||||
onContactClicked: ((friendRefKey: String) -> Unit)? = null,
|
||||
|
|
@ -53,39 +51,14 @@ class EventLogModel
|
|||
EventModel(eventLog)
|
||||
} else {
|
||||
val chatMessage = eventLog.chatMessage!!
|
||||
var replyTo = ""
|
||||
var isReply = chatMessage.isReply
|
||||
val replyText = if (chatMessage.isReply) {
|
||||
val replyMessage = chatMessage.replyMessage
|
||||
if (replyMessage != null) {
|
||||
val from = replyMessage.fromAddress
|
||||
val avatarModel = coreContext.contactsManager.getContactAvatarModelForAddress(from)
|
||||
replyTo = avatarModel.contactName ?: LinphoneUtils.getDisplayName(from)
|
||||
|
||||
LinphoneUtils.getPlainTextDescribingMessage(replyMessage)
|
||||
} else {
|
||||
Log.e(
|
||||
"$TAG Failed to find the reply message from ID [${chatMessage.replyMessageId}]"
|
||||
)
|
||||
isReply = false
|
||||
""
|
||||
}
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
MessageModel(
|
||||
chatMessage,
|
||||
isFromGroup,
|
||||
isReply,
|
||||
replyTo,
|
||||
replyText,
|
||||
chatMessage.replyMessageId,
|
||||
chatMessage.isForward,
|
||||
isGroupedWithPreviousOne,
|
||||
isGroupedWithNextOne,
|
||||
currentFilter,
|
||||
onContentClicked,
|
||||
onSipUriClicked,
|
||||
onJoinConferenceClicked,
|
||||
onWebUrlClicked,
|
||||
onContactClicked,
|
||||
|
|
|
|||
|
|
@ -116,23 +116,32 @@ class EventModel
|
|||
EventLog.Type.ConferenceEphemeralMessageLifetimeChanged -> {
|
||||
R.drawable.clock_countdown
|
||||
}
|
||||
EventLog.Type.ConferenceTerminated,
|
||||
EventLog.Type.ConferenceSecurityEvent -> {
|
||||
R.drawable.warning_circle
|
||||
}
|
||||
EventLog.Type.ConferenceSubjectChanged -> {
|
||||
R.drawable.pencil_simple
|
||||
}
|
||||
EventLog.Type.ConferenceCreated,
|
||||
EventLog.Type.ConferenceParticipantAdded,
|
||||
EventLog.Type.ConferenceCreated -> {
|
||||
R.drawable.door_open
|
||||
}
|
||||
EventLog.Type.ConferenceParticipantRemoved,
|
||||
EventLog.Type.ConferenceParticipantDeviceAdded,
|
||||
EventLog.Type.ConferenceParticipantDeviceRemoved -> {
|
||||
EventLog.Type.ConferenceTerminated -> {
|
||||
R.drawable.door
|
||||
}
|
||||
EventLog.Type.ConferenceParticipantDeviceAdded -> {
|
||||
R.drawable.user_circle_plus
|
||||
}
|
||||
EventLog.Type.ConferenceParticipantDeviceRemoved -> {
|
||||
R.drawable.user_circle_minus
|
||||
}
|
||||
EventLog.Type.ConferenceParticipantSetAdmin -> {
|
||||
R.drawable.user_circle_check
|
||||
}
|
||||
EventLog.Type.ConferenceParticipantUnsetAdmin -> {
|
||||
R.drawable.user_circle_dashed
|
||||
}
|
||||
else -> R.drawable.user_circle
|
||||
},
|
||||
coreContext.context.theme
|
||||
|
|
|
|||
|
|
@ -19,9 +19,11 @@
|
|||
*/
|
||||
package org.linphone.ui.main.chat.model
|
||||
|
||||
import android.graphics.pdf.PdfRenderer
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.media.MediaMetadataRetriever.METADATA_KEY_DURATION
|
||||
import android.media.ThumbnailUtils
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.provider.MediaStore
|
||||
import androidx.annotation.AnyThread
|
||||
import androidx.annotation.UiThread
|
||||
|
|
@ -35,6 +37,10 @@ import org.linphone.core.tools.Log
|
|||
import org.linphone.utils.FileUtils
|
||||
import org.linphone.utils.TimestampUtils
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.graphics.createBitmap
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.linphone.utils.FileUtils.Companion.getFileStorageCacheDir
|
||||
import java.io.File
|
||||
|
||||
class FileModel
|
||||
@AnyThread
|
||||
|
|
@ -91,13 +97,15 @@ class FileModel
|
|||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
init {
|
||||
mediaPreviewAvailable.postValue(false)
|
||||
updateTransferProgress(-1)
|
||||
formattedFileSize.postValue(FileUtils.bytesToDisplayableSize(fileSize))
|
||||
computeFileSize(fileSize)
|
||||
|
||||
if (!isWaitingToBeDownloaded) {
|
||||
val extension = FileUtils.getExtensionFromFileName(path)
|
||||
isPdf = extension == "pdf"
|
||||
if (isPdf) {
|
||||
loadPdfPreview()
|
||||
}
|
||||
|
||||
val mime = FileUtils.getMimeTypeFromExtension(extension)
|
||||
mimeTypeString = mime
|
||||
|
|
@ -142,6 +150,11 @@ class FileModel
|
|||
}
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
fun computeFileSize(fileSize: Long) {
|
||||
formattedFileSize.postValue(FileUtils.bytesToDisplayableSize(fileSize))
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
fun updateTransferProgress(percent: Int) {
|
||||
transferProgress.postValue(percent)
|
||||
|
|
@ -164,21 +177,67 @@ class FileModel
|
|||
}
|
||||
|
||||
@AnyThread
|
||||
private fun loadVideoPreview() {
|
||||
try {
|
||||
Log.i("$TAG Try to create an image preview of video file [$path]")
|
||||
val previewBitmap = ThumbnailUtils.createVideoThumbnail(
|
||||
path,
|
||||
MediaStore.Images.Thumbnails.MINI_KIND
|
||||
)
|
||||
if (previewBitmap != null) {
|
||||
val previewPath = FileUtils.storeBitmap(previewBitmap, fileName)
|
||||
Log.i("$TAG Preview of video file [$path] available at [$previewPath]")
|
||||
mediaPreview.postValue(previewPath)
|
||||
mediaPreviewAvailable.postValue(true)
|
||||
private fun loadPdfPreview() {
|
||||
scope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val pdfFileDescriptor = ParcelFileDescriptor.open(
|
||||
File(path),
|
||||
ParcelFileDescriptor.MODE_READ_ONLY
|
||||
)
|
||||
if (pdfFileDescriptor == null) {
|
||||
Log.e("$TAG Failed to get a file descriptor for PDF at [$path]")
|
||||
return@withContext
|
||||
}
|
||||
|
||||
val pdfRenderer = PdfRenderer(pdfFileDescriptor)
|
||||
val pdfFirstPage = pdfRenderer.openPage(0)
|
||||
val previewBitmap = createBitmap(pdfFirstPage.width, pdfFirstPage.height)
|
||||
pdfFirstPage.render(
|
||||
previewBitmap,
|
||||
null,
|
||||
null,
|
||||
PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY
|
||||
)
|
||||
|
||||
val file = getFileStorageCacheDir("$fileName.jpg", true)
|
||||
val previewPath = FileUtils.storeBitmap(previewBitmap, file)
|
||||
Log.i("$TAG Preview of PDF file [$path] available at [$previewPath]")
|
||||
mediaPreview.postValue(previewPath)
|
||||
mediaPreviewAvailable.postValue(true)
|
||||
|
||||
previewBitmap.recycle()
|
||||
pdfFirstPage.close()
|
||||
pdfRenderer.close()
|
||||
pdfFileDescriptor.close()
|
||||
} catch (e: Exception) {
|
||||
Log.e("$TAG Failed to get image preview for PDF file [$path]: $e")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
private fun loadVideoPreview() {
|
||||
scope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
Log.i("$TAG Try to create an image preview of video file [$path]")
|
||||
val previewBitmap = ThumbnailUtils.createVideoThumbnail(
|
||||
path,
|
||||
MediaStore.Images.Thumbnails.MINI_KIND
|
||||
)
|
||||
if (previewBitmap != null) {
|
||||
val file = getFileStorageCacheDir("$fileName.jpg", true)
|
||||
val previewPath = FileUtils.storeBitmap(previewBitmap, file)
|
||||
Log.i("$TAG Preview of video file [$path] available at [$previewPath]")
|
||||
mediaPreview.postValue(previewPath)
|
||||
mediaPreviewAvailable.postValue(true)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("$TAG Failed to get image preview for file [$path]: $e")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("$TAG Failed to get image preview for file [$path]: $e")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,16 +46,11 @@ class MessageBottomSheetParticipantModel
|
|||
}
|
||||
|
||||
@UiThread
|
||||
fun toggleShowSipUri() {
|
||||
if (!isOurOwnReaction && !corePreferences.onlyDisplaySipUriUsername) {
|
||||
fun clicked() {
|
||||
if (!isOurOwnReaction && !corePreferences.onlyDisplaySipUriUsername && !corePreferences.hideSipAddresses) {
|
||||
showSipUri.postValue(showSipUri.value == false)
|
||||
} else {
|
||||
clicked()
|
||||
onClick?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun clicked() {
|
||||
onClick?.invoke()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
package org.linphone.ui.main.chat.model
|
||||
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import org.linphone.utils.Event
|
||||
|
||||
class MessageDeleteDialogModel(val canBeRetracted: Boolean) {
|
||||
val dismissEvent = MutableLiveData<Event<Boolean>>()
|
||||
|
||||
val cancelEvent = MutableLiveData<Event<Boolean>>()
|
||||
|
||||
val deleteLocallyEvent = MutableLiveData<Event<Boolean>>()
|
||||
|
||||
val deleteForEveryoneEvent = MutableLiveData<Event<Boolean>>()
|
||||
|
||||
@UiThread
|
||||
fun dismiss() {
|
||||
dismissEvent.value = Event(true)
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun cancel() {
|
||||
cancelEvent.value = Event(true)
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun deleteLocally() {
|
||||
deleteLocallyEvent.value = Event(true)
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun deleteForEveryone() {
|
||||
deleteForEveryoneEvent.value = Event(true)
|
||||
}
|
||||
}
|
||||
|
|
@ -51,11 +51,11 @@ class MessageDeliveryModel
|
|||
|
||||
val displayedModels = arrayListOf<MessageBottomSheetParticipantModel>()
|
||||
|
||||
private val deliveredModels = arrayListOf<MessageBottomSheetParticipantModel>()
|
||||
val deliveredModels = arrayListOf<MessageBottomSheetParticipantModel>()
|
||||
|
||||
private val sentModels = arrayListOf<MessageBottomSheetParticipantModel>()
|
||||
val sentModels = arrayListOf<MessageBottomSheetParticipantModel>()
|
||||
|
||||
private val errorModels = arrayListOf<MessageBottomSheetParticipantModel>()
|
||||
val errorModels = arrayListOf<MessageBottomSheetParticipantModel>()
|
||||
|
||||
private val chatMessageListener = object : ChatMessageListenerStub() {
|
||||
@WorkerThread
|
||||
|
|
@ -63,7 +63,7 @@ class MessageDeliveryModel
|
|||
message: ChatMessage,
|
||||
state: ParticipantImdnState
|
||||
) {
|
||||
Log.i("$TAG Participant IMDN state changed [${state.state}], updating delivery status")
|
||||
Log.i("$TAG Participant IMDN state changed [${state.state}] for message with ID [${message.messageId}], updating delivery status")
|
||||
computeDeliveryStatus()
|
||||
}
|
||||
}
|
||||
|
|
@ -79,7 +79,7 @@ class MessageDeliveryModel
|
|||
}
|
||||
|
||||
@UiThread
|
||||
fun computeListForState(state: State): ArrayList<MessageBottomSheetParticipantModel> {
|
||||
fun getListForState(state: State): ArrayList<MessageBottomSheetParticipantModel> {
|
||||
return when (state) {
|
||||
State.DeliveredToUser -> {
|
||||
deliveredModels
|
||||
|
|
@ -98,6 +98,8 @@ class MessageDeliveryModel
|
|||
|
||||
@WorkerThread
|
||||
private fun computeDeliveryStatus() {
|
||||
Log.i("$TAG Message ID [${chatMessage.messageId}] is in state [${chatMessage.state}]")
|
||||
|
||||
displayedModels.clear()
|
||||
deliveredModels.clear()
|
||||
sentModels.clear()
|
||||
|
|
@ -175,12 +177,15 @@ class MessageDeliveryModel
|
|||
)
|
||||
)
|
||||
|
||||
if (displayedModels.isEmpty() && deliveredModels.isEmpty() && sentModels.isEmpty() && errorModels.isEmpty()) {
|
||||
Log.e("$TAG No participant found in state Displayed, DeliveredToUser, Delivered or Error for message ID [${chatMessage.messageId}]")
|
||||
}
|
||||
|
||||
displayedModels.sortBy { it.timestamp }
|
||||
deliveredModels.sortBy { it.timestamp }
|
||||
sentModels.sortBy { it.timestamp }
|
||||
errorModels.sortBy { it.timestamp }
|
||||
|
||||
Log.i("$TAG Message ID [${chatMessage.messageId}] is in state [${chatMessage.state}]")
|
||||
Log.i(
|
||||
"$TAG There are [$readCount] that have read this message, [$receivedCount] that have received it, [$sentCount] that haven't received it yet and [$errorCount] that probably won't receive it due to an error"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -24,7 +24,9 @@ import android.os.CountDownTimer
|
|||
import android.text.Spannable
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.Spanned
|
||||
import android.text.style.ForegroundColorSpan
|
||||
import android.text.style.StyleSpan
|
||||
import androidx.annotation.AnyThread
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.lifecycle.MediatorLiveData
|
||||
|
|
@ -70,15 +72,11 @@ class MessageModel
|
|||
constructor(
|
||||
val chatMessage: ChatMessage,
|
||||
val isFromGroup: Boolean,
|
||||
val isReply: Boolean,
|
||||
val replyTo: String,
|
||||
val replyText: String,
|
||||
val replyToMessageId: String?,
|
||||
val isForward: Boolean,
|
||||
isGroupedWithPreviousOne: Boolean,
|
||||
isGroupedWithNextOne: Boolean,
|
||||
private val currentFilter: String = "",
|
||||
private val onContentClicked: ((fileModel: FileModel) -> Unit)? = null,
|
||||
private val onSipUriClicked: ((uri: String) -> Unit)? = null,
|
||||
private val onJoinConferenceClicked: ((uri: String) -> Unit)? = null,
|
||||
private val onWebUrlClicked: ((url: String) -> Unit)? = null,
|
||||
private val onContactClicked: ((friendRefKey: String) -> Unit)? = null,
|
||||
|
|
@ -115,6 +113,16 @@ class MessageModel
|
|||
)?.params?.instantMessagingEncryptionMandatory == true
|
||||
)
|
||||
|
||||
val isReply = MutableLiveData<Boolean>()
|
||||
|
||||
val replyToMessageId = chatMessage.replyMessageId
|
||||
|
||||
val isForward = chatMessage.isForward
|
||||
|
||||
val replyTo = MutableLiveData<String>()
|
||||
|
||||
val replyText = MutableLiveData<Spannable>()
|
||||
|
||||
val avatarModel = MutableLiveData<ContactAvatarModel>()
|
||||
|
||||
val groupedWithNextMessage = MutableLiveData<Boolean>()
|
||||
|
|
@ -129,6 +137,8 @@ class MessageModel
|
|||
|
||||
val text = MutableLiveData<Spannable>()
|
||||
|
||||
val isTextEmoji = MutableLiveData<Boolean>()
|
||||
|
||||
val reactions = MutableLiveData<String>()
|
||||
|
||||
val ourReactionIndex = MutableLiveData<Int>()
|
||||
|
|
@ -137,8 +147,14 @@ class MessageModel
|
|||
|
||||
val firstFileModel = MediatorLiveData<FileModel>()
|
||||
|
||||
val hasBeenEdited = MutableLiveData<Boolean>()
|
||||
|
||||
val hasBeenRetracted = MutableLiveData<Boolean>()
|
||||
|
||||
val isSelected = MutableLiveData<Boolean>()
|
||||
|
||||
private var rawTextContent: String = ""
|
||||
|
||||
// Below are for conferences info
|
||||
val meetingFound = MutableLiveData<Boolean>()
|
||||
|
||||
|
|
@ -204,27 +220,13 @@ class MessageModel
|
|||
private val chatMessageListener = object : ChatMessageListenerStub() {
|
||||
@WorkerThread
|
||||
override fun onMsgStateChanged(message: ChatMessage, messageState: ChatMessage.State?) {
|
||||
Log.i("$TAG Chat message [${message.messageId}] state changed to [$messageState]")
|
||||
if (messageState != ChatMessage.State.FileTransferDone && messageState != ChatMessage.State.FileTransferInProgress) {
|
||||
statusIcon.postValue(LinphoneUtils.getChatIconResId(chatMessage.state))
|
||||
|
||||
if (messageState == ChatMessage.State.Displayed) {
|
||||
isRead = chatMessage.isRead
|
||||
}
|
||||
} else if (messageState == ChatMessage.State.FileTransferDone) {
|
||||
Log.i("$TAG File transfer is done")
|
||||
transferringFileModel?.updateTransferProgress(-1)
|
||||
transferringFileModel = null
|
||||
if (!allFilesDownloaded) {
|
||||
computeContentsList()
|
||||
}
|
||||
|
||||
for (content in message.contents) {
|
||||
if (content.isVoiceRecording) {
|
||||
Log.i("$TAG File transfer done, updating voice record info")
|
||||
computeVoiceRecordContent(content)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
isInError.postValue(messageState == ChatMessage.State.NotDelivered)
|
||||
}
|
||||
|
|
@ -232,22 +234,7 @@ class MessageModel
|
|||
@WorkerThread
|
||||
override fun onFileTransferTerminated(message: ChatMessage, content: Content) {
|
||||
Log.i("$TAG File [${content.name}] from message [${message.messageId}] transfer terminated")
|
||||
|
||||
// Never do auto media export for ephemeral messages!
|
||||
if (corePreferences.makePublicMediaFilesDownloaded && !message.isEphemeral) {
|
||||
val path = content.filePath
|
||||
if (path.isNullOrEmpty()) return
|
||||
|
||||
val mime = "${content.type}/${content.subtype}"
|
||||
val mimeType = FileUtils.getMimeType(mime)
|
||||
when (mimeType) {
|
||||
FileUtils.MimeType.Image, FileUtils.MimeType.Video, FileUtils.MimeType.Audio -> {
|
||||
Log.i("$TAG Exporting file path [$path] to the native media gallery")
|
||||
onFileToExportToNativeGallery?.invoke(path)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
fileTransferTerminated(message, content)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
|
|
@ -295,6 +282,21 @@ class MessageModel
|
|||
Log.d("$TAG Ephemeral timer started")
|
||||
updateEphemeralTimer()
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun onContentEdited(message: ChatMessage) {
|
||||
Log.i("$TAG Message [${message.messageId}] has been edited")
|
||||
hasBeenEdited.postValue(true)
|
||||
computeContentsList()
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun onRetracted(message: ChatMessage) {
|
||||
Log.i("$TAG Content(s) of the message have been deleted by it's sender")
|
||||
hasBeenEdited.postValue(false)
|
||||
hasBeenRetracted.postValue(true)
|
||||
computeContentsList()
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
|
|
@ -312,7 +314,15 @@ class MessageModel
|
|||
statusIcon.postValue(LinphoneUtils.getChatIconResId(chatMessage.state))
|
||||
updateReactionsList()
|
||||
|
||||
hasBeenEdited.postValue(chatMessage.isEdited && !chatMessage.isRetracted)
|
||||
hasBeenRetracted.postValue(chatMessage.isRetracted)
|
||||
computeContentsList()
|
||||
if (chatMessage.isReply) {
|
||||
// Wait to see if original message is found before setting isReply to true
|
||||
computeReplyInfo()
|
||||
} else {
|
||||
isReply.postValue(false)
|
||||
}
|
||||
|
||||
coreContext.postOnMainThread {
|
||||
firstFileModel.addSource(filesList) {
|
||||
|
|
@ -401,23 +411,29 @@ class MessageModel
|
|||
avatarModel.postValue(avatar)
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
fun getRawTextContent(): String {
|
||||
return rawTextContent
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun computeContentsList() {
|
||||
Log.d("$TAG Computing message contents list")
|
||||
text.postValue(Spannable.Factory.getInstance().newSpannable(""))
|
||||
filesList.postValue(arrayListOf())
|
||||
filesList.value.orEmpty().forEach(FileModel::destroy)
|
||||
|
||||
if (chatMessage.isRetracted) {
|
||||
meetingFound.postValue(false)
|
||||
isVoiceRecord.postValue(false)
|
||||
isTextEmoji.postValue(false)
|
||||
}
|
||||
|
||||
var displayableContentFound = false
|
||||
var filesContentCount = 0
|
||||
var contentIndex = 0
|
||||
val filesPath = arrayListOf<FileModel>()
|
||||
|
||||
val contents = chatMessage.contents
|
||||
allFilesDownloaded = true
|
||||
|
||||
val notMediaContent = contents.find {
|
||||
it.isIcalendar || it.isVoiceRecording || (it.isText && !it.isFile) || it.isFileTransfer || (it.isFile && !(it.type == "video" || it.type == "image"))
|
||||
}
|
||||
val allContentsAreMedia = notMediaContent == null
|
||||
val exactly4Contents = contents.size == 4
|
||||
|
||||
for (content in contents) {
|
||||
|
|
@ -440,9 +456,14 @@ class MessageModel
|
|||
|
||||
displayableContentFound = true
|
||||
} else {
|
||||
val wrapBefore = if (exactly4Contents) {
|
||||
contentIndex == 2 // To have a 2x2 grid
|
||||
} else {
|
||||
contentIndex % 3 == 0 // To have at most 3 columns
|
||||
}
|
||||
if (content.isFile) {
|
||||
Log.d("$TAG Found file content with type [${content.type}/${content.subtype}]")
|
||||
filesContentCount += 1
|
||||
contentIndex += 1
|
||||
|
||||
checkAndRepairFilePathIfNeeded(content)
|
||||
|
||||
|
|
@ -460,9 +481,11 @@ class MessageModel
|
|||
Log.d(
|
||||
"$TAG Found file ready to be displayed [$path] with MIME [${content.type}/${content.subtype}] for message [${chatMessage.messageId}]"
|
||||
)
|
||||
|
||||
val wrapBefore = allContentsAreMedia && exactly4Contents && filesContentCount == 3
|
||||
val fileSize = content.fileSize.toLong()
|
||||
val fileSize = if (content.fileSize.toLong() > 0) {
|
||||
content.fileSize.toLong()
|
||||
} else {
|
||||
FileUtils.getFileSize(path)
|
||||
}
|
||||
val timestamp = content.creationTimestamp
|
||||
val fileModel = FileModel(
|
||||
path,
|
||||
|
|
@ -487,20 +510,26 @@ class MessageModel
|
|||
"$TAG Found file content (not downloaded yet) with type [${content.type}/${content.subtype}] and name [${content.name}]"
|
||||
)
|
||||
allFilesDownloaded = false
|
||||
filesContentCount += 1
|
||||
contentIndex += 1
|
||||
val name = content.name ?: ""
|
||||
val timestamp = content.creationTimestamp
|
||||
if (name.isNotEmpty()) {
|
||||
val fileModel = if (isOutgoing && chatMessage.isFileTransferInProgress) {
|
||||
val path = content.filePath.orEmpty()
|
||||
val fileSize = if (content.fileSize.toLong() > 0) {
|
||||
content.fileSize.toLong()
|
||||
} else {
|
||||
FileUtils.getFileSize(path)
|
||||
}
|
||||
FileModel(
|
||||
path,
|
||||
name,
|
||||
content.fileSize.toLong(),
|
||||
fileSize,
|
||||
timestamp,
|
||||
isFileEncrypted,
|
||||
path,
|
||||
chatMessage.isEphemeral
|
||||
chatMessage.isEphemeral,
|
||||
flexboxLayoutWrapBefore = wrapBefore
|
||||
) { model ->
|
||||
onContentClicked?.invoke(model)
|
||||
}
|
||||
|
|
@ -513,7 +542,8 @@ class MessageModel
|
|||
isFileEncrypted,
|
||||
name,
|
||||
chatMessage.isEphemeral,
|
||||
isWaitingToBeDownloaded = true
|
||||
isWaitingToBeDownloaded = true,
|
||||
flexboxLayoutWrapBefore = wrapBefore
|
||||
) { model ->
|
||||
downloadContent(model, content)
|
||||
}
|
||||
|
|
@ -543,7 +573,7 @@ class MessageModel
|
|||
|
||||
@WorkerThread
|
||||
private fun downloadContent(model: FileModel, content: Content) {
|
||||
Log.d("$TAG Starting downloading content for file [${model.fileName}]")
|
||||
Log.i("$TAG Start downloading content for file [${model.fileName}]")
|
||||
|
||||
if (content.filePath.orEmpty().isEmpty()) {
|
||||
val contentName = content.name
|
||||
|
|
@ -617,35 +647,44 @@ class MessageModel
|
|||
if (textContent != null) {
|
||||
computeTextContent(textContent, highlight)
|
||||
}
|
||||
isSelected.postValue(highlight.isNotEmpty())
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun computeReplyInfo() {
|
||||
val replyMessage = chatMessage.replyMessage
|
||||
if (replyMessage != null) {
|
||||
val from = replyMessage.fromAddress
|
||||
val avatarModel = coreContext.contactsManager.getContactAvatarModelForAddress(from)
|
||||
replyTo.postValue(avatarModel.contactName ?: LinphoneUtils.getDisplayName(from))
|
||||
replyText.postValue(LinphoneUtils.getFormattedTextDescribingMessage(replyMessage))
|
||||
isReply.postValue(true)
|
||||
} else {
|
||||
Log.w("$TAG Failed to find the reply message from ID [${chatMessage.replyMessageId}]")
|
||||
isReply.postValue(false)
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun computeTextContent(content: Content, highlight: String) {
|
||||
val textContent = content.utf8Text.orEmpty().trim()
|
||||
val spannableBuilder = SpannableStringBuilder(textContent)
|
||||
rawTextContent = content.utf8Text.orEmpty().trim()
|
||||
val spannableBuilder = SpannableStringBuilder(rawTextContent)
|
||||
|
||||
// Check for search
|
||||
if (highlight.isNotEmpty()) {
|
||||
val indexStart = textContent.indexOf(highlight, 0, ignoreCase = true)
|
||||
if (indexStart >= 0) {
|
||||
isTextHighlighted = true
|
||||
val indexEnd = indexStart + highlight.length
|
||||
spannableBuilder.setSpan(
|
||||
StyleSpan(Typeface.BOLD),
|
||||
indexStart,
|
||||
indexEnd,
|
||||
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
}
|
||||
val emojiOnly = AppUtils.isTextOnlyContainsEmoji(rawTextContent)
|
||||
isTextEmoji.postValue(emojiOnly)
|
||||
if (emojiOnly) {
|
||||
text.postValue(spannableBuilder)
|
||||
return
|
||||
}
|
||||
|
||||
// Check for mentions
|
||||
val chatRoom = chatMessage.chatRoom
|
||||
val matcher = Pattern.compile(MENTION_REGEXP).matcher(textContent)
|
||||
val matcher = Pattern.compile(MENTION_REGEXP).matcher(rawTextContent)
|
||||
var offset = 0
|
||||
while (matcher.find()) {
|
||||
val start = matcher.start()
|
||||
val end = matcher.end()
|
||||
val source = textContent.subSequence(start + 1, end) // +1 to remove @
|
||||
val source = rawTextContent.subSequence(start + 1, end) // +1 to remove @
|
||||
Log.d("$TAG Found mention [$source]")
|
||||
|
||||
// Find address matching username
|
||||
|
|
@ -667,14 +706,14 @@ class MessageModel
|
|||
)
|
||||
val friend = avatarModel.friend
|
||||
val displayName = friend.name ?: LinphoneUtils.getDisplayName(address)
|
||||
Log.d(
|
||||
"$TAG Using display name [$displayName] instead of username [$source]"
|
||||
Log.i(
|
||||
"$TAG Using display name [$displayName] instead of mention username [$source]"
|
||||
)
|
||||
|
||||
spannableBuilder.replace(start, end, "@$displayName")
|
||||
spannableBuilder.replace(start + offset, end + offset, "@$displayName")
|
||||
val span = PatternClickableSpan.StyledClickableSpan(
|
||||
object :
|
||||
SpannableClickedListener {
|
||||
object : SpannableClickedListener {
|
||||
@UiThread
|
||||
override fun onSpanClicked(text: String) {
|
||||
val friendRefKey = friend.refKey ?: ""
|
||||
Log.i(
|
||||
|
|
@ -688,10 +727,18 @@ class MessageModel
|
|||
)
|
||||
spannableBuilder.setSpan(
|
||||
span,
|
||||
start,
|
||||
start + displayName.length + 1,
|
||||
start + offset,
|
||||
start + offset + displayName.length + 1,
|
||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
// Change color
|
||||
spannableBuilder.setSpan(
|
||||
ForegroundColorSpan(AppUtils.getColorInt(R.color.orange_main_500)),
|
||||
start + offset,
|
||||
start + offset + displayName.length + 1,
|
||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
offset += displayName.length - source.length
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -707,12 +754,7 @@ class MessageModel
|
|||
override fun onSpanClicked(text: String) {
|
||||
coreContext.postOnCoreThread {
|
||||
Log.i("$TAG Clicked on SIP URI: $text")
|
||||
val address = coreContext.core.interpretUrl(text, false)
|
||||
if (address != null) {
|
||||
coreContext.startAudioCall(address)
|
||||
} else {
|
||||
Log.w("$TAG Failed to parse [$text] as SIP URI")
|
||||
}
|
||||
onSipUriClicked?.invoke(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -722,6 +764,7 @@ class MessageModel
|
|||
HTTP_LINK_REGEXP
|
||||
),
|
||||
object : SpannableClickedListener {
|
||||
@UiThread
|
||||
override fun onSpanClicked(text: String) {
|
||||
Log.i("$TAG Clicked on web URL: $text")
|
||||
onWebUrlClicked?.invoke(text)
|
||||
|
|
@ -730,6 +773,21 @@ class MessageModel
|
|||
)
|
||||
.build(spannableBuilder)
|
||||
)
|
||||
|
||||
// Check for search
|
||||
if (highlight.isNotEmpty()) {
|
||||
val indexStart = rawTextContent.indexOf(highlight, 0, ignoreCase = true)
|
||||
if (indexStart >= 0) {
|
||||
isTextHighlighted = true
|
||||
val indexEnd = indexStart + highlight.length
|
||||
spannableBuilder.setSpan(
|
||||
StyleSpan(Typeface.BOLD),
|
||||
indexStart,
|
||||
indexEnd,
|
||||
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
|
|
@ -989,4 +1047,37 @@ class MessageModel
|
|||
"$TAG Found voice record with path [$voiceRecordPath] and duration [$formattedDuration]"
|
||||
)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun fileTransferTerminated(message: ChatMessage, content: Content) {
|
||||
// Never do auto media export for ephemeral messages!
|
||||
if (corePreferences.makePublicMediaFilesDownloaded && !message.isEphemeral) {
|
||||
val path = content.filePath
|
||||
if (path.isNullOrEmpty()) return
|
||||
|
||||
val mime = "${content.type}/${content.subtype}"
|
||||
val mimeType = FileUtils.getMimeType(mime)
|
||||
when (mimeType) {
|
||||
FileUtils.MimeType.Image, FileUtils.MimeType.Video, FileUtils.MimeType.Audio -> {
|
||||
Log.i("$TAG Exporting file path [$path] to the native media gallery")
|
||||
onFileToExportToNativeGallery?.invoke(path)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
transferringFileModel?.updateTransferProgress(-1)
|
||||
transferringFileModel = null
|
||||
if (!allFilesDownloaded) {
|
||||
computeContentsList()
|
||||
} else {
|
||||
for (content in message.contents) {
|
||||
if (content.isVoiceRecording) {
|
||||
Log.i("$TAG File transfer done, updating voice record info")
|
||||
computeVoiceRecordContent(content)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,9 +38,9 @@ class MessageReactionsModel
|
|||
private const val TAG = "[Message Reactions Model]"
|
||||
}
|
||||
|
||||
val allReactions = arrayListOf<MessageBottomSheetParticipantModel>()
|
||||
val allReactions = MutableLiveData<ArrayList<MessageBottomSheetParticipantModel>>()
|
||||
|
||||
val differentReactions = MutableLiveData<ArrayList<String>>()
|
||||
val differentReactions = arrayListOf<String>()
|
||||
|
||||
val reactionsMap = HashMap<String, Int>()
|
||||
|
||||
|
|
@ -71,7 +71,7 @@ class MessageReactionsModel
|
|||
fun filterReactions(emoji: String): ArrayList<MessageBottomSheetParticipantModel> {
|
||||
val filteredList = arrayListOf<MessageBottomSheetParticipantModel>()
|
||||
|
||||
for (reaction in allReactions) {
|
||||
for (reaction in allReactions.value.orEmpty()) {
|
||||
if (reaction.value == emoji) {
|
||||
filteredList.add(reaction)
|
||||
}
|
||||
|
|
@ -83,16 +83,17 @@ class MessageReactionsModel
|
|||
@WorkerThread
|
||||
private fun computeReactions() {
|
||||
reactionsMap.clear()
|
||||
allReactions.clear()
|
||||
differentReactions.clear()
|
||||
|
||||
val differentReactionsList = arrayListOf<String>()
|
||||
val allReactionsList = arrayListOf<MessageBottomSheetParticipantModel>()
|
||||
for (reaction in chatMessage.reactions) {
|
||||
val body = reaction.body
|
||||
val count = reactionsMap.getOrDefault(body, 0)
|
||||
reactionsMap[body] = count + 1
|
||||
Log.i("$TAG Found reaction with body [$body] (count = ${count + 1}) from [${reaction.fromAddress.asStringUriOnly()}]")
|
||||
|
||||
val isOurOwn = reaction.fromAddress.weakEqual(chatMessage.chatRoom.localAddress)
|
||||
allReactions.add(
|
||||
allReactionsList.add(
|
||||
MessageBottomSheetParticipantModel(
|
||||
reaction.fromAddress,
|
||||
reaction.body,
|
||||
|
|
@ -111,15 +112,15 @@ class MessageReactionsModel
|
|||
}
|
||||
)
|
||||
|
||||
if (!differentReactionsList.contains(body)) {
|
||||
differentReactionsList.add(body)
|
||||
if (!differentReactions.contains(body)) {
|
||||
differentReactions.add(body)
|
||||
}
|
||||
}
|
||||
|
||||
Log.i(
|
||||
"$TAG [${differentReactionsList.size}] reactions found on a total of [${allReactions.size}]"
|
||||
"$TAG [${differentReactions.size}] reactions found on a total of [${allReactionsList.size}]"
|
||||
)
|
||||
differentReactions.postValue(differentReactionsList)
|
||||
allReactions.postValue(allReactionsList)
|
||||
onReactionsUpdated?.invoke(this)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,19 +58,14 @@ class ParticipantModel
|
|||
}
|
||||
|
||||
@UiThread
|
||||
fun toggleShowSipUri() {
|
||||
if (!corePreferences.onlyDisplaySipUriUsername) {
|
||||
fun onClicked() {
|
||||
if (onClicked == null && !corePreferences.onlyDisplaySipUriUsername && !corePreferences.hideSipAddresses) {
|
||||
showSipUri.postValue(showSipUri.value == false)
|
||||
} else {
|
||||
onClicked()
|
||||
onClicked?.invoke(this)
|
||||
}
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun onClicked() {
|
||||
onClicked?.invoke(this)
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun openMenu(view: View) {
|
||||
onMenuClicked?.invoke(view, this)
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ import org.linphone.LinphoneApplication.Companion.coreContext
|
|||
import org.linphone.R
|
||||
import org.linphone.core.Address
|
||||
import org.linphone.core.ChatRoom
|
||||
import org.linphone.core.Conference
|
||||
import org.linphone.core.ConferenceListenerStub
|
||||
import org.linphone.core.MediaDirection
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.ui.GenericViewModel
|
||||
|
|
@ -51,6 +53,23 @@ abstract class AbstractConversationViewModel : GenericViewModel() {
|
|||
|
||||
lateinit var conversationId: String
|
||||
|
||||
private val conferenceListener = object : ConferenceListenerStub() {
|
||||
@WorkerThread
|
||||
override fun onStateChanged(conference: Conference, newState: Conference.State?) {
|
||||
Log.i("$TAG Conference state changed [$newState]")
|
||||
when (newState) {
|
||||
Conference.State.CreationFailed -> {
|
||||
showRedToast(R.string.conference_failed_to_create_group_call_toast, R.drawable.warning_circle)
|
||||
conference.removeListener(this)
|
||||
}
|
||||
Conference.State.Created -> {
|
||||
conference.removeListener(this)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun isChatRoomInitialized(): Boolean {
|
||||
return ::chatRoom.isInitialized
|
||||
}
|
||||
|
|
@ -173,6 +192,8 @@ abstract class AbstractConversationViewModel : GenericViewModel() {
|
|||
if (conference.inviteParticipants(participants, callParams) != 0) {
|
||||
Log.e("$TAG Failed to invite participants into group call!")
|
||||
showRedToast(R.string.conference_failed_to_create_group_call_toast, R.drawable.warning_circle)
|
||||
} else {
|
||||
conference.addListener(conferenceListener)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,9 +19,6 @@
|
|||
*/
|
||||
package org.linphone.ui.main.chat.viewmodel
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
|
|
@ -31,6 +28,7 @@ import org.linphone.core.tools.Log
|
|||
import org.linphone.databinding.ChatBubbleEmojiPickerBottomSheetBinding
|
||||
import org.linphone.ui.GenericViewModel
|
||||
import org.linphone.ui.main.chat.model.MessageModel
|
||||
import org.linphone.utils.AppUtils
|
||||
import org.linphone.utils.Event
|
||||
|
||||
class ChatMessageLongPressViewModel : GenericViewModel() {
|
||||
|
|
@ -48,16 +46,26 @@ class ChatMessageLongPressViewModel : GenericViewModel() {
|
|||
|
||||
val isChatRoomReadOnly = MutableLiveData<Boolean>()
|
||||
|
||||
val canBeEdited = MutableLiveData<Boolean>()
|
||||
|
||||
val canBeRemotelyDeleted = MutableLiveData<Boolean>()
|
||||
|
||||
val messageModel = MutableLiveData<MessageModel>()
|
||||
|
||||
val isMessageOutgoing = MutableLiveData<Boolean>()
|
||||
|
||||
val isMessageInError = MutableLiveData<Boolean>()
|
||||
|
||||
val hasBeenRetracted = MutableLiveData<Boolean>()
|
||||
|
||||
val showImdnInfoEvent: MutableLiveData<Event<Boolean>> by lazy {
|
||||
MutableLiveData<Event<Boolean>>()
|
||||
}
|
||||
|
||||
val editMessageEvent: MutableLiveData<Event<Boolean>> by lazy {
|
||||
MutableLiveData<Event<Boolean>>()
|
||||
}
|
||||
|
||||
val replyToMessageEvent: MutableLiveData<Event<Boolean>> by lazy {
|
||||
MutableLiveData<Event<Boolean>>()
|
||||
}
|
||||
|
|
@ -76,6 +84,8 @@ class ChatMessageLongPressViewModel : GenericViewModel() {
|
|||
|
||||
init {
|
||||
visible.value = false
|
||||
canBeEdited.value = false
|
||||
canBeRemotelyDeleted.value = false
|
||||
}
|
||||
|
||||
@UiThread
|
||||
|
|
@ -92,6 +102,9 @@ class ChatMessageLongPressViewModel : GenericViewModel() {
|
|||
isMessageOutgoing.value = model.isOutgoing
|
||||
isMessageInError.value = model.isInError.value == true
|
||||
horizontalBias.value = if (model.isOutgoing) 1f else 0f
|
||||
canBeEdited.value = model.chatMessage.isEditable
|
||||
canBeRemotelyDeleted.value = model.chatMessage.isRetractable
|
||||
hasBeenRetracted.value = model.hasBeenRetracted.value == true
|
||||
messageModel.value = model
|
||||
|
||||
emojiBottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
|
|
@ -125,13 +138,20 @@ class ChatMessageLongPressViewModel : GenericViewModel() {
|
|||
}
|
||||
|
||||
@UiThread
|
||||
fun copyClickListener() {
|
||||
Log.i("$TAG Copying message text into clipboard")
|
||||
fun edit() {
|
||||
Log.i("$TAG Editing message")
|
||||
editMessageEvent.value = Event(true)
|
||||
dismiss()
|
||||
}
|
||||
|
||||
val text = messageModel.value?.text?.value?.toString()
|
||||
val clipboard = coreContext.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val label = "Message"
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText(label, text))
|
||||
@UiThread
|
||||
fun copyClickListener() {
|
||||
val text = messageModel.value?.getRawTextContent().orEmpty()
|
||||
if (text.isNotEmpty()) {
|
||||
Log.i("$TAG Copying message text into clipboard")
|
||||
val label = "Message"
|
||||
AppUtils.copyToClipboard(coreContext.context, label, text)
|
||||
}
|
||||
|
||||
dismiss()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,16 +22,21 @@ package org.linphone.ui.main.chat.viewmodel
|
|||
import androidx.annotation.UiThread
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
import org.linphone.core.Content
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.ui.main.chat.model.FileModel
|
||||
import org.linphone.utils.Event
|
||||
import org.linphone.utils.LinphoneUtils
|
||||
import kotlin.math.min
|
||||
|
||||
class ConversationDocumentsListViewModel
|
||||
@UiThread
|
||||
constructor() : AbstractConversationViewModel() {
|
||||
companion object {
|
||||
private const val TAG = "[Conversation Documents List ViewModel]"
|
||||
|
||||
private const val CONTENTS_PER_PAGE = 20
|
||||
}
|
||||
|
||||
val documentsList = MutableLiveData<List<FileModel>>()
|
||||
|
|
@ -42,6 +47,8 @@ class ConversationDocumentsListViewModel
|
|||
MutableLiveData<Event<FileModel>>()
|
||||
}
|
||||
|
||||
private var totalDocumentsCount: Int = -1
|
||||
|
||||
@WorkerThread
|
||||
override fun afterNotifyingChatRoomFound(sameOne: Boolean) {
|
||||
loadDocumentsList()
|
||||
|
|
@ -56,16 +63,48 @@ class ConversationDocumentsListViewModel
|
|||
@WorkerThread
|
||||
private fun loadDocumentsList() {
|
||||
operationInProgress.postValue(true)
|
||||
|
||||
val list = arrayListOf<FileModel>()
|
||||
Log.i(
|
||||
"$TAG Loading document contents for conversation [${LinphoneUtils.getConversationId(
|
||||
chatRoom
|
||||
)}]"
|
||||
)
|
||||
val documents = chatRoom.documentContents
|
||||
Log.i("$TAG [${documents.size}] documents have been fetched")
|
||||
for (documentContent in documents) {
|
||||
|
||||
totalDocumentsCount = chatRoom.documentContentsSize
|
||||
Log.i("$TAG Document contents size is [$totalDocumentsCount]")
|
||||
|
||||
val contentsToLoad = min(totalDocumentsCount, CONTENTS_PER_PAGE)
|
||||
val contents = chatRoom.getDocumentContentsRange(0, contentsToLoad)
|
||||
Log.i("$TAG [${contents.size}] documents have been fetched")
|
||||
|
||||
documentsList.postValue(getFileModelsListFromContents(contents))
|
||||
operationInProgress.postValue(false)
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun loadMoreData(totalItemsCount: Int) {
|
||||
coreContext.postOnCoreThread {
|
||||
Log.i("$TAG Loading more data, current total is $totalItemsCount, max size is $totalDocumentsCount")
|
||||
|
||||
if (totalItemsCount < totalDocumentsCount) {
|
||||
var upperBound: Int = totalItemsCount + CONTENTS_PER_PAGE
|
||||
if (upperBound > totalDocumentsCount) {
|
||||
upperBound = totalDocumentsCount
|
||||
}
|
||||
val contents = chatRoom.getDocumentContentsRange(totalItemsCount, upperBound)
|
||||
Log.i("$TAG [${contents.size}] contents loaded, adding them to list")
|
||||
|
||||
val list = arrayListOf<FileModel>()
|
||||
list.addAll(documentsList.value.orEmpty())
|
||||
list.addAll(getFileModelsListFromContents(contents))
|
||||
documentsList.postValue(list)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun getFileModelsListFromContents(contents: Array<Content>): ArrayList<FileModel> {
|
||||
val list = arrayListOf<FileModel>()
|
||||
for (documentContent in contents) {
|
||||
val isEncrypted = documentContent.isFileEncrypted
|
||||
val originalPath = documentContent.filePath.orEmpty()
|
||||
val path = if (isEncrypted) {
|
||||
|
|
@ -94,14 +133,11 @@ class ConversationDocumentsListViewModel
|
|||
|
||||
val model =
|
||||
FileModel(path, name, size, timestamp, isEncrypted, originalPath, ephemeral) {
|
||||
openDocumentEvent.postValue(Event(it))
|
||||
}
|
||||
openDocumentEvent.postValue(Event(it))
|
||||
}
|
||||
list.add(model)
|
||||
}
|
||||
}
|
||||
|
||||
Log.i("$TAG [${documents.size}] documents have been processed")
|
||||
documentsList.postValue(list)
|
||||
operationInProgress.postValue(false)
|
||||
return list
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,9 +30,8 @@ import org.linphone.core.Address
|
|||
import org.linphone.core.ChatRoom
|
||||
import org.linphone.core.ChatRoomListenerStub
|
||||
import org.linphone.core.Conference
|
||||
import org.linphone.core.Friend
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.ui.main.contacts.model.ContactNumberOrAddressClickListener
|
||||
import org.linphone.ui.main.contacts.model.ContactNumberOrAddressModel
|
||||
import org.linphone.ui.main.model.ConversationContactOrSuggestionModel
|
||||
import org.linphone.ui.main.viewmodel.AddressSelectionViewModel
|
||||
import org.linphone.utils.AppUtils
|
||||
|
|
@ -52,31 +51,6 @@ class ConversationForwardMessageViewModel
|
|||
MutableLiveData<Event<String>>()
|
||||
}
|
||||
|
||||
val showNumberOrAddressPickerDialogEvent: MutableLiveData<Event<ArrayList<ContactNumberOrAddressModel>>> by lazy {
|
||||
MutableLiveData<Event<ArrayList<ContactNumberOrAddressModel>>>()
|
||||
}
|
||||
|
||||
val hideNumberOrAddressPickerDialogEvent: MutableLiveData<Event<Boolean>> by lazy {
|
||||
MutableLiveData<Event<Boolean>>()
|
||||
}
|
||||
|
||||
private val listener = object : ContactNumberOrAddressClickListener {
|
||||
@UiThread
|
||||
override fun onClicked(model: ContactNumberOrAddressModel) {
|
||||
val address = model.address
|
||||
coreContext.postOnCoreThread {
|
||||
if (address != null) {
|
||||
Log.i("$TAG Selected address is [${model.address.asStringUriOnly()}]")
|
||||
onAddressSelected(model.address)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@UiThread
|
||||
override fun onLongPress(model: ContactNumberOrAddressModel) {
|
||||
}
|
||||
}
|
||||
|
||||
private val chatRoomListener = object : ChatRoomListenerStub() {
|
||||
@WorkerThread
|
||||
override fun onStateChanged(chatRoom: ChatRoom, newState: ChatRoom.State?) {
|
||||
|
|
@ -105,8 +79,8 @@ class ConversationForwardMessageViewModel
|
|||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun onAddressSelected(address: Address) {
|
||||
hideNumberOrAddressPickerDialogEvent.postValue(Event(true))
|
||||
override fun onSingleAddressSelected(address: Address, friend: Friend?) {
|
||||
dismissNumberOrAddressPickerDialogEvent.postValue(Event(true))
|
||||
|
||||
createOneToOneChatRoomWith(address)
|
||||
|
||||
|
|
@ -136,7 +110,7 @@ class ConversationForwardMessageViewModel
|
|||
val friend = model.friend
|
||||
if (friend == null) {
|
||||
Log.i("$TAG Friend is null, using address [${model.address.asStringUriOnly()}]")
|
||||
onAddressSelected(model.address)
|
||||
onSingleAddressSelected(model.address, null)
|
||||
return@postOnCoreThread
|
||||
}
|
||||
|
||||
|
|
@ -145,9 +119,9 @@ class ConversationForwardMessageViewModel
|
|||
Log.i(
|
||||
"$TAG Only 1 SIP address or phone number found for contact [${friend.name}], using it"
|
||||
)
|
||||
onAddressSelected(singleAvailableAddress)
|
||||
onSingleAddressSelected(singleAvailableAddress, friend)
|
||||
} else {
|
||||
val list = friend.getListOfSipAddressesAndPhoneNumbers(listener)
|
||||
val list = friend.getListOfSipAddressesAndPhoneNumbers(numberOrAddressClickListener)
|
||||
Log.i(
|
||||
"$TAG [${list.size}] numbers or addresses found for contact [${friend.name}], showing selection dialog"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -56,6 +56,8 @@ class ConversationInfoViewModel
|
|||
|
||||
val isGroup = MutableLiveData<Boolean>()
|
||||
|
||||
val hideSipAddresses = MutableLiveData<Boolean>()
|
||||
|
||||
val isEndToEndEncrypted = MutableLiveData<Boolean>()
|
||||
|
||||
val subject = MutableLiveData<String>()
|
||||
|
|
@ -80,6 +82,8 @@ class ConversationInfoViewModel
|
|||
|
||||
val friendAvailable = MutableLiveData<Boolean>()
|
||||
|
||||
val disableAddContact = MutableLiveData<Boolean>()
|
||||
|
||||
val groupLeftEvent: MutableLiveData<Event<Boolean>> by lazy {
|
||||
MutableLiveData<Event<Boolean>>()
|
||||
}
|
||||
|
|
@ -108,7 +112,7 @@ class ConversationInfoViewModel
|
|||
R.string.conversation_info_participant_added_to_conversation_toast,
|
||||
getParticipant(eventLog)
|
||||
)
|
||||
showFormattedGreenToast(message, R.drawable.user_circle)
|
||||
showFormattedGreenToast(message, R.drawable.user_circle_plus)
|
||||
|
||||
computeParticipantsList()
|
||||
infoChangedEvent.postValue(Event(true))
|
||||
|
|
@ -121,7 +125,7 @@ class ConversationInfoViewModel
|
|||
R.string.conversation_info_participant_removed_from_conversation_toast,
|
||||
getParticipant(eventLog)
|
||||
)
|
||||
showFormattedGreenToast(message, R.drawable.user_circle)
|
||||
showFormattedGreenToast(message, R.drawable.user_circle_minus)
|
||||
|
||||
computeParticipantsList()
|
||||
infoChangedEvent.postValue(Event(true))
|
||||
|
|
@ -132,18 +136,19 @@ class ConversationInfoViewModel
|
|||
Log.i(
|
||||
"$TAG A participant has been given/removed administration rights for group [${chatRoom.subject}]"
|
||||
)
|
||||
val message = if (eventLog.type == EventLog.Type.ConferenceParticipantSetAdmin) {
|
||||
AppUtils.getFormattedString(
|
||||
if (eventLog.type == EventLog.Type.ConferenceParticipantSetAdmin) {
|
||||
val message = AppUtils.getFormattedString(
|
||||
R.string.conversation_info_participant_has_been_granted_admin_rights_toast,
|
||||
getParticipant(eventLog)
|
||||
)
|
||||
showFormattedGreenToast(message, R.drawable.user_circle_check)
|
||||
} else {
|
||||
AppUtils.getFormattedString(
|
||||
val message = AppUtils.getFormattedString(
|
||||
R.string.conversation_info_participant_no_longer_has_admin_rights_toast,
|
||||
getParticipant(eventLog)
|
||||
)
|
||||
showFormattedGreenToast(message, R.drawable.user_circle_dashed)
|
||||
}
|
||||
showFormattedGreenToast(message, R.drawable.user_circle)
|
||||
|
||||
computeParticipantsList()
|
||||
}
|
||||
|
|
@ -156,6 +161,7 @@ class ConversationInfoViewModel
|
|||
showGreenToast(R.string.conversation_subject_changed_toast, R.drawable.check)
|
||||
|
||||
subject.postValue(chatRoom.subject)
|
||||
computeParticipantsList()
|
||||
infoChangedEvent.postValue(Event(true))
|
||||
}
|
||||
|
||||
|
|
@ -190,8 +196,10 @@ class ConversationInfoViewModel
|
|||
init {
|
||||
expandParticipants.value = true
|
||||
showPeerSipUri.value = false
|
||||
disableAddContact.value = corePreferences.disableAddContact
|
||||
|
||||
coreContext.postOnCoreThread {
|
||||
hideSipAddresses.postValue(corePreferences.hideSipAddresses)
|
||||
coreContext.contactsManager.addListener(contactsListener)
|
||||
}
|
||||
}
|
||||
|
|
@ -558,7 +566,9 @@ class ConversationInfoViewModel
|
|||
} else {
|
||||
participantsList.first().avatarModel
|
||||
}
|
||||
avatarModel.postValue(avatar)
|
||||
if (!avatar.compare(avatarModel.value)) {
|
||||
avatarModel.postValue(avatar)
|
||||
}
|
||||
|
||||
participants.postValue(participantsList)
|
||||
participantsLabel.postValue(
|
||||
|
|
|
|||
|
|
@ -22,16 +22,21 @@ package org.linphone.ui.main.chat.viewmodel
|
|||
import androidx.annotation.UiThread
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
import org.linphone.core.Content
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.ui.main.chat.model.FileModel
|
||||
import org.linphone.utils.Event
|
||||
import org.linphone.utils.LinphoneUtils
|
||||
import kotlin.math.min
|
||||
|
||||
class ConversationMediaListViewModel
|
||||
@UiThread
|
||||
constructor() : AbstractConversationViewModel() {
|
||||
companion object {
|
||||
private const val TAG = "[Conversation Media List ViewModel]"
|
||||
|
||||
private const val CONTENTS_PER_PAGE = 50
|
||||
}
|
||||
|
||||
val mediaList = MutableLiveData<List<FileModel>>()
|
||||
|
|
@ -42,6 +47,8 @@ class ConversationMediaListViewModel
|
|||
MutableLiveData<Event<FileModel>>()
|
||||
}
|
||||
|
||||
private var totalMediaCount: Int = -1
|
||||
|
||||
@WorkerThread
|
||||
override fun afterNotifyingChatRoomFound(sameOne: Boolean) {
|
||||
loadMediaList()
|
||||
|
|
@ -56,16 +63,48 @@ class ConversationMediaListViewModel
|
|||
@WorkerThread
|
||||
private fun loadMediaList() {
|
||||
operationInProgress.postValue(true)
|
||||
|
||||
val list = arrayListOf<FileModel>()
|
||||
Log.i(
|
||||
"$TAG Loading media contents for conversation [${LinphoneUtils.getConversationId(
|
||||
chatRoom
|
||||
)}]"
|
||||
)
|
||||
val media = chatRoom.mediaContents
|
||||
Log.i("$TAG [${media.size}] media have been fetched")
|
||||
for (mediaContent in media) {
|
||||
|
||||
totalMediaCount = chatRoom.mediaContentsSize
|
||||
Log.i("$TAG Media contents size is [$totalMediaCount]")
|
||||
|
||||
val contentsToLoad = min(totalMediaCount, CONTENTS_PER_PAGE)
|
||||
val contents = chatRoom.getMediaContentsRange(0, contentsToLoad)
|
||||
Log.i("$TAG [${contents.size}] media have been fetched")
|
||||
|
||||
mediaList.postValue(getFileModelsListFromContents(contents))
|
||||
operationInProgress.postValue(false)
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun loadMoreData(totalItemsCount: Int) {
|
||||
coreContext.postOnCoreThread {
|
||||
Log.i("$TAG Loading more data, current total is $totalItemsCount, max size is $totalMediaCount")
|
||||
|
||||
if (totalItemsCount < totalMediaCount) {
|
||||
var upperBound: Int = totalItemsCount + CONTENTS_PER_PAGE
|
||||
if (upperBound > totalMediaCount) {
|
||||
upperBound = totalMediaCount
|
||||
}
|
||||
val contents = chatRoom.getMediaContentsRange(totalItemsCount, upperBound)
|
||||
Log.i("$TAG [${contents.size}] contents loaded, adding them to list")
|
||||
|
||||
val list = arrayListOf<FileModel>()
|
||||
list.addAll(mediaList.value.orEmpty())
|
||||
list.addAll(getFileModelsListFromContents(contents))
|
||||
mediaList.postValue(list)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun getFileModelsListFromContents(contents: Array<Content>): ArrayList<FileModel> {
|
||||
val list = arrayListOf<FileModel>()
|
||||
for (mediaContent in contents) {
|
||||
// Do not display voice recordings here, even if they are media file
|
||||
if (mediaContent.isVoiceRecording) continue
|
||||
|
||||
|
|
@ -85,14 +124,11 @@ class ConversationMediaListViewModel
|
|||
if (path.isNotEmpty() && name.isNotEmpty()) {
|
||||
val model =
|
||||
FileModel(path, name, size, timestamp, isEncrypted, originalPath, chatRoom.isEphemeralEnabled) {
|
||||
openMediaEvent.postValue(Event(it))
|
||||
}
|
||||
openMediaEvent.postValue(Event(it))
|
||||
}
|
||||
list.add(model)
|
||||
}
|
||||
}
|
||||
|
||||
Log.i("$TAG [${media.size}] media have been processed")
|
||||
mediaList.postValue(list)
|
||||
operationInProgress.postValue(false)
|
||||
return list
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,7 +44,6 @@ import org.linphone.ui.main.chat.model.EventLogModel
|
|||
import org.linphone.ui.main.chat.model.FileModel
|
||||
import org.linphone.ui.main.chat.model.MessageModel
|
||||
import org.linphone.ui.main.contacts.model.ContactAvatarModel
|
||||
import org.linphone.utils.AppUtils
|
||||
import org.linphone.utils.Event
|
||||
import org.linphone.utils.FileUtils
|
||||
import org.linphone.utils.LinphoneUtils
|
||||
|
|
@ -89,6 +88,8 @@ class ConversationViewModel
|
|||
|
||||
val composingLabel = MutableLiveData<String>()
|
||||
|
||||
val composingIcon = MutableLiveData<Int>()
|
||||
|
||||
val searchBarVisible = MutableLiveData<Boolean>()
|
||||
|
||||
val searchFilter = MutableLiveData<String>()
|
||||
|
|
@ -97,6 +98,8 @@ class ConversationViewModel
|
|||
|
||||
val canSearchDown = MutableLiveData<Boolean>()
|
||||
|
||||
val canSearchUp = MutableLiveData<Boolean>()
|
||||
|
||||
val itemToScrollTo = MutableLiveData<Int>()
|
||||
|
||||
val isUserScrollingUp = MutableLiveData<Boolean>()
|
||||
|
|
@ -111,6 +114,10 @@ class ConversationViewModel
|
|||
MutableLiveData<Event<FileModel>>()
|
||||
}
|
||||
|
||||
val sipUriToCallEvent: MutableLiveData<Event<String>> by lazy {
|
||||
MutableLiveData<Event<String>>()
|
||||
}
|
||||
|
||||
val conferenceToJoinEvent: MutableLiveData<Event<String>> by lazy {
|
||||
MutableLiveData<Event<String>>()
|
||||
}
|
||||
|
|
@ -145,11 +152,15 @@ class ConversationViewModel
|
|||
|
||||
private var latestMatch: EventLog? = null
|
||||
|
||||
private var latestMatchModel: MessageModel? = null
|
||||
|
||||
private val chatRoomListener = object : ChatRoomListenerStub() {
|
||||
@WorkerThread
|
||||
override fun onConferenceJoined(chatRoom: ChatRoom, eventLog: EventLog) {
|
||||
Log.i("$TAG Conversation was joined")
|
||||
addEvents(arrayOf(eventLog))
|
||||
if (LinphoneUtils.isChatRoomAGroup(chatRoom)) {
|
||||
addEvents(arrayOf(eventLog))
|
||||
}
|
||||
computeConversationInfo()
|
||||
|
||||
val messageToForward = pendingForwardMessage
|
||||
|
|
@ -163,8 +174,10 @@ class ConversationViewModel
|
|||
@WorkerThread
|
||||
override fun onConferenceLeft(chatRoom: ChatRoom, eventLog: EventLog) {
|
||||
Log.w("$TAG Conversation was left")
|
||||
addEvents(arrayOf(eventLog))
|
||||
isReadOnly.postValue(true)
|
||||
if (LinphoneUtils.isChatRoomAGroup(chatRoom)) {
|
||||
addEvents(arrayOf(eventLog))
|
||||
}
|
||||
isReadOnly.postValue(chatRoom.isReadOnly)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
|
|
@ -185,17 +198,6 @@ class ConversationViewModel
|
|||
Log.i("$TAG Conversation was marked as read")
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun onChatMessageSending(chatRoom: ChatRoom, eventLog: EventLog) {
|
||||
val message = eventLog.chatMessage
|
||||
Log.i("$TAG Message [$message] is being sent, marking conversation as read")
|
||||
|
||||
// Prevents auto scroll to go to latest received message
|
||||
chatRoom.markAsRead()
|
||||
|
||||
addEvents(arrayOf(eventLog))
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun onChatMessagesReceived(chatRoom: ChatRoom, eventLogs: Array<EventLog>) {
|
||||
Log.i("$TAG Received [${eventLogs.size}] new message(s)")
|
||||
|
|
@ -296,6 +298,22 @@ class ConversationViewModel
|
|||
Log.e("$TAG Failed to find matching message in conversation events list")
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun onMessageRetracted(chatRoom: ChatRoom, message: ChatMessage) {
|
||||
updateRepliesUpTo(message)
|
||||
|
||||
if (message.isOutgoing) {
|
||||
messageDeletedEvent.postValue(Event(true))
|
||||
}
|
||||
|
||||
unreadMessagesCount.postValue(chatRoom.unreadMessagesCount)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun onMessageContentEdited(chatRoom: ChatRoom, message: ChatMessage) {
|
||||
updateRepliesUpTo(message)
|
||||
}
|
||||
}
|
||||
|
||||
private val contactsListener = object : ContactsManager.ContactsListener {
|
||||
|
|
@ -326,6 +344,7 @@ class ConversationViewModel
|
|||
isDisabledBecauseNotSecured.value = false
|
||||
searchInProgress.value = false
|
||||
canSearchDown.value = false
|
||||
canSearchUp.value = false
|
||||
itemToScrollTo.value = -1
|
||||
}
|
||||
|
||||
|
|
@ -357,38 +376,41 @@ class ConversationViewModel
|
|||
|
||||
@UiThread
|
||||
fun openSearchBar() {
|
||||
canSearchUp.value = true
|
||||
searchBarVisible.value = true
|
||||
focusSearchBarEvent.value = Event(true)
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun closeSearchBar() {
|
||||
coreContext.postOnCoreThread {
|
||||
latestMatchModel?.highlightText("")
|
||||
latestMatchModel = null
|
||||
}
|
||||
|
||||
searchFilter.value = ""
|
||||
searchBarVisible.value = false
|
||||
focusSearchBarEvent.value = Event(false)
|
||||
latestMatch = null
|
||||
canSearchDown.value = false
|
||||
canSearchUp.value = false
|
||||
}
|
||||
|
||||
coreContext.postOnCoreThread {
|
||||
for (eventLog in eventsList) {
|
||||
if ((eventLog.model as? MessageModel)?.isTextHighlighted == true) {
|
||||
eventLog.model.highlightText("")
|
||||
}
|
||||
@UiThread
|
||||
fun searchUp() {
|
||||
if (canSearchUp.value == true) {
|
||||
coreContext.postOnCoreThread {
|
||||
searchChatMessage(SearchDirection.Up)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun searchUp() {
|
||||
coreContext.postOnCoreThread {
|
||||
searchChatMessage(SearchDirection.Up)
|
||||
}
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun searchDown() {
|
||||
coreContext.postOnCoreThread {
|
||||
searchChatMessage(SearchDirection.Down)
|
||||
if (canSearchDown.value == true) {
|
||||
coreContext.postOnCoreThread {
|
||||
searchChatMessage(SearchDirection.Down)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -402,6 +424,7 @@ class ConversationViewModel
|
|||
|
||||
@UiThread
|
||||
fun updateUnreadMessageCount() {
|
||||
if (!isChatRoomInitialized()) return
|
||||
coreContext.postOnCoreThread {
|
||||
unreadMessagesCount.postValue(chatRoom.unreadMessagesCount)
|
||||
}
|
||||
|
|
@ -430,7 +453,9 @@ class ConversationViewModel
|
|||
|
||||
Log.i("$TAG Removing chat message id [${chatMessageModel.id}] from events list")
|
||||
list.remove(found)
|
||||
|
||||
eventsList = list
|
||||
|
||||
updateEvents.postValue(Event(true))
|
||||
isEmpty.postValue(eventsList.isEmpty())
|
||||
} else {
|
||||
|
|
@ -442,11 +467,23 @@ class ConversationViewModel
|
|||
Log.i("$TAG Deleting message id [${chatMessageModel.id}] from database")
|
||||
chatRoom.deleteMessage(chatMessageModel.chatMessage)
|
||||
messageDeletedEvent.postValue(Event(true))
|
||||
|
||||
updateRepliesUpTo(chatMessageModel.chatMessage)
|
||||
}
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun deleteChatMessageForEveryone(chatMessageModel: MessageModel) {
|
||||
coreContext.postOnCoreThread {
|
||||
val message = chatMessageModel.chatMessage
|
||||
Log.i("$TAG Sending order to delete contents of message [${message.messageId}] to every participant of the conversation")
|
||||
chatRoom.retractMessage(message)
|
||||
}
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun markAsRead() {
|
||||
if (!isChatRoomInitialized()) return
|
||||
coreContext.postOnCoreThread {
|
||||
if (chatRoom.unreadMessagesCount == 0) return@postOnCoreThread
|
||||
Log.i("$TAG Marking chat room as read")
|
||||
|
|
@ -456,6 +493,7 @@ class ConversationViewModel
|
|||
|
||||
@UiThread
|
||||
fun mute() {
|
||||
if (!isChatRoomInitialized()) return
|
||||
coreContext.postOnCoreThread {
|
||||
chatRoom.muted = true
|
||||
isMuted.postValue(chatRoom.muted)
|
||||
|
|
@ -464,6 +502,7 @@ class ConversationViewModel
|
|||
|
||||
@UiThread
|
||||
fun unMute() {
|
||||
if (!isChatRoomInitialized()) return
|
||||
coreContext.postOnCoreThread {
|
||||
chatRoom.muted = false
|
||||
isMuted.postValue(chatRoom.muted)
|
||||
|
|
@ -487,6 +526,7 @@ class ConversationViewModel
|
|||
|
||||
@UiThread
|
||||
fun updateEphemeralLifetime(lifetime: Long) {
|
||||
if (!isChatRoomInitialized()) return
|
||||
coreContext.postOnCoreThread {
|
||||
LinphoneUtils.chatRoomConfigureEphemeralMessagesLifetime(chatRoom, lifetime)
|
||||
ephemeralLifetime.postValue(
|
||||
|
|
@ -500,6 +540,7 @@ class ConversationViewModel
|
|||
|
||||
@UiThread
|
||||
fun loadMoreData(totalItemsCount: Int) {
|
||||
if (!isChatRoomInitialized()) return
|
||||
coreContext.postOnCoreThread {
|
||||
val maxSize: Int = chatRoom.historyEventsSize
|
||||
Log.i("$TAG Loading more data, current total is $totalItemsCount, max size is $maxSize")
|
||||
|
|
@ -535,6 +576,7 @@ class ConversationViewModel
|
|||
|
||||
@WorkerThread
|
||||
fun checkIfConversationShouldBeDisabledForSecurityReasons() {
|
||||
if (!isChatRoomInitialized()) return
|
||||
if (!chatRoom.hasCapability(ChatRoom.Capabilities.Encrypted.toInt())) {
|
||||
if (LinphoneUtils.getAccountForAddress(chatRoom.localAddress)?.params?.instantMessagingEncryptionMandatory == true) {
|
||||
Log.w(
|
||||
|
|
@ -568,8 +610,23 @@ class ConversationViewModel
|
|||
}
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun addSentMessageToEventsList(message: ChatMessage) {
|
||||
coreContext.postOnCoreThread {
|
||||
val eventLog = message.eventLog
|
||||
if (eventLog != null) {
|
||||
Log.i("$TAG Adding sent message with ID [${message.messageId}] to events list")
|
||||
addEvents(arrayOf(eventLog))
|
||||
} else {
|
||||
Log.e("$TAG Failed to get event log for sent message with ID [${message.messageId}]")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun configureChatRoom() {
|
||||
if (!isChatRoomInitialized()) return
|
||||
|
||||
computeComposingLabel()
|
||||
|
||||
isEndToEndEncrypted.postValue(
|
||||
|
|
@ -585,6 +642,8 @@ class ConversationViewModel
|
|||
|
||||
@WorkerThread
|
||||
private fun computeConversationInfo() {
|
||||
if (!isChatRoomInitialized()) return
|
||||
|
||||
val group = LinphoneUtils.isChatRoomAGroup(chatRoom)
|
||||
isGroup.postValue(group)
|
||||
|
||||
|
|
@ -615,6 +674,8 @@ class ConversationViewModel
|
|||
|
||||
@WorkerThread
|
||||
private fun computeParticipantsInfo() {
|
||||
if (!isChatRoomInitialized()) return
|
||||
|
||||
val friends = arrayListOf<Friend>()
|
||||
val address = if (chatRoom.hasCapability(ChatRoom.Capabilities.Basic.toInt())) {
|
||||
chatRoom.peerAddress
|
||||
|
|
@ -644,6 +705,8 @@ class ConversationViewModel
|
|||
|
||||
@WorkerThread
|
||||
private fun computeEvents() {
|
||||
if (!isChatRoomInitialized()) return
|
||||
|
||||
eventsList.forEach(EventLogModel::destroy)
|
||||
|
||||
val history = chatRoom.getHistoryEvents(MESSAGES_PER_PAGE)
|
||||
|
|
@ -656,7 +719,7 @@ class ConversationViewModel
|
|||
|
||||
@WorkerThread
|
||||
private fun addEvents(eventLogs: Array<EventLog>) {
|
||||
Log.i("$TAG Adding [${eventLogs.size}] events")
|
||||
Log.i("$TAG Adding [${eventLogs.size}] event(s)")
|
||||
// Need to use a new list, otherwise ConversationFragment's dataObserver isn't triggered...
|
||||
val list = arrayListOf<EventLogModel>()
|
||||
list.addAll(eventsList)
|
||||
|
|
@ -748,25 +811,28 @@ class ConversationViewModel
|
|||
index > 0,
|
||||
index != groupedEventLogs.size - 1,
|
||||
searchFilter.value.orEmpty(),
|
||||
{ fileModel ->
|
||||
{ fileModel -> // onContentClicked
|
||||
fileToDisplayEvent.postValue(Event(fileModel))
|
||||
},
|
||||
{ conferenceUri ->
|
||||
{ sipUri -> // onSipUriClicked
|
||||
sipUriToCallEvent.postValue(Event(sipUri))
|
||||
},
|
||||
{ conferenceUri -> // onJoinConferenceClicked
|
||||
conferenceToJoinEvent.postValue(Event(conferenceUri))
|
||||
},
|
||||
{ url ->
|
||||
{ url -> // onWebUrlClicked
|
||||
openWebBrowserEvent.postValue(Event(url))
|
||||
},
|
||||
{ friendRefKey ->
|
||||
{ friendRefKey -> // onContactClicked
|
||||
contactToDisplayEvent.postValue(Event(friendRefKey))
|
||||
},
|
||||
{ redToast ->
|
||||
{ redToast -> // onRedToastToShow
|
||||
showRedToastEvent.postValue(Event(redToast))
|
||||
},
|
||||
{ id ->
|
||||
{ id -> // onVoiceRecordingPlaybackEnded
|
||||
voiceRecordPlaybackEndedEvent.postValue(Event(id))
|
||||
},
|
||||
{ filePath ->
|
||||
{ filePath -> // onFileToExportToNativeGallery
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
Log.i("$TAG Export file [$filePath] to Android's MediaStore")
|
||||
|
|
@ -878,29 +944,31 @@ class ConversationViewModel
|
|||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun computeComposingLabel() {
|
||||
val composingFriends = arrayListOf<String>()
|
||||
var label = ""
|
||||
for (address in chatRoom.composingAddresses) {
|
||||
val avatar = coreContext.contactsManager.getContactAvatarModelForAddress(address)
|
||||
val name = avatar.name.value ?: LinphoneUtils.getDisplayName(address)
|
||||
composingFriends.add(name)
|
||||
label += "$name, "
|
||||
}
|
||||
if (composingFriends.isNotEmpty()) {
|
||||
label = label.dropLast(2)
|
||||
private fun updateRepliesUpTo(chatMessage: ChatMessage) {
|
||||
for (model in eventsList.reversed()) {
|
||||
if (model.model is MessageModel) {
|
||||
if (model.model.replyToMessageId == chatMessage.messageId) {
|
||||
model.model.computeReplyInfo()
|
||||
}
|
||||
|
||||
val format = AppUtils.getStringWithPlural(
|
||||
R.plurals.conversation_composing_label,
|
||||
composingFriends.size,
|
||||
label
|
||||
)
|
||||
composingLabel.postValue(format)
|
||||
} else {
|
||||
composingLabel.postValue("")
|
||||
if (model.model.timestamp < chatMessage.time) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun computeComposingLabel() {
|
||||
if (!isChatRoomInitialized()) return
|
||||
|
||||
val pair = LinphoneUtils.getComposingIconAndText(chatRoom)
|
||||
val icon = pair.first
|
||||
composingIcon.postValue(icon)
|
||||
val label = pair.second
|
||||
composingLabel.postValue(label)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun loadMessagesUpTo(targetEvent: EventLog) {
|
||||
val mask = HistoryFilter.ChatMessage.toInt() or HistoryFilter.InfoNoDevice.toInt()
|
||||
|
|
@ -928,6 +996,7 @@ class ConversationViewModel
|
|||
|
||||
@WorkerThread
|
||||
private fun searchChatMessage(direction: SearchDirection) {
|
||||
if (!isChatRoomInitialized()) return
|
||||
searchInProgress.postValue(true)
|
||||
|
||||
val textToSearch = searchFilter.value.orEmpty().trim()
|
||||
|
|
@ -940,15 +1009,34 @@ class ConversationViewModel
|
|||
val message = if (latestMatch == null) {
|
||||
R.string.conversation_search_no_match_found
|
||||
} else {
|
||||
// Scroll to last matching event anyway, user may have scrolled away
|
||||
val found = eventsList.find {
|
||||
it.eventLog == latestMatch
|
||||
}
|
||||
if (found != null) { // This should always be true
|
||||
val index = eventsList.indexOf(found)
|
||||
itemToScrollTo.postValue(index)
|
||||
}
|
||||
// Disable button as latest result has been reached
|
||||
if (direction == SearchDirection.Down) {
|
||||
canSearchDown.postValue(false)
|
||||
} else {
|
||||
canSearchUp.postValue(false)
|
||||
}
|
||||
R.string.conversation_search_no_more_match
|
||||
}
|
||||
showRedToast(message, R.drawable.magnifying_glass)
|
||||
} else {
|
||||
canSearchDown.postValue(true)
|
||||
canSearchUp.postValue(true)
|
||||
|
||||
// Clear highlight from previous match
|
||||
latestMatchModel?.highlightText("")
|
||||
|
||||
Log.i(
|
||||
"$TAG Found result [${match.chatMessage?.messageId}] while looking up for message with text [$textToSearch] in direction [$direction] starting from message [${latestMatch?.chatMessage?.messageId}]"
|
||||
)
|
||||
latestMatch = match
|
||||
|
||||
val found = eventsList.find {
|
||||
it.eventLog == match
|
||||
}
|
||||
|
|
@ -957,19 +1045,12 @@ class ConversationViewModel
|
|||
loadMessagesUpTo(match)
|
||||
} else {
|
||||
Log.i("$TAG Found result is already in history, no need to load more history")
|
||||
(found.model as? MessageModel)?.highlightText(textToSearch)
|
||||
latestMatchModel = (found.model as? MessageModel)
|
||||
latestMatchModel?.highlightText(textToSearch)
|
||||
val index = eventsList.indexOf(found)
|
||||
if (direction == SearchDirection.Down && index < eventsList.size - 1) {
|
||||
// Go to next message to prevent the message we are looking for to be behind the scroll to bottom button
|
||||
itemToScrollTo.postValue(index + 1)
|
||||
} else {
|
||||
// Go to previous message so target message won't be displayed stuck to the top
|
||||
itemToScrollTo.postValue(index - 1)
|
||||
}
|
||||
itemToScrollTo.postValue(index)
|
||||
searchInProgress.postValue(false)
|
||||
}
|
||||
|
||||
canSearchDown.postValue(true)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -174,16 +174,12 @@ class ConversationsListViewModel
|
|||
|
||||
@WorkerThread
|
||||
private fun addChatRoom(chatRoom: ChatRoom) {
|
||||
val localAddress = chatRoom.localAddress
|
||||
val peerAddress = chatRoom.peerAddress
|
||||
|
||||
val identifier = chatRoom.identifier
|
||||
val chatRoomAccount = chatRoom.account
|
||||
val defaultAccount = LinphoneUtils.getDefaultAccount()
|
||||
if (defaultAccount == null ||
|
||||
defaultAccount.params.identityAddress?.weakEqual(localAddress) == false
|
||||
)
|
||||
{
|
||||
if (defaultAccount == null || chatRoomAccount == null || chatRoomAccount != defaultAccount) {
|
||||
Log.w(
|
||||
"$TAG Chat room with local address [${localAddress.asStringUriOnly()}] and peer address [${peerAddress.asStringUriOnly()}] was created but not displaying it because it doesn't belong to currently default account"
|
||||
"$TAG Chat room with identifier [$identifier] was created but not displaying it because it doesn't belong to currently default account"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
|
@ -191,16 +187,16 @@ class ConversationsListViewModel
|
|||
val hideEmptyChatRooms = coreContext.core.config.getBool("misc", "hide_empty_chat_rooms", true)
|
||||
// Hide empty chat rooms only applies to 1-1 conversations
|
||||
if (hideEmptyChatRooms && !LinphoneUtils.isChatRoomAGroup(chatRoom) && chatRoom.lastMessageInHistory == null) {
|
||||
Log.w("$TAG Chat room with local address [${localAddress.asStringUriOnly()}] and peer address [${peerAddress.asStringUriOnly()}] is empty, not adding it to match Core setting")
|
||||
Log.w("$TAG Chat room with identifier [$identifier] is empty, not adding it to match Core setting")
|
||||
return
|
||||
}
|
||||
|
||||
val currentList = conversations.value.orEmpty()
|
||||
val found = currentList.find {
|
||||
it.chatRoom.peerAddress.weakEqual(peerAddress)
|
||||
it.chatRoom.identifier == identifier
|
||||
}
|
||||
if (found != null) {
|
||||
Log.w("$TAG Created chat room with local address [${localAddress.asStringUriOnly()}] and peer address [${peerAddress.asStringUriOnly()}] is already in the list, skipping")
|
||||
Log.w("$TAG Created chat room with identifier [$identifier] is already in the list, skipping")
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -216,27 +212,27 @@ class ConversationsListViewModel
|
|||
val model = ConversationModel(chatRoom)
|
||||
newList.add(model)
|
||||
newList.addAll(currentList)
|
||||
Log.i("$TAG Adding chat room with local address [${localAddress.asStringUriOnly()}] and peer address [${peerAddress.asStringUriOnly()}] to list")
|
||||
Log.i("$TAG Adding chat room with identifier [$identifier] to list")
|
||||
conversations.postValue(newList)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun removeChatRoom(chatRoom: ChatRoom) {
|
||||
val currentList = conversations.value.orEmpty()
|
||||
val peerAddress = chatRoom.peerAddress
|
||||
val identifier = chatRoom.identifier
|
||||
val found = currentList.find {
|
||||
it.chatRoom.peerAddress.weakEqual(peerAddress)
|
||||
it.chatRoom.identifier == identifier
|
||||
}
|
||||
if (found != null) {
|
||||
val newList = arrayListOf<ConversationModel>()
|
||||
newList.addAll(currentList)
|
||||
newList.remove(found)
|
||||
found.destroy()
|
||||
Log.i("$TAG Removing chat room [${peerAddress.asStringUriOnly()}] from list")
|
||||
Log.i("$TAG Removing chat room with identifier [$identifier] from list")
|
||||
conversations.postValue(newList)
|
||||
} else {
|
||||
Log.w(
|
||||
"$TAG Failed to find item in list matching deleted chat room peer address [${peerAddress.asStringUriOnly()}]"
|
||||
"$TAG Failed to find item in list matching deleted chat room identifier [$identifier]"
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -84,6 +84,10 @@ class SendMessageInConversationViewModel
|
|||
|
||||
val attachments = MutableLiveData<ArrayList<FileModel>>()
|
||||
|
||||
val isEditing = MutableLiveData<Boolean>()
|
||||
|
||||
val isEditingMessage = MutableLiveData<Spannable>()
|
||||
|
||||
val isReplying = MutableLiveData<Boolean>()
|
||||
|
||||
val isReplyingTo = MutableLiveData<String>()
|
||||
|
|
@ -106,6 +110,8 @@ class SendMessageInConversationViewModel
|
|||
|
||||
val voiceRecordPlayerPosition = MutableLiveData<Int>()
|
||||
|
||||
val isComputingParticipantsList = MutableLiveData<Boolean>()
|
||||
|
||||
private lateinit var voiceRecordPlayer: Player
|
||||
|
||||
private val playerListener = PlayerListener {
|
||||
|
|
@ -129,14 +135,22 @@ class SendMessageInConversationViewModel
|
|||
MutableLiveData<Event<Boolean>>()
|
||||
}
|
||||
|
||||
val messageSentEvent: MutableLiveData<Event<ChatMessage>> by lazy {
|
||||
MutableLiveData<Event<ChatMessage>>()
|
||||
}
|
||||
|
||||
lateinit var chatRoom: ChatRoom
|
||||
|
||||
private var chatMessageToReplyTo: ChatMessage? = null
|
||||
|
||||
private var chatMessageToEdit: ChatMessage? = null
|
||||
|
||||
private lateinit var voiceMessageRecorder: Recorder
|
||||
|
||||
private var voiceRecordAudioFocusRequest: AudioFocusRequestCompat? = null
|
||||
|
||||
private var participantsListFilter = ""
|
||||
|
||||
private val chatRoomListener = object : ChatRoomListenerStub() {
|
||||
@WorkerThread
|
||||
override fun onParticipantAdded(chatRoom: ChatRoom, eventLog: EventLog) {
|
||||
|
|
@ -157,6 +171,7 @@ class SendMessageInConversationViewModel
|
|||
isKeyboardOpen.value = false
|
||||
isEmojiPickerOpen.value = false
|
||||
areFilePickersOpen.value = false
|
||||
isParticipantsListOpen.value = false
|
||||
isVoiceRecording.value = false
|
||||
isPlayingVoiceRecord.value = false
|
||||
isCallConversation.value = false
|
||||
|
|
@ -228,8 +243,41 @@ class SendMessageInConversationViewModel
|
|||
areFilePickersOpen.value = false
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun editMessage(model: MessageModel) {
|
||||
if (isReplying.value == true) {
|
||||
cancelReply()
|
||||
}
|
||||
if (isVoiceRecording.value == true) {
|
||||
cancelVoiceMessageRecording()
|
||||
}
|
||||
|
||||
val newValue = model.text.value?.toString() ?: ""
|
||||
textToSend.value = newValue
|
||||
|
||||
coreContext.postOnCoreThread {
|
||||
val message = model.chatMessage
|
||||
Log.i("$TAG Pending message edit [${message.messageId}]")
|
||||
chatMessageToEdit = message
|
||||
isEditingMessage.postValue(LinphoneUtils.getFormattedTextDescribingMessage(message))
|
||||
isEditing.postValue(true)
|
||||
}
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun cancelEdit() {
|
||||
Log.i("$TAG Cancelling edit")
|
||||
isEditing.value = false
|
||||
chatMessageToEdit = null
|
||||
textToSend.value = ""
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun replyToMessage(model: MessageModel) {
|
||||
if (isEditing.value == true) {
|
||||
cancelEdit()
|
||||
}
|
||||
|
||||
coreContext.postOnCoreThread {
|
||||
val message = model.chatMessage
|
||||
Log.i("$TAG Pending reply to message [${message.messageId}]")
|
||||
|
|
@ -253,9 +301,12 @@ class SendMessageInConversationViewModel
|
|||
val isBasicChatRoom: Boolean = chatRoom.hasCapability(ChatRoom.Capabilities.Basic.toInt())
|
||||
|
||||
val messageToReplyTo = chatMessageToReplyTo
|
||||
val messageToEdit = chatMessageToEdit
|
||||
val message = if (messageToReplyTo != null) {
|
||||
Log.i("$TAG Sending message as reply to [${messageToReplyTo.messageId}]")
|
||||
chatRoom.createReplyMessage(messageToReplyTo)
|
||||
} else if (messageToEdit != null) {
|
||||
chatRoom.createReplacesMessage(messageToEdit)
|
||||
} else {
|
||||
chatRoom.createEmptyMessage()
|
||||
}
|
||||
|
|
@ -278,9 +329,9 @@ class SendMessageInConversationViewModel
|
|||
val voiceMessage = chatRoom.createEmptyMessage()
|
||||
voiceMessage.addContent(content)
|
||||
voiceMessage.send()
|
||||
messageSentEvent.postValue(Event(voiceMessage))
|
||||
} else {
|
||||
message.addContent(content)
|
||||
contentAdded = true
|
||||
}
|
||||
} else {
|
||||
Log.e("$TAG Voice recording content couldn't be created!")
|
||||
|
|
@ -310,6 +361,7 @@ class SendMessageInConversationViewModel
|
|||
val fileMessage = chatRoom.createEmptyMessage()
|
||||
fileMessage.addFileContent(content)
|
||||
fileMessage.send()
|
||||
messageSentEvent.postValue(Event(fileMessage))
|
||||
} else {
|
||||
message.addFileContent(content)
|
||||
contentAdded = true
|
||||
|
|
@ -320,11 +372,13 @@ class SendMessageInConversationViewModel
|
|||
if (message.contents.isNotEmpty()) {
|
||||
Log.i("$TAG Sending message")
|
||||
message.send()
|
||||
messageSentEvent.postValue(Event(message))
|
||||
}
|
||||
|
||||
Log.i("$TAG Message sent, re-setting defaults")
|
||||
textToSend.postValue("")
|
||||
isReplying.postValue(false)
|
||||
isEditing.postValue(false)
|
||||
isFileAttachmentsListOpen.postValue(false)
|
||||
isParticipantsListOpen.postValue(false)
|
||||
isEmojiPickerOpen.postValue(false)
|
||||
|
|
@ -339,15 +393,20 @@ class SendMessageInConversationViewModel
|
|||
attachments.postValue(attachmentsList)
|
||||
|
||||
chatMessageToReplyTo = null
|
||||
chatMessageToEdit = null
|
||||
maxNumberOfAttachmentsReached.postValue(false)
|
||||
}
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun notifyChatMessageIsBeingComposed() {
|
||||
fun notifyComposing(composing: Boolean) {
|
||||
coreContext.postOnCoreThread {
|
||||
if (::chatRoom.isInitialized) {
|
||||
chatRoom.compose()
|
||||
if (composing) {
|
||||
chatRoom.composeTextMessage()
|
||||
} else {
|
||||
chatRoom.stopComposing()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -362,6 +421,10 @@ class SendMessageInConversationViewModel
|
|||
@UiThread
|
||||
fun closeParticipantsList() {
|
||||
isParticipantsListOpen.value = false
|
||||
coreContext.postOnCoreThread {
|
||||
participantsListFilter = ""
|
||||
computeParticipantsList()
|
||||
}
|
||||
}
|
||||
|
||||
@UiThread
|
||||
|
|
@ -379,35 +442,41 @@ class SendMessageInConversationViewModel
|
|||
}
|
||||
|
||||
@UiThread
|
||||
fun addAttachment(file: String) {
|
||||
if (attachments.value.orEmpty().size >= MAX_FILES_TO_ATTACH) {
|
||||
Log.w(
|
||||
"$TAG Max number of attachments [$MAX_FILES_TO_ATTACH] reached, file [$file] won't be attached"
|
||||
)
|
||||
showRedToast(R.string.conversation_maximum_number_of_attachments_reached, R.drawable.warning_circle)
|
||||
viewModelScope.launch {
|
||||
Log.i("$TAG Deleting temporary file [$file]")
|
||||
FileUtils.deleteFile(file)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
fun addAttachments(files: ArrayList<String>) {
|
||||
val list = arrayListOf<FileModel>()
|
||||
list.addAll(attachments.value.orEmpty())
|
||||
|
||||
val fileName = FileUtils.getNameFromFilePath(file)
|
||||
val timestamp = System.currentTimeMillis() / 1000
|
||||
val model = FileModel(file, fileName, 0, timestamp, false, file, false) { model ->
|
||||
removeAttachment(model.path)
|
||||
}
|
||||
for (file in files) {
|
||||
if (list.size >= MAX_FILES_TO_ATTACH) {
|
||||
Log.w(
|
||||
"$TAG Max number of attachments [$MAX_FILES_TO_ATTACH] reached, file [$file] won't be attached"
|
||||
)
|
||||
showRedToast(
|
||||
R.string.conversation_maximum_number_of_attachments_reached,
|
||||
R.drawable.warning_circle
|
||||
)
|
||||
viewModelScope.launch {
|
||||
Log.i("$TAG Deleting temporary file [$file]")
|
||||
FileUtils.deleteFile(file)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
list.add(model)
|
||||
val fileName = FileUtils.getNameFromFilePath(file)
|
||||
val timestamp = System.currentTimeMillis() / 1000
|
||||
val size = FileUtils.getFileSize(file)
|
||||
val model = FileModel(file, fileName, size, timestamp, false, file, false) { model ->
|
||||
removeAttachment(model.path)
|
||||
}
|
||||
|
||||
list.add(model)
|
||||
}
|
||||
attachments.value = list
|
||||
maxNumberOfAttachmentsReached.value = list.size >= MAX_FILES_TO_ATTACH
|
||||
|
||||
if (list.isNotEmpty()) {
|
||||
isFileAttachmentsListOpen.value = true
|
||||
Log.i("$TAG [${list.size}] attachment(s) added")
|
||||
Log.i("$TAG [${files.size}] attachment(s) added, in total ${list.size}] file(s) are attached")
|
||||
} else {
|
||||
Log.w("$TAG No attachment to display!")
|
||||
}
|
||||
|
|
@ -483,6 +552,7 @@ class SendMessageInConversationViewModel
|
|||
@UiThread
|
||||
fun stopVoiceMessageRecording() {
|
||||
coreContext.postOnCoreThread {
|
||||
chatRoom.stopComposing()
|
||||
stopVoiceRecorder()
|
||||
}
|
||||
}
|
||||
|
|
@ -490,6 +560,7 @@ class SendMessageInConversationViewModel
|
|||
@UiThread
|
||||
fun cancelVoiceMessageRecording() {
|
||||
coreContext.postOnCoreThread {
|
||||
chatRoom.stopComposing()
|
||||
stopVoiceRecorder()
|
||||
|
||||
val path = voiceMessageRecorder.file
|
||||
|
|
@ -516,7 +587,32 @@ class SendMessageInConversationViewModel
|
|||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun computeParticipantsList() {
|
||||
fun filterParticipantsList(filter: String) {
|
||||
Log.i("$TAG Filtering participants list using user-input [$filter]")
|
||||
if (filter.isEmpty() && participantsListFilter.isNotEmpty()) {
|
||||
participantsListFilter = ""
|
||||
computeParticipantsList()
|
||||
return
|
||||
}
|
||||
|
||||
if (filter.length >= participantsListFilter.length) {
|
||||
isComputingParticipantsList.postValue(true)
|
||||
participantsListFilter = filter
|
||||
val currentList = participants.value.orEmpty()
|
||||
val newList = currentList.filter {
|
||||
it.address.username.orEmpty().contains(filter) || it.avatarModel.contactName?.contains(filter) == true
|
||||
}
|
||||
participants.postValue(newList as ArrayList<ParticipantModel>)
|
||||
isComputingParticipantsList.postValue(false)
|
||||
} else {
|
||||
participantsListFilter = filter
|
||||
computeParticipantsList(filter)
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun computeParticipantsList(filter: String = "") {
|
||||
isComputingParticipantsList.postValue(true)
|
||||
val participantsList = arrayListOf<ParticipantModel>()
|
||||
|
||||
for (participant in chatRoom.participants) {
|
||||
|
|
@ -525,14 +621,18 @@ class SendMessageInConversationViewModel
|
|||
coreContext.postOnCoreThread {
|
||||
val username = clicked.address.username
|
||||
if (!username.isNullOrEmpty()) {
|
||||
participantUsernameToAddEvent.postValue(Event(username))
|
||||
participantUsernameToAddEvent.postValue(Event(username.substring(participantsListFilter.length)))
|
||||
}
|
||||
}
|
||||
})
|
||||
participantsList.add(model)
|
||||
|
||||
if (filter.isEmpty() || participant.address.asStringUriOnly().contains(filter) || model.avatarModel.contactName?.contains(filter) == true) {
|
||||
participantsList.add(model)
|
||||
}
|
||||
}
|
||||
|
||||
participants.postValue(participantsList)
|
||||
isComputingParticipantsList.postValue(false)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
|
|
@ -583,6 +683,7 @@ class SendMessageInConversationViewModel
|
|||
}
|
||||
else -> {}
|
||||
}
|
||||
chatRoom.composeVoiceMessage()
|
||||
|
||||
val duration = voiceMessageRecorder.duration
|
||||
val formattedDuration = SimpleDateFormat("mm:ss", Locale.getDefault()).format(duration) // duration is in ms
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import org.linphone.core.Address
|
|||
import org.linphone.core.ChatRoom
|
||||
import org.linphone.core.ChatRoomListenerStub
|
||||
import org.linphone.core.Conference
|
||||
import org.linphone.core.Friend
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.ui.main.viewmodel.AddressSelectionViewModel
|
||||
import org.linphone.utils.AppUtils
|
||||
|
|
@ -51,10 +52,6 @@ class StartConversationViewModel
|
|||
|
||||
val operationInProgress = MutableLiveData<Boolean>()
|
||||
|
||||
val chatRoomCreationErrorEvent: MutableLiveData<Event<Int>> by lazy {
|
||||
MutableLiveData<Event<Int>>()
|
||||
}
|
||||
|
||||
val chatRoomCreatedEvent: MutableLiveData<Event<String>> by lazy {
|
||||
MutableLiveData<Event<String>>()
|
||||
}
|
||||
|
|
@ -77,9 +74,7 @@ class StartConversationViewModel
|
|||
Log.e("$TAG Conversation [$id] creation has failed!")
|
||||
chatRoom.removeListener(this)
|
||||
operationInProgress.postValue(false)
|
||||
chatRoomCreationErrorEvent.postValue(
|
||||
Event(R.string.conversation_failed_to_create_toast)
|
||||
)
|
||||
showRedToast(R.string.conversation_failed_to_create_toast, R.drawable.warning_circle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -93,6 +88,11 @@ class StartConversationViewModel
|
|||
updateGroupChatButtonVisibility()
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun onSingleAddressSelected(address: Address, friend: Friend?) {
|
||||
createOneToOneChatRoomWith(address)
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun createGroupChatRoom() {
|
||||
coreContext.postOnCoreThread { core ->
|
||||
|
|
@ -111,7 +111,11 @@ class StartConversationViewModel
|
|||
params.isChatEnabled = true
|
||||
params.isGroupEnabled = true
|
||||
params.subject = groupChatRoomSubject
|
||||
params.securityLevel = Conference.SecurityLevel.EndToEnd
|
||||
if (LinphoneUtils.isEndToEndEncryptedChatAvailable(core)) {
|
||||
params.securityLevel = Conference.SecurityLevel.EndToEnd
|
||||
} else {
|
||||
params.securityLevel = Conference.SecurityLevel.None
|
||||
}
|
||||
params.account = account
|
||||
|
||||
val chatParams = params.chatParams ?: return@postOnCoreThread
|
||||
|
|
@ -149,9 +153,7 @@ class StartConversationViewModel
|
|||
} else {
|
||||
Log.e("$TAG Failed to create group conversation [$groupChatRoomSubject]!")
|
||||
operationInProgress.postValue(false)
|
||||
chatRoomCreationErrorEvent.postValue(
|
||||
Event(R.string.conversation_failed_to_create_toast)
|
||||
)
|
||||
showRedToast(R.string.conversation_failed_to_create_toast, R.drawable.warning_circle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -202,9 +204,7 @@ class StartConversationViewModel
|
|||
"$TAG Account is in secure mode, can't chat with SIP address of different domain [${remote.asStringUriOnly()}]"
|
||||
)
|
||||
operationInProgress.postValue(false)
|
||||
chatRoomCreationErrorEvent.postValue(
|
||||
Event(R.string.conversation_invalid_participant_due_to_security_mode_toast)
|
||||
)
|
||||
showRedToast(R.string.conversation_invalid_participant_due_to_security_mode_toast, R.drawable.warning_circle)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -237,9 +237,7 @@ class StartConversationViewModel
|
|||
} else {
|
||||
Log.e("$TAG Failed to create 1-1 conversation with [${remote.asStringUriOnly()}]!")
|
||||
operationInProgress.postValue(false)
|
||||
chatRoomCreationErrorEvent.postValue(
|
||||
Event(R.string.conversation_failed_to_create_toast)
|
||||
)
|
||||
showRedToast(R.string.conversation_failed_to_create_toast, R.drawable.warning_circle)
|
||||
}
|
||||
} else {
|
||||
Log.w(
|
||||
|
|
|
|||
|
|
@ -125,12 +125,12 @@ class ContactsListAdapter(
|
|||
|
||||
val previousItem = bindingAdapterPosition - 1
|
||||
val previousLetter = if (previousItem >= 0) {
|
||||
getItem(previousItem).contactName?.get(0).toString()
|
||||
getItem(previousItem).sortingName?.get(0).toString()
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
val currentLetter = contactModel.contactName?.get(0).toString()
|
||||
val currentLetter = contactModel.sortingName?.get(0).toString()
|
||||
val displayLetter = previousLetter.isEmpty() || currentLetter != previousLetter
|
||||
firstContactStartingByThatLetter = displayLetter
|
||||
|
||||
|
|
@ -160,7 +160,7 @@ class ContactsListAdapter(
|
|||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: ContactAvatarModel, newItem: ContactAvatarModel): Boolean {
|
||||
return false // oldItem & newItem are always the same because fetched from cache, so return false to force refresh
|
||||
return newItem.compare(oldItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,9 +20,7 @@
|
|||
package org.linphone.ui.main.contacts.fragment
|
||||
|
||||
import android.app.Dialog
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.provider.ContactsContract
|
||||
|
|
@ -170,20 +168,27 @@ class ContactFragment : SlidingPaneChildFragment() {
|
|||
|
||||
viewModel.openNativeContactEditor.observe(viewLifecycleOwner) {
|
||||
it.consume { uri ->
|
||||
val editIntent = Intent(Intent.ACTION_EDIT).apply {
|
||||
setDataAndType(uri.toUri(), ContactsContract.Contacts.CONTENT_ITEM_TYPE)
|
||||
putExtra("finishActivityOnSaveCompleted", true)
|
||||
try {
|
||||
val editIntent = Intent(Intent.ACTION_EDIT).apply {
|
||||
setDataAndType(uri.toUri(), ContactsContract.Contacts.CONTENT_ITEM_TYPE)
|
||||
putExtra("finishActivityOnSaveCompleted", true)
|
||||
}
|
||||
startActivity(editIntent)
|
||||
} catch (anfe: ActivityNotFoundException) {
|
||||
Log.e("$TAG Failed to open native contact editor with URI [$uri]: $anfe")
|
||||
}
|
||||
startActivity(editIntent)
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.openLinphoneContactEditor.observe(viewLifecycleOwner) {
|
||||
it.consume { refKey ->
|
||||
val action = ContactFragmentDirections.actionContactFragmentToEditContactFragment(
|
||||
refKey
|
||||
)
|
||||
findNavController().navigate(action)
|
||||
if (findNavController().currentDestination?.id == R.id.contactFragment) {
|
||||
val action =
|
||||
ContactFragmentDirections.actionContactFragmentToEditContactFragment(
|
||||
refKey
|
||||
)
|
||||
findNavController().navigate(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -213,8 +218,8 @@ class ContactFragment : SlidingPaneChildFragment() {
|
|||
}
|
||||
|
||||
viewModel.startCallToDeviceToIncreaseTrustEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { pair ->
|
||||
callDirectlyOrShowConfirmTrustCallDialog(pair.first, pair.second)
|
||||
it.consume { triple ->
|
||||
callDirectlyOrShowConfirmTrustCallDialog(triple.first, triple.second, triple.third)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -244,19 +249,15 @@ class ContactFragment : SlidingPaneChildFragment() {
|
|||
}
|
||||
|
||||
private fun copyNumberOrAddressToClipboard(value: String, isSip: Boolean) {
|
||||
val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val label = if (isSip) "SIP address" else "Phone number"
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText(label, value))
|
||||
|
||||
val message = if (isSip) {
|
||||
getString(R.string.sip_address_copied_to_clipboard_toast)
|
||||
} else {
|
||||
getString(R.string.contact_details_phone_number_copied_to_clipboard_toast)
|
||||
if (AppUtils.copyToClipboard(requireContext(), label, value)) {
|
||||
val message = if (isSip) {
|
||||
getString(R.string.sip_address_copied_to_clipboard_toast)
|
||||
} else {
|
||||
getString(R.string.contact_details_phone_number_copied_to_clipboard_toast)
|
||||
}
|
||||
(requireActivity() as GenericActivity).showGreenToast(message, R.drawable.check)
|
||||
}
|
||||
(requireActivity() as GenericActivity).showGreenToast(
|
||||
message,
|
||||
R.drawable.check
|
||||
)
|
||||
}
|
||||
|
||||
private fun shareContact(name: String, file: File) {
|
||||
|
|
@ -275,7 +276,11 @@ class ContactFragment : SlidingPaneChildFragment() {
|
|||
}
|
||||
|
||||
val shareIntent = Intent.createChooser(sendIntent, null)
|
||||
startActivity(shareIntent)
|
||||
try {
|
||||
startActivity(shareIntent)
|
||||
} catch (anfe: ActivityNotFoundException) {
|
||||
Log.e("$TAG Failed to start intent chooser: $anfe")
|
||||
}
|
||||
}
|
||||
|
||||
private fun inviteContactBySms(number: String) {
|
||||
|
|
@ -291,22 +296,26 @@ class ContactFragment : SlidingPaneChildFragment() {
|
|||
putExtra("address", number)
|
||||
putExtra("sms_body", smsBody)
|
||||
}
|
||||
startActivity(smsIntent)
|
||||
try {
|
||||
startActivity(smsIntent)
|
||||
} catch (anfe: ActivityNotFoundException) {
|
||||
Log.e("$TAG Failed to start SMS intent: $anfe")
|
||||
}
|
||||
}
|
||||
|
||||
private fun showTrustProcessDialog() {
|
||||
val initials = viewModel.contact.value?.initials?.value ?: "JD"
|
||||
val initials = viewModel.contact.value?.initials?.value.orEmpty()
|
||||
val picture = viewModel.contact.value?.picturePath?.value.orEmpty()
|
||||
val model = ContactTrustDialogModel(initials, picture)
|
||||
val dialog = DialogUtils.getContactTrustProcessExplanationDialog(requireActivity(), model)
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
private fun callDirectlyOrShowConfirmTrustCallDialog(contactName: String, deviceSipUri: String) {
|
||||
private fun callDirectlyOrShowConfirmTrustCallDialog(contactName: String, deviceName: String, deviceSipUri: String) {
|
||||
coreContext.postOnCoreThread {
|
||||
if (corePreferences.showDialogWhenCallingDeviceUuidDirectly) {
|
||||
coreContext.postOnMainThread {
|
||||
showConfirmTrustCallDialog(contactName, deviceSipUri)
|
||||
showConfirmTrustCallDialog(contactName, deviceName, deviceSipUri)
|
||||
}
|
||||
} else {
|
||||
val address = Factory.instance().createAddress(deviceSipUri)
|
||||
|
|
@ -317,11 +326,11 @@ class ContactFragment : SlidingPaneChildFragment() {
|
|||
}
|
||||
}
|
||||
|
||||
private fun showConfirmTrustCallDialog(contactName: String, deviceSipUri: String) {
|
||||
private fun showConfirmTrustCallDialog(contactName: String, deviceName: String, deviceSipUri: String) {
|
||||
val label = AppUtils.getFormattedString(
|
||||
R.string.contact_dialog_increase_trust_level_message,
|
||||
contactName,
|
||||
deviceSipUri
|
||||
deviceName
|
||||
)
|
||||
val model = ConfirmationDialogModel(label)
|
||||
val dialog = DialogUtils.getContactTrustCallConfirmationDialog(requireActivity(), model)
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue