Compare commits

...

408 commits

Author SHA1 Message Date
Sylvain Berfini
e6f897cb39 Change layout to default one (instead of audion only) when joining a conference using a SIP URI 2026-01-15 15:22:45 +01:00
Sylvain Berfini
97606578a4 Updated gradle to 9.1.0 2026-01-15 11:13:26 +01:00
Sylvain Berfini
48c9b4d7af Updated dependencies 2026-01-15 09:15:03 +01:00
Sylvain Berfini
7d0d1a22ee Fixed text alignment 2026-01-14 19:54:54 +01:00
Sylvain Berfini
d467699388 Factorized code related to copying data into clipboard, added exception catch 2026-01-14 11:53:06 +01:00
Sylvain Berfini
5ad7b5da14 Update chat room shortcuts each time a notification is about to be created + report 'use' when opening a conversation 2026-01-14 09:56:03 +01:00
Sylvain Berfini
50c922b581 Small UI changes to improve look & feel 2026-01-14 09:39:46 +01:00
Sylvain Berfini
b574bf420c Fixed file viewer hover color 2026-01-13 10:20:52 +01:00
Sylvain Berfini
3df3c8d741 Fixed generated avatar size sometimes small 2026-01-12 16:44:25 +01:00
Sylvain Berfini
4039da9c8a Improved meeting layout 2026-01-12 16:19:33 +01:00
Sylvain Berfini
3cec19126d Fixed contacts not updated with LDAP results if latest query fails 2026-01-12 15:23:29 +01:00
Sylvain Berfini
a816a956b8 Added missing hover effects 2026-01-12 15:12:14 +01:00
Sylvain Berfini
cec3639b73 Try to workaround race condition on slow phones that may happen with foreground service notification not displayed in time 2026-01-09 10:53:07 +01:00
Sylvain Berfini
07cae7eb12 When opening delivery status bottom sheet select 'first' not empty tab automatically 2026-01-09 10:30:11 +01:00
Sylvain Berfini
3b561275a4 Close search bar when opening bottom sheet and vice versa 2026-01-08 12:17:24 +01:00
Sylvain Berfini
3f868e02fe Remove highlight from previous match when navigating between search results 2026-01-08 12:06:13 +01:00
Sylvain Berfini
57644a34de Changed mentions color in chat bubble to app's primary 2026-01-08 11:45:46 +01:00
Sylvain Berfini
e306c8c7fc Do not rely on chatMessageSending callback for scenario where message(s) are queued because of delayed subscribe 2026-01-08 09:54:25 +01:00
Sylvain Berfini
dd9190df07 Fixed layout broken by recyclerview id change 2026-01-06 17:29:15 +01:00
Sylvain Berfini
d90861b5f3 Cancel search when making reply/edit/forward action on a message 2026-01-06 16:20:00 +01:00
Sylvain Berfini
9151898a4d Disable search direction after latest matching result found 2026-01-06 16:09:47 +01:00
Sylvain Berfini
fe788caf0e Added label when no participant match user input after typing @ in a group conversation 2026-01-06 12:18:50 +01:00
Sylvain Berfini
7e0353cc91 Prevent participants list from blinking if it hasn't changed, using RecyclerView will also improve performances a bit for conversations with a lot of participants 2026-01-06 10:47:54 +01:00
Sylvain Berfini
a897c127e5 Using onFileTransferTerminated instead of relying on FileTransferDone chat message state 2026-01-05 15:58:18 +01:00
Sylvain Berfini
50aa053c19 Filter participants list using user input after '@' 2026-01-05 15:15:02 +01:00
Sylvain Berfini
b88b6a8093 Fixed hidden reply area when adding file to send area 2026-01-05 14:19:08 +01:00
Sylvain Berfini
24d808b1a7 Hide presence SIP addresses from contact details & editor views 2026-01-05 11:58:35 +01:00
Sylvain Berfini
6f09853424 Fixed attaching file to conversation from third party app using shortcut if matching conversation is already displayed 2026-01-05 10:40:35 +01:00
Sylvain Berfini
a7593e07fc Added a setting to edit native contacts in-app 2026-01-05 09:24:57 +01:00
Sylvain Berfini
844b182df2 Bumped version code 2025-12-18 15:37:12 +01:00
Sylvain Berfini
d299b0b129 Added RC flag to allow disabling add contact feature 2025-12-17 09:43:30 +00:00
Sylvain Berfini
965b159139 Using newly added MWI API to only show the notification bar for the concerned account + call voicemail when clicking on it 2025-12-17 08:09:22 +00:00
Sylvain Berfini
00b8e59ade Updated CHANGELOG with 6.0.21 release info 2025-12-16 16:23:11 +01:00
Sylvain Berfini
be47deeb40 Fixed self avatar not displayed in call views 2025-12-15 14:15:13 +01:00
Sylvain Berfini
e8c67fdd6f Don't use connected bluetooth audio device (if any) for recording a voice message 2025-12-15 11:21:44 +01:00
Sylvain Berfini
ff98c15840 Fixed crash due to empty routes & when setting an empty one in account params 2025-12-15 09:40:15 +01:00
Sylvain Berfini
3711fd749e Bumped AGP to 8.13.2 2025-12-12 10:15:00 +01:00
Sylvain Berfini
dce7095f74 Added workaround for linphone-config:// URIs 2025-12-11 11:09:25 +01:00
Sylvain Berfini
d2b12159af Allow linphone-config URIs in QR codes scanned inside Linphone 2025-12-11 10:25:46 +01:00
Sylvain Berfini
618be9ee7c Fixed contact not updated when company or job title was removed from native contact 2025-12-08 15:25:23 +01:00
Sylvain Berfini
e1abcc6dca Fixed ContactLoader not notifying app of changes when editing a native friend through another app 2025-12-08 15:04:43 +01:00
Sylvain Berfini
2a9ef440b7 Code small improvements 2025-12-05 11:28:41 +01:00
Sylvain Berfini
77b933c5a8 Migrated translations from release/6.0 branch 2025-12-05 11:19:36 +01:00
Sylvain Berfini
6b56165d4f Bumped version code to 6.01.001 and updated CHANGELOG 2025-12-05 10:33:38 +01:00
Sylvain Berfini
61c79a86f7 Added seek to recordings player & media player 2025-12-05 10:04:13 +01:00
Sylvain Berfini
40d195e06b Updated dependencies 2025-12-04 12:03:54 +00:00
Sylvain Berfini
e173e402c2 Added answer/decline keyboard shortcuts to CallActivity 2025-12-04 12:55:52 +01:00
Sylvain Berfini
7817e6603c Force front camera as default device when leaving QR code fragment 2025-12-03 14:19:49 +01:00
Sylvain Berfini
bf4b5a51f5 Added back largeHeap in Manifest XML file 2025-12-02 13:26:18 +01:00
Sylvain Berfini
3ffda24b82 Workaround missing audio focus requests & wrong audio manager mode when TelecomManager APIs aren't available on device 2025-12-02 11:05:11 +01:00
Sylvain Berfini
c99acbb5e1 Added missing update unread chat message count when a message has been retracted 2025-12-01 10:57:08 +01:00
Sylvain Berfini
cc1cc7d929 Fixed crash seen on Crashlytics due to clipboard data text being null 2025-12-01 08:38:09 +00:00
Sylvain Berfini
6bcce4ddbf Fixed call recording wrong indicator in case UPDATE isn't answered 2025-11-25 12:57:09 +01:00
Sylvain Berfini
696a593cbc Prevent replying to retracted message with swipe action 2025-11-24 15:20:48 +01:00
Sylvain Berfini
88e474533e Fixed reply preview when a message has been deleted (locally or remotely) 2025-11-24 14:11:03 +01:00
Sylvain Berfini
8e76c60a38 Added more info to startup listener, also log 3 previous startup reasons 2025-11-24 11:52:17 +01:00
Sylvain Berfini
85aa50d8d8 Fixed in-call media encryption alignment 2025-11-24 10:35:16 +01:00
Sylvain Berfini
c496545023 Added missing try/catch around some startActivity to prevent not found exceptions 2025-11-21 16:36:22 +01:00
Sylvain Berfini
1183a9e1c2 Updated CHANGELOG & version code from release/6.0 branch 2025-11-21 10:01:34 +01:00
Sylvain Berfini
170cd6fccc Cancel voice message recording when starting editing already sent text message 2025-11-20 15:50:56 +01:00
Sylvain Berfini
bc7ac8be64 Fixed issue with recording player fragment layout using lateinit property model 2025-11-18 17:12:44 +01:00
Sylvain Berfini
5ed68e0171 Remove AuthInfo when creating CardDAV entry if synchronization fails 2025-11-18 14:38:22 +01:00
Sylvain Berfini
41e6776b32 Use newly available API to properly remove account and all associated data 2025-11-18 11:38:42 +01:00
Sylvain Berfini
e290a8c4ea Added resources shrink to release build 2025-11-18 10:43:12 +01:00
Sylvain Berfini
93e26f6c10 Fixed missing error toast when starting a group call/meeting and there's an error after adding participants 2025-11-18 10:23:10 +01:00
Sylvain Berfini
3f22a596db Ignore Telecom Manager endpoints availability/requests, using our own preferred endpoint policy (to workaround device disconnect/reconnect not always notified) 2025-11-17 12:22:54 +01:00
Sylvain Berfini
d5c836b8b5 Should prevent crash that may happen after picking ringtone if settings fragment was destroyed while new ringtone was being picked 2025-11-17 10:06:35 +01:00
Sylvain Berfini
9afcb6db15 Updated AGP to 8.13.1 2025-11-17 09:47:56 +01:00
Sylvain Berfini
a6f568497d Bumped dependencies 2025-11-06 09:04:43 +01:00
Sylvain Berfini
209c0df091 Prevent voice message recording when editing an already sent message 2025-11-06 08:50:39 +01:00
Sylvain Berfini
7b0de4185c Prevent message edit to overlap reply and vice-versa 2025-11-05 14:47:13 +01:00
Sylvain Berfini
89458ed826 Bumped firebase BoM 2025-11-03 10:18:34 +01:00
Sylvain Berfini
a3f86fbac0 Fixed toggling favorite flag on contact not adding/removing it from favorites list 2025-10-31 14:37:00 +01:00
Sylvain Berfini
28cee7f539 Hide LDAP verbose mode toggle setting as it has no effect in SDK 2025-10-31 09:44:20 +01:00
Sylvain Berfini
daa2f10f7b Bumped Kotlin version 2025-10-30 12:39:43 +01:00
Sylvain Berfini
e14ea0ac68 Fixed emoji reaction not visible when long pressing an image on a device with a small screen 2025-10-30 09:31:36 +01:00
Sylvain Berfini
c3ad96cd1f Fixed issue in contact layout 2025-10-27 15:24:18 +01:00
Sylvain Berfini
c6a0f25041 Fixed generated avatars color when switching between light/dark modes 2025-10-27 15:17:18 +01:00
Sylvain Berfini
7ab7136a5b Fixed contacts list cell clipping 2025-10-27 14:16:22 +01:00
Sylvain Berfini
3698e1673e Removed delete contact option for native ones, will be re-imported at next restart anyway 2025-10-27 11:20:37 +01:00
Sylvain Berfini
bdd5c8766b Fixed missing bottom margin 2025-10-27 10:30:26 +01:00
Sylvain Berfini
ce2b794936 Fixed conversation avatar not updated when subject changes 2025-10-22 10:02:28 +02:00
Sylvain Berfini
e267f46fd7 Prevent blinking avatars in list in case they didn't change 2025-10-22 09:29:48 +02:00
Sylvain Berfini
ab6911dd11 Fixed adding/editing CardDAV synchronized contact picture 2025-10-21 16:58:38 +02:00
Sylvain Berfini
b0283043ee Save generated avatar as files in cache for faster re-user and lower memory footprint 2025-10-21 15:38:50 +02:00
Sylvain Berfini
0e71a726c1 Fixed infinite LDAP queries loop in case it returns a result that doesn't match the request 2025-10-20 11:29:48 +02:00
Sylvain Berfini
d74ccb523e Prevent LDAP password to be removed after editing existing config 2025-10-20 10:52:06 +02:00
Sylvain Berfini
4dc1b9a903 Disable camera button while in conference with audio_only layout 2025-10-20 10:00:53 +02:00
Sylvain Berfini
45c756cfd6 Updated CHANGELOG & version code from release/6.0 branch 2025-10-16 11:01:31 +02:00
Sylvain Berfini
069997d780 Fixed displayer screen sharing participant name 2025-10-15 17:03:01 +02:00
Sylvain Berfini
e2c9e1196f Force all LDAP fields to be filled, added verbose mode toggle 2025-10-15 12:12:32 +02:00
Sylvain Berfini
e8c642b9c6 Bumped firebase & gms version 2025-10-15 11:02:38 +02:00
Sylvain Berfini
d75c48cd34 Fixed misleading method name 2025-10-15 09:59:13 +02:00
Sylvain Berfini
d9ab840570 Prevent black screen when trying to scan a QR code in assistant right after granting the app the CAMERA permission (on some devices) 2025-10-14 14:23:41 +02:00
Sylvain Berfini
5ee3ba4ea9 Updated warning about conversations that aren't E2E encrypted 2025-10-13 11:55:33 +02:00
Sylvain Berfini
d694789d4b Trying to troubleshoot missing participant video when changing conference layout sometimes 2025-10-13 11:16:31 +02:00
Sylvain Berfini
b71249ea36 Fixed typos in French translation 2025-10-13 09:40:11 +02:00
Sylvain Berfini
7855d4e1db Updated lock open icon & warning color to increase contrast 2025-10-13 09:38:34 +02:00
Sylvain Berfini
7e2527c46c Fixed wrong label for LDAP form field 2025-10-06 16:19:43 +02:00
Sylvain Berfini
d16dbcf0fd Fixed proximity sensor not turned ON when call is answered from notification 2025-10-06 11:08:54 +02:00
Sylvain Berfini
1d28ce1846 Added logs to help troubleshoot contact matching issue 2025-10-06 10:28:23 +02:00
Sylvain Berfini
2ea38abdfe Reworked proxy/outbound proxy settings in account advanced settings 2025-10-02 16:12:00 +02:00
Sylvain Berfini
416cc6ea7f Only display missing permissions in assistant PermissionsFragment 2025-10-02 15:17:05 +02:00
Sylvain Berfini
6dc4790597 Simplified code using newly added API in SDK 2025-10-02 10:51:23 +02:00
Sylvain Berfini
f8556aa46b Hide suggestions SIP address domain if it matches default account SIP identity one + fixed suggestion avatar for phone numbers 2025-10-01 15:12:14 +02:00
Sylvain Berfini
df09bcad76 Hide SIP address field from contact editor when hide SIP addresses flag is set, and fixed issue where dialog with only 1 item would be displayed 2025-10-01 11:41:40 +02:00
Sylvain Berfini
0ca4eba63b Fixed contacts presence subscribe being only enabled for default domain account, added setting to disable presence 2025-10-01 10:01:48 +02:00
Sylvain Berfini
c556d14fb0 Fixed ConcurrentModificationException that could happen during contact edition 2025-09-29 13:59:50 +02:00
Sylvain Berfini
6cb78c8c59 Bumped dependencies 2025-09-26 11:57:52 +02:00
Sylvain Berfini
61517461dd Use account recovery token FlexiAPI endpoint 2025-09-23 20:32:13 +02:00
Sylvain Berfini
1fdc2bcc58 Fixed no suggestion flag not applied for filter text input in some screens (mostly participant pickers) 2025-09-22 16:28:10 +02:00
Sylvain Berfini
8f3415f6fa Reduce limit of attempts to change audio device in Android framework + return correct value if it failed 2025-09-22 14:58:44 +02:00
Sylvain Berfini
ae7a3c5bce Load contents by chunks instead of loading all of them at once 2025-09-17 16:30:41 +02:00
Sylvain Berfini
31e15ddfca Prevent app crash when trying to open a corrupted PDF sent/received by chat 2025-09-17 13:36:15 +02:00
Sylvain Berfini
808dc92cd7 Added PDF file preview in conversation (message bubble + documents list) 2025-09-17 12:18:17 +02:00
Sylvain Berfini
99936e8f75 Removed font padding on main fragments' titles 2025-09-17 09:22:10 +02:00
Sylvain Berfini
2ce07b5e89 Updated CHANGELOG & version code from release/6.0 branch 2025-09-15 14:22:44 +02:00
Sylvain Berfini
9d3ef9e8a5 Fix for empty fragment still opened after device rotation if user clicked on the empty part 2025-09-15 11:26:30 +02:00
Sylvain Berfini
5f17dd8534 Added menu icon in top bar next to current profile avatar + fixed layout icon while in conference 2025-09-15 09:29:22 +02:00
Sylvain Berfini
719b28f0ab Fixed account labelled as Disabled instead of Disconnected if network isn't reachable 2025-09-12 15:09:39 +02:00
Sylvain Berfini
6f1439756e Improved bodyless friendlist presence received processing 2025-09-11 11:14:31 +02:00
Sylvain Berfini
7fdbaf5fd6 Updated dependencies 2025-09-11 09:14:11 +02:00
Sylvain Berfini
4639e054bb Ask CallActivity to finish if no call found when trying to answer/hangup 2025-09-08 10:55:19 +02:00
Sylvain Berfini
504f6e2a2c Fixed missing conference subject when calling it's SIP URI without having the conference info 2025-09-05 15:16:56 +02:00
Sylvain Berfini
1e6f501dee Fixed mute mic / toggle speaker buttons background changing color when pressing the bottom bar empty space 2025-09-05 13:50:33 +02:00
Sylvain Berfini
633aee829a Updated numpad digits from cirlces to squircles to make them bigger 2025-09-05 12:08:39 +02:00
Sylvain Berfini
38ffac31b4 Added missing floating action button for dialpad on call transfer fragment 2025-09-05 11:59:19 +02:00
Sylvain Berfini
6e40e3f75f Updated AGP to 8.13.0 2025-09-03 13:28:21 +02:00
Sylvain Berfini
7d7900e081 Fixed media encryption label show end-to-end encrypted call while in conference instead of point-to-point 2025-09-03 08:42:02 +02:00
Sylvain Berfini
f0e899bb95 Updated CHANGELOG & version code from release/6.0 branch 2025-09-02 16:53:44 +02:00
Sylvain Berfini
db7ca6793b Added swipe/pull to refresh on contacts list when a CardDAV friend list is configured to force the synchronization 2025-09-02 10:39:37 +02:00
Sylvain Berfini
ac521557ce Bumped AGP & Firebase BoM dependencies 2025-09-01 09:56:05 +02:00
Sylvain Berfini
b5babae39a Updated telecom dependency 2025-08-28 18:29:11 +02:00
Sylvain Berfini
ce13d4c7d4 Reworked how meetings are cancelled/deleted from user perspective 2025-08-27 12:12:36 +02:00
Sylvain Berfini
e9cc03891b Remove update available alert for two dependencies 2025-08-26 15:31:22 +02:00
Sylvain Berfini
98bf3daed8 Use newly added SDK API to merge friend list 2025-08-25 16:59:00 +02:00
Sylvain Berfini
a5cee98a57 Updated CHANGELOG & version code from release/6.0 branch 2025-08-25 10:12:56 +02:00
Sylvain Berfini
26e391cbf8 Fixed contacts not displayed in app after being received through bodyless presence 2025-08-21 10:05:21 +02:00
Sylvain Berfini
0add60c628 Remove legacy shortcuts, follow Android guidelines and create chat room shortcuts when a message is sent or received in it, not at the start 2025-08-19 17:18:00 +02:00
Sylvain Berfini
fc90a95e94 Removed trash icon from retract message placeholder 2025-08-19 12:07:56 +02:00
Sylvain Berfini
c153b2d928 Hide reply/forward actions for retracted message 2025-08-19 11:48:27 +02:00
Sylvain Berfini
881e2c217b Reworked GenericAddressPicker related code, updated CorePreferences getters to be @AnyThread instead of @WorkerThread 2025-08-19 11:29:16 +02:00
Sylvain Berfini
77d744d020 Bumped dependencies 2025-08-19 10:46:43 +02:00
Sylvain Berfini
5a16761fdf Fixed methods called from wrong thread, other code cleanup / improvements 2025-08-19 10:38:54 +02:00
Sylvain Berfini
e0ff593f3d Prevent encrypted.pref file from being backed up, at best it won't work at worst it will prevent VFS from being turned on 2025-08-14 12:35:07 +02:00
Sylvain Berfini
71bb569fde Scroll to latest match during chat message search in case user scrolled away 2025-08-14 10:38:48 +02:00
Sylvain Berfini
9096225b45 Bumped dependencies 2025-08-14 09:41:08 +02:00
Sylvain Berfini
ad0037fe4c Do not show delete for everyone/delete locally choice dialog when removing received message 2025-08-14 09:09:55 +02:00
Sylvain Berfini
7eed9c06d3 Reworked some logs in ContactsManager, do not show ContactNumberOrAddressModel if phone number couldn't be turned into an Address 2025-08-11 14:58:17 +02:00
Sylvain Berfini
e2dabf5448 Updated CHANGELOG & version code from release/6.0 branch 2025-08-11 11:51:57 +02:00
Sylvain Berfini
08c72dbb8c Prevent fatal error due to changes in SDK preventing stopping Core from within iterate() loop, fixed leaving assistant after remote provisioning if no account was configured 2025-08-11 10:11:45 +02:00
Sylvain Berfini
f99b51d572 Use newly added Queued chat message state 2025-08-07 18:07:07 +02:00
Sylvain Berfini
8f0f6581b2 Use participant device display name in conference if device name is empty 2025-08-07 16:41:41 +02:00
Sylvain Berfini
fac6e42c22 Added edit/retract message features 2025-08-06 15:38:45 +00:00
Sylvain Berfini
e8d3c8750a Updated CHANGELOG & version code from release/6.0 branch 2025-08-06 17:21:11 +02:00
Sylvain Berfini
dee932da42 Reset forground service wait to stop flag when first call starts, just in case 2025-08-05 12:07:56 +00:00
Sylvain Berfini
2f4fd3da18 Fixed method called from main thread instead of core's one 2025-08-05 10:45:21 +02:00
Sylvain Berfini
22ae4e372f Fixed generated avatar for SIP URIs without username 2025-08-04 17:12:10 +02:00
Sylvain Berfini
95bd14bdd4 Quick workaround to fix missing favorites from address selection fragment 2025-08-04 14:41:45 +02:00
Sylvain Berfini
ffabd02f31 Fixed microphone not recording audio in background if SIP dialog doesn't reach Updating state 2025-08-04 13:51:00 +02:00
Sylvain Berfini
0bb7761db9 Fix outgoing call in full screen 2025-08-01 10:56:50 +02:00
Sylvain Berfini
50418f5dbb Updated AGP to 8.12.0 2025-08-01 09:37:37 +02:00
Sylvain Berfini
e800249445 Updated CHANGELOG & version code from release/6.0 branch 2025-07-31 23:58:11 +02:00
Sylvain Berfini
ec5b6e5707 Updated dependencies 2025-07-31 13:40:33 +02:00
Sylvain Berfini
97c6c0b553 Updated bell and bell_slash icons 2025-07-30 18:46:10 +02:00
Sylvain Berfini
95ce77e0e4 Prevent crash in message notification if person name is empty 2025-07-28 11:49:36 +02:00
Sylvain Berfini
243a6d8cb2 Hide numpad (if visible) from start call fragment when going back using gesture/click 2025-07-28 11:30:16 +02:00
Sylvain Berfini
e98318a23d Fixed numpad padding causing not centered # 2025-07-28 10:38:12 +02:00
Sylvain Berfini
ace0a3f61e Bumped dependencies 2025-07-26 11:57:59 +02:00
Sylvain Berfini
b3ac16052f Prevent exception causing crash if no Activity is available to create document 2025-07-26 11:45:39 +02:00
Sylvain Berfini
865216d717 Remove font padding when chat message only contains emoji 2025-07-26 10:41:40 +02:00
Sylvain Berfini
670eecf0d6 Fixed duplicated week label if 'no meeting today' is first of the week, improved layout a bit 2025-07-25 17:23:08 +02:00
Sylvain Berfini
8dcb18d059 Try to fix video issue when starting video call and video is declined when answered by remote end 2025-07-25 17:23:00 +02:00
Sylvain Berfini
92672bde0a Prevent favourites contacts from missing due to magic search limit 2025-07-25 17:22:51 +02:00
Sylvain Berfini
ada6f35d92 Fixed muted call if Telecom Manager requested a quick mute/unmute 2025-07-25 17:22:42 +02:00
Sylvain Berfini
332828dc7c Fixed french translation 2025-07-23 13:42:01 +02:00
Sylvain Berfini
595ff96d50 Use newly added chat room compose APIs to notify wether text or voice recording is being composed 2025-07-18 15:20:25 +00:00
Sylvain Berfini
6cdcdec373 Updated CHANGELOG & version code from release/6.0 branch 2025-07-18 15:17:51 +02:00
Sylvain Berfini
3098c3e68e Fixed file viewer opening in call activity's if there is one in PiP 2025-07-17 11:55:06 +02:00
Sylvain Berfini
2c9d627794 Updated dependencies 2025-07-17 07:54:16 +00:00
Sylvain Berfini
70df098ee4 Fixed crashes related to lateinit property used before being initialized found on Crashlytics 2025-07-17 07:43:01 +00:00
Sylvain Berfini
c32bac7b07 Added missing operation in progress dialog during account REGISTER in assistant 2025-07-15 16:32:40 +02:00
Sylvain Berfini
3d41a4d221 Added importantForAccessibility=no to all separator views 2025-07-15 14:32:10 +02:00
Sylvain Berfini
dfa87e4088 Improve accessibility by using labelFor item in XML layouts, fixed & uniformized margins in settings 2025-07-15 13:59:04 +02:00
Sylvain Berfini
d6b43c474b Prevent huge and pixelated emojis in picker when device is connected to an external screen 2025-07-15 10:22:43 +02:00
Sylvain Berfini
9d0f2cafc9 Show system notification when account registration is failed 2025-07-13 10:51:57 +02:00
Sylvain Berfini
98cc173d2e Fixed reactions list in bottom sheet not properly updated if opened when changes happen 2025-07-11 16:03:02 +02:00
Sylvain Berfini
4ae046a166 Updated CHANGELOG & version code from release/6.0 branch 2025-07-11 14:39:34 +02:00
Sylvain Berfini
62180140b7 Updated AGP to 8.11.1 2025-07-11 14:28:45 +02:00
Sylvain Berfini
899129d4bc Fixed CardDAV settings when it's read only 2025-07-10 16:22:10 +02:00
Sylvain Berfini
c4618702ab Fixed in-call top bar notification label when going from two calls to one 2025-07-10 13:47:49 +02:00
Sylvain Berfini
8c7c7b40c3 Show user when magic search didn't returned all available results 2025-07-10 11:25:52 +02:00
Sylvain Berfini
856f3e7f94 Fixed issues in French strings 2025-07-09 13:58:17 +02:00
Sylvain Berfini
f7be887984 Fixed wrong english string for password hint in auth requested dialog 2025-07-08 10:32:14 +02:00
Sylvain Berfini
028ece407c Add a way to go to Help page from assistant 2025-07-07 15:19:15 +02:00
Sylvain Berfini
bb81957aab Improved audio device name in advanced settings 2025-07-04 15:16:22 +02:00
Sylvain Berfini
da581c3737 Added enabled toggle for LDAP plugins allowing to disable it without deleting it + reduced max displayed contacts 2025-07-04 11:04:09 +02:00
Sylvain Berfini
461537aa9c Prevent auth dialog when failed to login in assistant and then successfully login another account 2025-07-04 10:15:50 +02:00
Sylvain Berfini
1a54746a80 Added support for HDMI devices 2025-07-03 17:10:32 +02:00
Sylvain Berfini
be24224f4c Bumped dependencies 2025-07-03 09:47:10 +02:00
Sylvain Berfini
f2cdb92858 Reworked LIME algo settings as you can set multiple and order matters 2025-07-02 16:18:08 +02:00
Sylvain Berfini
aa0255bcfd Use read-only information from FriendList if any to hide edit/delete/mark as favortie buttons in contact details & contacts list long press menu 2025-07-01 09:24:58 +02:00
Sylvain Berfini
ff425089c7 Reworked UI for incoming call screen when screen is locked 2025-07-01 08:49:32 +02:00
Sylvain Berfini
b4c2a52bf7 Added LIME algorithm dropdown in account advanced settings if developer mode is enabled 2025-06-30 17:04:26 +02:00
Sylvain Berfini
f397456879 Updated CHANGELOG & version code to match 6.0.10 release 2025-06-27 10:10:37 +02:00
Sylvain Berfini
5337ab6413 Make sure an account is set in CallParams when initiating a call 2025-06-26 13:06:36 +02:00
Sylvain Berfini
998f969c0f Increased single media max height for small screens in portrait orientation or large screens 2025-06-26 12:04:12 +02:00
Sylvain Berfini
58410ee112 Fixed mentions in chat message if there are more than one 2025-06-26 09:53:59 +02:00
Sylvain Berfini
6c97ee9176 Bumped dependencies 2025-06-25 16:57:52 +02:00
Sylvain Berfini
187946bf34 Fixed chat bubble when reply original message is missing (reply UI is hidden) 2025-06-24 16:45:57 +02:00
Sylvain Berfini
3c40bf3d6f Show event at top of conversation in case of unsecure conversation (like we do for e2e encrypted ones) + icon under conversation title 2025-06-23 11:42:07 +02:00
Sylvain Berfini
b7a9f4ba8e Fixed log + missing elevation for nav bar in landscape 2025-06-23 11:22:00 +02:00
Sylvain Berfini
4a1c5304b1 Fixed issue with encryption label being stuck in 'waiting for encryption' 2025-06-23 09:34:28 +02:00
Sylvain Berfini
67e3c51a84 Code cleanup, bumped dependencies & gradle 2025-06-20 13:54:47 +02:00
Sylvain Berfini
4d8ab32da7 Using a style for top bar icons to prevent duplicating code 2025-06-20 11:30:46 +02:00
Sylvain Berfini
62ff36e7a7 Reworked UI for dialogs buttons 2025-06-19 15:34:15 +02:00
Sylvain Berfini
6f80409086 Trying to prevent BT not being used automatically when it connects during a call + improved AudioUtils logs 2025-06-19 13:24:11 +02:00
Sylvain Berfini
fd3f746e3d Prevent race condition crash with VuMeter when call is ending 2025-06-19 12:03:24 +02:00
Sylvain Berfini
42fbbc51fd Improved NotificationsManager code, added call notification PendingIntent's ActivityOptions bundle, use PendingIntent in CoreContext to start CallActivity 2025-06-19 09:53:36 +02:00
Sylvain Berfini
60c74ee5b2 Fixed logs related to developer settings 2025-06-17 20:27:46 +02:00
Sylvain Berfini
79212a8757 Improved process to enable developer settings 2025-06-17 20:19:21 +02:00
Sylvain Berfini
1307ec5471 Added developer setting to change push notification compatible domains list 2025-06-17 17:38:15 +02:00
Sylvain Berfini
c62f549521 Added vu meter developer setting 2025-06-17 16:50:43 +02:00
Sylvain Berfini
9ba2684f31 Using contextClickListener to open bottom sheets/menus when mouse right button is clicked 2025-06-17 14:23:47 +02:00
Sylvain Berfini
4b631a19ef Fixed extra action visible under clear search field icon + fixed search field hint text color 2025-06-17 11:11:48 +02:00
Sylvain Berfini
496279d724 Added pressed/hover effect to icons that can be clicked 2025-06-17 10:40:34 +02:00
Sylvain Berfini
c25ed404dc Added missing hint to every top bar filter/search field, changed empty fragment background color to match the one on desktop app, removed useless title in call history details 2025-06-16 17:12:49 +02:00
Sylvain Berfini
a81973e4cf Using style to factorize popup menus attributes, added hover effect (same as pressed) & improved some layouts 2025-06-16 15:10:19 +02:00
Sylvain Berfini
654e790a6d Also handle text waiting to be shared in newly added top bar 2025-06-16 13:39:53 +02:00
Sylvain Berfini
6602c7692b Reworked MainActivity's top bar, split alerts & in-call notifications, added file sharing dedicated one 2025-06-16 08:48:56 +00:00
Sylvain Berfini
cc57244b56 Hide SIP address/phone number picker dialog if contact has exactly one SIP address matching the default domain and currently default account domain 2025-06-16 09:36:12 +02:00
Sylvain Berfini
19df3b07dc Increase text size when chat bubble only contains emoji character(s) 2025-06-13 16:22:44 +02:00
Sylvain Berfini
4ef9a2bdf3 Increase number of columns in conversation's media list when screen is large 2025-06-13 15:30:25 +02:00
Sylvain Berfini
61be1d21d5 Improved UI on large tablets 2025-06-13 15:00:55 +02:00
Sylvain Berfini
8148354901 Added enable/disable speaker to active call notification 2025-06-13 11:25:28 +02:00
Sylvain Berfini
ae39d79420 Various improvements 2025-06-13 09:20:01 +02:00
Sylvain Berfini
2bd0de4af1 Fixed group conversation creation if LIME server URL not set 2025-06-10 12:07:45 +02:00
Sylvain Berfini
cb27b35984 Updated changelog & version code/name from release/6.0 branch 2025-06-06 15:47:32 +02:00
Sylvain Berfini
316bc6698a Added user guide link in Help section, factorized code for URL opening, added missing ScrollView to help & debug layouts 2025-06-06 11:53:22 +02:00
Sylvain Berfini
fea1dbe5ca Display last message timestamp in conversations list instead of chat room last updated timestamp 2025-06-05 10:28:58 +02:00
Sylvain Berfini
85679dcc43 Added vu meter for playback volume + setting to enable vu meters (disabled by default) 2025-06-05 09:51:21 +02:00
Sylvain Berfini
32060f6830 Bumped dependencies 2025-06-05 09:39:23 +02:00
Sylvain Berfini
dfdc26a575 Prevent crash for call notification due to empty person name, using all identification fields from vCard (first & last names, organization, job title) 2025-06-02 14:08:56 +02:00
Sylvain Berfini
9e1d358f4e Check if outgoing early-media call is really doing video instead of assuming it is 2025-06-02 11:01:30 +02:00
Sylvain Berfini
90922568b5 Prevent port from being set in third party account SIP identity + update existing accounts to remove port from identity 2025-06-02 10:54:32 +02:00
Sylvain Berfini
d212b7b06e Added setting to hide contacts without SIP address nor phone number 2025-05-30 10:09:56 +02:00
Sylvain Berfini
4cb83980ba Bumped AGP 2025-05-30 09:42:33 +02:00
Sylvain Berfini
def52f69ad Only refresh conversation list cell when a message is deleted, prevents blinking 2025-05-29 11:40:42 +02:00
Sylvain Berfini
17ce34aba7 Updated changelog & version code/name from release/6.0 branch 2025-05-23 15:04:55 +02:00
Sylvain Berfini
1833b1985d Fixed sent files size missing 2025-05-22 14:45:31 +02:00
Sylvain Berfini
5256ee79c6 Use gray background for file preview in attachment area to increase contrast, improved remove file from attachments icon size and position 2025-05-22 10:49:13 +02:00
Sylvain Berfini
27e59a5f8b Using utils method to check whether call has video enabled or not 2025-05-21 13:41:24 +00:00
Sylvain Berfini
cea2d49778 Trying to workaround hearing aids issue in Telecom Manager 2025-05-21 15:19:42 +02:00
Sylvain Berfini
c0f67d01fe Prevent crash in MediaViewerFragment if media player wasn't initialized 2025-05-21 10:11:18 +02:00
Sylvain Berfini
b6279b03c0 Make sure that files grid in chat bubble is using at most 3 columns 2025-05-20 16:33:22 +02:00
Sylvain Berfini
21398c7b37 Added sliding button to answer/decline incoming call if device screen is locked 2025-05-20 15:53:21 +02:00
Sylvain Berfini
4cb7ea1965 Showing files in square area like media when more than one in a single chat message 2025-05-20 10:45:27 +02:00
Sylvain Berfini
7aae03f1f9 Fixed missing margin in media grid for audio files 2025-05-19 13:37:45 +02:00
Sylvain Berfini
25d13f44c7 Prevent 1-1 events for conference joined/left + temporary read only state 2025-05-16 15:51:28 +02:00
Sylvain Berfini
81d0da4241 Updated coil dependency 2025-05-16 12:14:37 +02:00
Sylvain Berfini
1556abc79e Fixed logs sharing server URL setting 2025-05-16 11:19:45 +02:00
Sylvain Berfini
28b6bd7e90 Updated changelog & version code/name from release/6.0 branch 2025-05-16 11:13:58 +02:00
Sylvain Berfini
1c3173b871 Moved call related advanced parameters into dedicated fragment 2025-05-16 09:51:32 +02:00
Sylvain Berfini
17588de5a9 Do not delete chat rooms when removing account, will cause leaving groups in case of multi device 2025-05-16 09:40:08 +02:00
Sylvain Berfini
502c6413ee Reworked bottom nav bar (in portrait) 2025-05-16 07:37:06 +00:00
Sylvain Berfini
02cbb45de9 Trying to prevent another race condition in notifications manager leading to foreground service not being started before being stopped 2025-05-16 09:11:38 +02:00
Sylvain Berfini
f5852a7b3e Prevent bottom nav bar titles from being cropped when font size is increased 2025-05-14 08:39:48 +02:00
Sylvain Berfini
cfec621787 Fixed group chat event icons 2025-05-13 16:35:51 +02:00
Sylvain Berfini
6847227f1a Reworked click on SIP URI in chat message to prevent long press on it from starting the call 2025-05-13 14:02:54 +02:00
Sylvain Berfini
f1fdb186ec Reworked unread count indicators 2025-05-13 13:48:15 +02:00
Sylvain Berfini
d822cbc827 Make sure after a remote provisioning a default account has been set 2025-05-13 10:15:42 +02:00
Sylvain Berfini
627f881364 Make sure speaker audio device is used if available when incoming early media call is ringing 2025-05-13 10:09:50 +02:00
Sylvain Berfini
244061c0b1 Fixed black thumbnails when joining conference without bundle mode 2025-05-12 16:23:51 +02:00
Sylvain Berfini
dc2b94ca4d Fixed broken link in README 2025-05-12 13:23:05 +02:00
Sylvain Berfini
7c78b021db Removed some debug logs, improved findContactByAddress performances a bit 2025-05-12 10:29:05 +02:00
Sylvain Berfini
d113797dfb Added auto answer with video in both directions advanced call setting 2025-05-12 09:56:38 +02:00
Sylvain Berfini
926b8d4dc1 Updated dependencies 2025-05-09 11:09:20 +02:00
Sylvain Berfini
85e24e25bf Added missing toast events observer 2025-05-07 16:12:34 +02:00
Sylvain Berfini
1c1729f3f0 Fixed meeting list yesterday item still displayed as today if list isn't reloaded in two days 2025-05-07 11:13:51 +02:00
Sylvain Berfini
bcce9a9ba1 Updated AGP to 8.10 2025-05-07 11:08:00 +02:00
Sylvain Berfini
a496e2bf56 Forgot to disable IMDN bottom sheet for incoming messages in groups 2025-05-06 14:33:19 +02:00
Sylvain Berfini
73237ee335 Added toast for meeting update error + fixed other toasts 2025-05-06 12:53:28 +02:00
Sylvain Berfini
b293bf7f2f Added back Weblate section to README 2025-05-05 15:42:36 +02:00
Sylvain Berfini
4689b7c7da Fixed app reloading lists too many times at startup when looking for friends in remote contact directories such as LDAP/CardDAV 2025-05-05 14:50:22 +02:00
Sylvain Berfini
e38040428b Improved empty lists (contacts & conversations) labels + added button to let user know it can change account 2025-05-05 13:52:28 +02:00
Sylvain Berfini
b740409642 Update conversations list after clearing conversation history 2025-05-05 12:01:23 +02:00
Sylvain Berfini
99a5ed23f6 Updated CHANGELOG & version code from release/6.0 branch 2025-05-02 14:37:23 +02:00
Sylvain Berfini
c9a3a01733 Improved empty SIP contacts list 2025-04-30 15:40:49 +02:00
Sylvain Berfini
966f713f19 Increased margin for clean/share logs button in troubleshooting fragment 2025-04-29 17:15:04 +02:00
Sylvain Berfini
344afdfcfa Moved print logs in logcat & file sharing URL settings to developper section, added logs upload file sharing server URL setting, added setting to disable crashlytics logs collection 2025-04-29 16:54:20 +02:00
Sylvain Berfini
2634945b8d Improved chat room lookup while in conference 2025-04-29 14:18:52 +02:00
Sylvain Berfini
2713c82ca3 Added content description french translation 2025-04-29 09:02:36 +00:00
Sylvain Berfini
1a813ee11e Prevent crash due to uncaught exception 2025-04-29 09:33:38 +02:00
Sylvain Berfini
e5cec2d45c Another attempt at fixing crashes related to in-call service never truly started as foreground before being stopped 2025-04-28 16:06:09 +02:00
Sylvain Berfini
6e9c6d1b33 Fixed group chat events icon 2025-04-28 11:26:35 +02:00
Sylvain Berfini
056abd629f Fixed newly created contact not appearing in contacts list 2025-04-24 10:10:15 +02:00
Sylvain Berfini
6c86af747b Improved VFS confirmation dialog message 2025-04-24 10:04:10 +02:00
Sylvain Berfini
f7790fbed7 Bumped AGP version & splashscreen dependency 2025-04-24 09:47:10 +02:00
Sylvain Berfini
90524da610 Fixed chat room lookup while in call 2025-04-23 10:18:25 +02:00
Sylvain Berfini
616b7bb70f Prevent crash & show error toast when trying to open a password protected PDF 2025-04-23 09:06:51 +02:00
Sylvain Berfini
985a304df9 Updated CHANGELOG & version code from release/6.0 branch 2025-04-18 11:06:19 +02:00
Sylvain Berfini
cd35f213c1 Fixed crash due to missing foreground service if OS denies call notification until foreground Service was started + fixed crash if call is ended before CoreInCallService was started and foreground service notification sent 2025-04-18 09:53:57 +02:00
Sylvain Berfini
dcbc837106 Apply workaround when making a call to a SIP URI having a phone number as username & IP as domain 2025-04-17 14:30:23 +02:00
Sylvain Berfini
5ef7eab0c5 Added microphone volume vu meter 2025-04-17 13:07:15 +02:00
Sylvain Berfini
c64bd5bc1c Fixed numpad dial button while transfering a call 2025-04-17 09:45:53 +02:00
Sylvain Berfini
afa041baf6 Hide account creation form when device doesn't support push notifications 2025-04-17 09:45:02 +02:00
Sylvain Berfini
94b6db6a08 Fixed build with latest SDK 2025-04-17 09:40:52 +02:00
Sylvain Berfini
6ba8760be7 Improved called account display 2025-04-15 11:41:21 +02:00
Sylvain Berfini
b1b1ab0d8a Updated CHANGELOG & version code from release/6.0 branch 2025-04-11 10:28:59 +02:00
Sylvain Berfini
518ecc1823 Added a list of domain for which to show push notification settings 2025-04-11 09:38:55 +02:00
Sylvain Berfini
e2dfd95857 Prevent crash in HelpViewModel if app is built without Firebase 2025-04-10 14:12:39 +02:00
Sylvain Berfini
51d725c757 Quick code cleanup 2025-04-10 13:24:10 +02:00
Sylvain Berfini
af3b1fa418 Bumped dependencies 2025-04-10 10:04:30 +02:00
Sylvain Berfini
bc9a6581b1 Added logs to call transfer (blind & attended) 2025-04-10 09:45:03 +02:00
Sylvain Berfini
26df085df3 Hide push notification setting in third party SIP accounts parameters, they won't work anyway + disable push for existing third party SIP accounts when migrating to 6.0.4 2025-04-09 10:28:23 +02:00
Sylvain Berfini
c08157b659 Removed code no longer needed, done by SDK now + prevent onContactsLoaded() callback to be triggered too many times when fetching multiple addresses from remote contacts directories 2025-04-07 09:54:14 +02:00
Sylvain Berfini
910527ef1b Updated CHANGELOG & version code from release/6.0 branch 2025-04-04 12:59:34 +02:00
Sylvain Berfini
8577571e67 Show operation in progress during contact search 2025-04-04 10:07:32 +02:00
Sylvain Berfini
dbca62bea9 Added hidden developer settings 2025-04-03 17:31:14 +02:00
Sylvain Berfini
5e9be7d10b Show alert when default account is disabled 2025-04-03 14:52:16 +02:00
Sylvain Berfini
836deaae99 Fixed no default account issue when removing currently default one 2025-04-03 13:06:49 +02:00
Sylvain Berfini
a2680028ce Keep attach file icon when keyboard is opened in chat instead of emoji picker 2025-04-03 11:36:50 +02:00
Sylvain Berfini
c8ff7262d4 Follow contacts list filter for every contact/address picker 2025-04-03 10:47:54 +02:00
Sylvain Berfini
06d8e903fc Revert "Trying to prevent bottom bar from disappearing sometimes", trying better fix instead
This reverts commit 317a7c4417.
2025-04-03 09:53:53 +02:00
Sylvain Berfini
a5872ef8de Set default values for notification channels, do not rely only on importance level 2025-04-02 15:15:09 +02:00
Sylvain Berfini
9255830fe2 Show copy SIP URI icon & do it on click in call history like in conversation details 2025-04-02 14:20:13 +02:00
Sylvain Berfini
80eaf08fbf Refresh lists content when going back from background after at least 1 hour (when keep alive service is enabled) 2025-04-01 13:25:16 +02:00
Sylvain Berfini
903aaad6fe Do not store friends map in ContactsLoader, might cause concurrent modification 2025-03-31 16:20:37 +02:00
Sylvain Berfini
bdb2615300 Targetting Android 16 Baklava (API level 36) 2025-03-31 14:15:26 +02:00
Sylvain Berfini
bab2acb75c Prevent meetings list display issue if source isn't sorted 2025-03-31 13:31:19 +02:00
Sylvain Berfini
bd52960749 Fixed behavior when video is disabled in settings, should not show incoming video calls as video nor route audio to speaker automatically 2025-03-31 12:07:06 +02:00
Sylvain Berfini
23810e41e5 Forgot to change some of POST_NOTIFICATIONS checks 2025-03-31 09:26:22 +02:00
Sylvain Berfini
3f3a229844 Updated Github issue template with 6.0 way of sharing logs 2025-03-28 20:05:43 +01:00
Sylvain Berfini
0eb659b633 Updated CHANGELOG from release/6.0 branch 2025-03-28 10:25:14 +01:00
Sylvain Berfini
c35a44b1a0 Fixed migration scenario where logs upload sharing server url might not be set 2025-03-28 10:22:24 +01:00
Sylvain Berfini
1cccf7d26b Apply same call history workaround for missed calls count 2025-03-28 10:22:18 +01:00
Sylvain Berfini
0fab732e89 Fixed LDAP/remote CardDAV results not always displayed when making a search in contacts list 2025-03-27 16:17:49 +00:00
Sylvain Berfini
317a7c4417 Trying to prevent bottom bar from disappearing sometimes 2025-03-27 15:28:57 +01:00
Sylvain Berfini
689665c475 Show floating action button to open numpad in outgoing early media call, prevent display name & SIP address being displayed twice if early media is audio only 2025-03-27 11:32:11 +01:00
Sylvain Berfini
18e15b60a4 Fixed toggle setting color when disabled & off, disable early media ringing toggle if early media ringing is disabled 2025-03-27 10:14:25 +01:00
Sylvain Berfini
7bead679ad Delete all data related to account being removed 2025-03-27 09:40:07 +01:00
Sylvain Berfini
f0ad67fb29 Fixed disabled handle color while outgoing call is ringing 2025-03-27 09:10:22 +01:00
Sylvain Berfini
2621eb306e Added content message to keep app alive foreground service notification 2025-03-26 16:21:55 +01:00
Sylvain Berfini
90bf20e50e Prevent meeting icons from being briefly visible when selected account has no videoconference factory URI set in it's params 2025-03-26 09:48:03 +01:00
Sylvain Berfini
1f45ba8bd0 Added back ring during early media setting 2025-03-26 09:19:37 +01:00
Sylvain Berfini
c528f0cdb8 Bumped dependencies 2025-03-25 17:14:06 +01:00
Sylvain Berfini
a0108776dd Keep newly created account disabled until SMS code validation is done 2025-03-25 16:04:36 +00:00
Peio Rigaux
a503ef06ee Now use docker to deploy. Allows multiple deploy at the same time 2025-03-25 15:55:13 +00:00
Sylvain Berfini
fbc19c7053 Fixes regarding contacts list filter when switching account 2025-03-25 16:45:56 +01:00
Sylvain Berfini
9c8c5f309e Added hidden setting to allow hiding SIP URIs, show device name instead of SIP full SIP URI when doing trust call from contact details 2025-03-25 16:28:00 +01:00
Sylvain Berfini
b40fbcad77 Log TelecomManager CallControl failed operations 2025-03-25 13:27:24 +00:00
Sylvain Berfini
8dda38a925 Parse friends in a coroutine scope, no need to do it on the Core's thread 2025-03-25 13:41:47 +01:00
Sylvain Berfini
d150027c24 Delay heavy tasks to prevent ServiceDidNotStartInTimeException (for example) 2025-03-25 13:16:18 +01:00
Sylvain Berfini
7018cd3442 Fixed crash when default device URI is null 2025-03-24 23:43:46 +01:00
Sylvain Berfini
d6494cd27c Ask for full screen intent if not granted 2025-03-24 17:33:52 +01:00
Sylvain Berfini
10f2d7cd78 Prevent today indicator in meetings list from blinking upon refresh 2025-03-24 14:03:43 +01:00
Sylvain Berfini
6767bc09f9 Fixed displayed SIP URI in call history details in case we find a matching contact that has a different SIP URI in addition to the one used for the call 2025-03-24 13:36:22 +01:00
Sylvain Berfini
b22ab7024e Fixed contact lookup if phone number starts by 00 instead of + 2025-03-24 13:34:10 +01:00
Sylvain Berfini
c6fa645f94 Fixed invisible conference notification icon 2025-03-24 11:39:03 +01:00
Sylvain Berfini
fb3feb0bc3 This should prevent crashes on Androids < 13 due to broken POST_NOTIFICATIONS permission check 2025-03-24 11:33:00 +01:00
Sylvain Berfini
77f61c1cfa Updated version code to match the one on release/6.0 branch 2025-03-24 10:17:31 +01:00
Sylvain Berfini
9ce803667b Do not send meeting message invitation when chat is disabled 2025-03-24 09:53:11 +01:00
Sylvain Berfini
50bd8f67d5 Check if lateinit conference was initialized 2025-03-24 09:44:47 +01:00
Sylvain Berfini
faac4111d9 Updated CHANGELOG from release/6.0 branch 2025-03-24 08:58:28 +01:00
Sylvain Berfini
6121cb41bf Fixed remove all call logs when workaround is active 2025-03-24 08:58:15 +01:00
Sylvain Berfini
2aed404167 Have automaticallyShowDialpad setting also work on new call/transfer call screens while already in call 2025-03-24 08:58:05 +01:00
Sylvain Berfini
2f9eb2f0ab Improved message when WiFi only mode is enabled & active network isn't Wifi nor Ethernet 2025-03-21 11:13:01 +01:00
Sylvain Berfini
1255d626af Fixed recordings order, now most recent on the top 2025-03-21 09:03:26 +01:00
Sylvain Berfini
a83f9d4424 Moved call related advanced settings to dedicated sub-section, added back auto answer 2025-03-21 08:54:17 +01:00
Sylvain Berfini
fecf067b50 Fixed wrong setting disabled when VFS is enabled 2025-03-21 08:41:24 +01:00
Sylvain Berfini
b194272f91 Use newly added chatRoom.getAccount() to dynamically filter conversations 2025-03-20 14:59:32 +01:00
Sylvain Berfini
cad90752db Fixed call logs details if it's not attached to any account 2025-03-20 14:55:49 +01:00
Sylvain Berfini
2abad0ab9a Fetch call history from Core instead of Account if only one of them 2025-03-20 13:00:10 +01:00
Sylvain Berfini
a0d74c8036 Added back apply prefix to phone numbers for calls & chat setting 2025-03-20 12:29:27 +01:00
Sylvain Berfini
2eb376fd2d Prevent schedule meeting button being pressed multiple times 2025-03-19 13:58:12 +01:00
Sylvain Berfini
1942ee8f85 Few tweaks trying to prevent jni global ref table overflow 2025-03-18 16:31:38 +00:00
Sylvain Berfini
488a0fd98c Added advanced setting letting user choose whether to record in MKV or SMFF 2025-03-18 16:46:12 +01:00
Sylvain Berfini
08412ef99a Prevent some crashes seen on Play Store console 2025-03-18 12:51:25 +01:00
Sylvain Berfini
e16e767d5a Fixed wrong deleted notification channel ID... 2025-03-18 12:34:22 +01:00
Sylvain Berfini
886be9e038 Fixed hearing aid icon not showing in bottom actions when selected + updated earpiece icon in device list to match 2025-03-18 11:58:02 +01:00
Sylvain Berfini
2a5b5d368c Revert using notification channel to play incoming calls ringtone 2025-03-18 10:50:53 +01:00
Sylvain Berfini
be5428aa08 Add generic exception handle for starting action_view activity for URL 2025-03-18 10:13:17 +01:00
Sylvain Berfini
d6c6de2b5e Wait for foreground service to be started before being stopped to try preventing ForegroundServiceDidNotStartInTimeException/RemoteServiceException due to Context.startForegroundService() did not then call Service.startForeground() 2025-03-17 17:37:43 +01:00
Sylvain Berfini
915a847083 Prevent crash due to service being started as foreground if post_notifications permission isn't granted 2025-03-17 17:37:43 +01:00
Sylvain Berfini
0d8397b914 Should fix hearing aids issue 2025-03-17 16:20:34 +00:00
Sylvain Berfini
b5a1e21f40 Should fix quit button visibility in drawer menu 2025-03-17 16:10:12 +01:00
Sylvain Berfini
9837a834d4 Added back "replace + by 00 when formatting phone numbers" account setting 2025-03-17 14:38:01 +01:00
Sylvain Berfini
8a4956e7c1 Hidden save/export buttons for call recordings until export feature will be added to SDK 2025-03-17 14:21:54 +01:00
Sylvain Berfini
052d7cc522 Added UI setting to have dialpad automatically opened when starting new call 2025-03-17 14:19:14 +01:00
Sylvain Berfini
6c6fb9eff3 Prevent call transfer if state is Ended, Error or Released 2025-03-17 13:10:38 +01:00
Sylvain Berfini
b23f52adec Prevent system call notification to be stuck if call was ended in Linphone SDK before being added to TelecomManager's 2025-03-17 11:58:02 +01:00
Sylvain Berfini
8769a47ed0 Adding back auto start setting 2025-03-17 11:47:29 +01:00
Sylvain Berfini
ebb7201701 Prevent crash if contacts listener triggers before chatRoom property is initialized 2025-03-17 09:23:44 +00:00
Sylvain Berfini
6e83b794b3 Prevent crash if not on contact fragment before navigating to editor 2025-03-17 09:14:33 +00:00
Sylvain Berfini
3045378eb0 Prevent crash if fails to go to outside activity because it doesn't exists or it isn't found 2025-03-17 09:14:33 +00:00
Sylvain Berfini
dc4619a7d7 Prevent use of recording ViewModel property not initialized yet 2025-03-17 09:06:15 +00:00
Sylvain Berfini
87b6c2deef Prevent crash clinking on link if no browser is installed on device 2025-03-17 08:44:33 +00:00
Sylvain Berfini
614ac7f9cf Prevent crash if DMTF setting doesn't exists (Samsung A51) 2025-03-16 20:34:29 +01:00
Sylvain Berfini
71e1734ca0 Fixed crash due to currentCall not being initialized 2025-03-13 15:28:11 +01:00
Sylvain Berfini
71b1cf8e7a Make sure Qr Code fragment doesn't use Static Picture camera device 2025-03-13 12:17:19 +01:00
Sylvain Berfini
dee684b364 Added setting to choose whether to sort contacts by first or last name 2025-03-13 11:54:46 +01:00
Sylvain Berfini
0b6805a73c Fixed color selector used when not needed, may cause crash on old devices 2025-03-13 10:10:47 +01:00
Sylvain Berfini
11795cded8 Prevent SecurityException when accessing bluetooth_name on some devices 2025-03-13 09:58:00 +01:00
Sylvain Berfini
cc5bfcf14d Use Account's onConferenceInformationUpdated callback to refresh meetings list 2025-03-13 09:45:19 +01:00
Sylvain Berfini
b3ab9601b2 Prevent user from connecting the same account multiple times 2025-03-12 13:32:11 +01:00
Sylvain Berfini
0e6d91a467 Fixed selecting participant in group conversation when typing '@' 2025-03-12 13:05:01 +01:00
Sylvain Berfini
fbf68db2dd Updated linphone version to use 5.5.0-alpha 2025-03-11 16:28:51 +01:00
Sylvain Berfini
0bf50f1495 Fixed wrong constraint layout reference 2025-03-11 16:23:32 +01:00
491 changed files with 23608 additions and 7798 deletions

View file

@ -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).

View file

@ -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
View file

@ -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
View file

@ -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">

View file

@ -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

View file

@ -1,5 +1,6 @@
[![pipeline status](https://gitlab.linphone.org/BC/public/linphone-android/badges/master/pipeline.svg)](https://gitlab.linphone.org/BC/public/linphone-android/commits/master) [![weblate status](https://weblate.linphone.org/widgets/linphone/-/linphone-android/svg-badge.svg)](https://weblate.linphone.org/engage/linphone/?utm_source=widget)
[![pipeline status](https://gitlab.linphone.org/BC/public/linphone-android/badges/master/pipeline.svg)](https://gitlab.linphone.org/BC/public/linphone-android/commits/master)
[![weblate status](https://weblate.linphone.org/widget/linphone/linphone-android-6-0/status-badge.png)](https://weblate.linphone.org/engage/linphone/)
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:

View file

@ -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)

View file

@ -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"

View file

@ -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>

View file

@ -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

View file

@ -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)
}
}
}

View file

@ -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")
}
}

View file

@ -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
}
}
}

View file

@ -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
}
}
}

View file

@ -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"

View file

@ -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
}
}
}

View file

@ -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()
}
}
}

View file

@ -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))
}

View file

@ -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()

View file

@ -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,

View file

@ -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
}
}

View file

@ -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)

View file

@ -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
}

View file

@ -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

View file

@ -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)
)

View file

@ -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)) {

View file

@ -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"
}
}
}

View file

@ -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)
}
}

View file

@ -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) {

View file

@ -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"
)
}
}
}

View file

@ -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"
)
}
}
}

View file

@ -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())
}

View file

@ -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()

View file

@ -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"

View file

@ -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
) {

View file

@ -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()
}
}

View file

@ -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"
)
}
}

View file

@ -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)
}
}

View file

@ -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(

View file

@ -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)
}
}
}

View file

@ -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!")
}
}
}

View file

@ -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()

View file

@ -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)
}
}
}

View file

@ -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

View file

@ -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)
}
}

View file

@ -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]

View file

@ -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()

View file

@ -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(

View file

@ -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

View file

@ -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 ->

View file

@ -99,7 +99,9 @@ class CallsListFragment : GenericCallFragment() {
adapter.callClickedEvent.observe(viewLifecycleOwner) {
it.consume { model ->
model.togglePauseResume()
coreContext.postOnCoreThread {
model.togglePauseResume()
}
}
}

View file

@ -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)
}

View file

@ -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()

View file

@ -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() {

View file

@ -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()

View 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)
}
}

View file

@ -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)
}
}
}

View file

@ -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")
}
}
}

View file

@ -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!"

View file

@ -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(

View file

@ -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)
}
}
}
}

View file

@ -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)
}
}
}

View file

@ -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 ->

View file

@ -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

View file

@ -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 isnt 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)
}
}
}
}

View file

@ -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) {

View file

@ -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

View file

@ -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) {

View file

@ -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

View file

@ -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)

View file

@ -82,7 +82,7 @@ class ConversationEphemeralLifetimeFragment : SlidingPaneChildFragment() {
}
override fun onPause() {
sharedViewModel.newChatMessageEphemeralLifetimeToSet.value = Event(
sharedViewModel.newChatMessageEphemeralLifetimeToSetEvent.value = Event(
viewModel.currentlySelectedValue.value ?: 0L
)
super.onPause()

View file

@ -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(

View file

@ -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()
}
}

View file

@ -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)
}
}
}

View file

@ -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) {

View file

@ -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() {

View file

@ -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)

View file

@ -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)
}
}

View file

@ -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,

View file

@ -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

View file

@ -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")
}
}

View file

@ -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()
}
}

View file

@ -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)
}
}

View file

@ -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"
)

View file

@ -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
}
}
}
}
}

View file

@ -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)
}
}

View file

@ -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)

View file

@ -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)
}
}
}

View file

@ -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()
}

View file

@ -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
}
}

View file

@ -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"
)

View file

@ -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(

View file

@ -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
}
}

View file

@ -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)
}
}

View file

@ -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]"
)
}

View file

@ -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

View file

@ -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(

View file

@ -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)
}
}
}

View file

@ -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