Compare commits

...

418 commits

Author SHA1 Message Date
Sylvain Berfini
eb57f6bac4 Only apply Crashlytics plugin if local SDK build directory is configured 2026-01-27 09:27:06 +01:00
Sylvain Berfini
29343f8887 Improved hover effect on drawer menu items 2026-01-22 11:26:00 +01:00
Sylvain Berfini
14f8273588 Added setting to show more call stats 2026-01-21 11:43:52 +01:00
Sylvain Berfini
8cb883a978 Workaround to prevent numpad from being briefly displayed when answering a video call 2026-01-20 17:19:45 +01:00
Sylvain Berfini
539439a055 Updated CHANGELOG with 6.0.22 release info 2026-01-20 16:51:29 +01:00
Sylvain Berfini
597581faa5 Fixed use of TelecomManager APIs 2026-01-20 14:23:43 +01:00
Sylvain Berfini
5391c12473 Bumped AGP to 9.0 (must keep using kapt instead of ksp because of databinding) and build using Java 21 + updated docker image 2026-01-20 14:04:33 +01:00
Sylvain Berfini
cea47a767b Reduced bottom bar height while in portrait to match conversation's send area height 2026-01-20 09:35:23 +01:00
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
Sylvain Berfini
8363d41441 Merge branch 'release/6.0' 2025-03-11 16:01:38 +01:00
Sylvain Berfini
6d08625168 Fixed group call missed notification & in-call alert titles 2025-01-27 10:45:06 +01:00
495 changed files with 23905 additions and 7821 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) 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). It's also explained [in the README](https://github.com/BelledonneCommunications/linphone-android#behavior-issue).

View file

@ -2,7 +2,7 @@ job-android:
stage: build stage: build
tags: [ "docker-android" ] tags: [ "docker-android" ]
image: gitlab.linphone.org:4567/bc/public/linphone-android/bc-dev-android:20230414_bullseye_jdk_17_cleaned image: gitlab.linphone.org:4567/bc/public/linphone-android/bc-dev-android-36:20260120_trixie_java21_android36_gradle9
before_script: before_script:
- if ! [ -z ${SCP_PRIVATE_KEY+x} ]; then eval $(ssh-agent -s); fi - if ! [ -z ${SCP_PRIVATE_KEY+x} ]; then eval $(ssh-agent -s); fi

View file

@ -1,12 +1,20 @@
job-android-upload: job-android-upload:
stage: deploy stage: deploy
tags: [ "deploy" ] tags: [ "docker-deploy" ]
only: only:
- schedules - schedules
dependencies: dependencies:
- job-android - 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: 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

View file

@ -10,6 +10,376 @@ Group changes to describe their impact on the project, as follows:
Fixed for any bug fixes. Fixed for any bug fixes.
Security to invite users to upgrade in case of vulnerabilities. Security to invite users to upgrade in case of vulnerabilities.
## [6.1.0] - Unreleased
### Added
- Added the ability to edit/delete chat messages sent less than 24 hours ago.
- Added keyboard shortcuts on IncomingCallFragment: Ctrl + Shift + A to answer the call, Ctrl + Shift + D to decline it
- Added seeking feature to recordings & media player within app
- Added PDF preview in conversation (message bubble & documents list)
- Added hover effect when using a mouse (useful for tablets or devices with desktop mode)
- Support right click on some items to open bottom sheet/menu
- Added toggle speaker action in active call notification
- Increased text size for chat messages that only contains emoji(s)
- Use user-input to filter participants list after typing "@" in conversation send area
- Handle read-only CardDAV address books, disable edit/delete menus for contacts in read-only FriendList
- Added swipe/pull to refresh on contacts list of a CardDAV addressbook has been configured to force the synchronization
- Show information to user when filtering contacts doesn't show them all and user may have to refine it's search
- Show Android notification when an account goes to failed registration state (only when background mode is enabled)
- New settings:
- one for user to choose whether to sort contacts by first name or last name
- one to hide contacts that have neither a SIP address nor a phone number
- one to let app auto-answer call with video sending already enabled
- one to let edit native contacts Linphone copy in-app instead of opening native addressbook third party app
- Added a vu meter for recording & playback volumes (must be enabled in developer settings)
- Added support for HDMI audio devices
### Changed
- No longer follow TelecomManager audio endpoint during calls, using our own routing policy
- Join a conference using default layout instead of audio only when clicking on a meeting SIP URI
- Removing an account will also remove all related data in the local database (auth info, call logs, conversations, meetings, etc...)
- Hide SIP address/phone number picker dialog if contact has exactly one SIP address matching both the app default domain & the currently selected account domain
- Hide SIP address associated to phone number through presence mecanism in contact details & editor views.
- Improved UI on tablets with screen sw600dp and higher, will look more like our desktop app
- Improved navigation within app when using a keyboard
- Now loading media/documents contents in conversation by chunks (instead of all of them at once)
- Simplified audio device name in settings
- Reworked some settings (moved calls related ones from advanced settings to advanced calls settings)
- Increased shared media preview size in chat
- Un-encrypted conversation warning will be more visible for accounts that support end-to-end encrypted conversations
- Made numpad buttons larger by changing their shape
- All LDAP fields are mandatory now
- Improved how Android shortcuts are created
- Permission fragment will only show missing ones
- Added more info into StartupListener logs
- Updated password forgotten procedure, will use online account manager platform
### Fixed
- Copy raw message content instead of modified one when it contains a participant mention ("@username")
## [6.0.22] - 2026-01-20
### Changed
- Close search bar when opening bottom sheet and vice versa
### Fixed
- Sending a file from another app using Android shortcut not working if conversation was already opened
- Trying to workaround an issue where ForegroundService notification isn't displayed in the allowed timeframe, causing an Exception
## [6.0.21] - 2025-12-16
### Added
- Allow linphone-config: scheme URIs in in-app QR code scanner
### Changed
- Workaround for audio focus & audio manager mode on devices that do not support TelecomManager APIs
- Set front camera as default after using back camera when scanning a QR code
- Added back largeHeap flag in AndroidManifest.xml
### Fixed
- Fixed call recording indicator not showing local record in progress in case UPDATE isn't answered
- Fixed native addressbook reload when a contact is updated in the OS default app
- Fixed issue with linphone-config scheme URIs if scheme is followed by "//"
- Fixed Job & Company contact field not updated if field content was removed
- Fixed local avatar not displayed when calling ourselves
- Prevent crashes due to some ActivityNotFound exceptions
- Prevent crash due to empty clipboard on some devices
## [6.0.20] - 2025-11-21
### Changed
- Added shrink resources to release config in gradle
### Fixed
- Remove AuthInfo when configuring a CardDAV friend list if synchronization fails
- Added missing toast when starting a group call or meeting if there's an issue
- Fixed crash in RecordingPlayerFragment due to used lateinit property before it's initialized
## [6.0.19] - 2025-10-16
### Added
- Spanish and Slovakian translations thanks to Weblate contributors
### Changed
- SIP addresses domain hidden in Suggestions if it matches the currently selected account SIP identity domain
- Start proximity sensor when an incoming call is answered from the notification (disabling screen when device is near)
### Fixed
- Black screen when trying to scan a QR Code right after granting CAMERA permission (only happened on some devices)
- Possible crash due to ConcurrentModificationException
- Camera preview in conference that was black sometimes after switching layout
- Possibly wrong screen sharing participant name in conference
- Presence SUBSCRIBE that was only sent for sip.linphone.org accounts
- Keyboard suggestions in participant picker textfield
- Account labelled as Disabled instead of Disconnected when network isn't reachable
- Suggestions generated avatar if username starts with '+'
- Two LDAP fields label where swapped
## [6.0.18] - 2025-09-15
### Added
- Added menu icon next to currently selected account avatar to make the drawer menu easier to understand
- Added missing dialpad floating action button in the call transfer fragment
### Changed
- Improved bodyless friendlist presence process when it's received
### Fixed
- Fixed "End-to-end encrypted call" label while in conference, the call may be end-to-end encrypted but only to the conference server, not to all participants
- Fixed missing meeting subject when calling the conference SIP URI if the conference info doesn't exist yet
- Finish CallActivity if no call is found when trying to answer/decline a call from the IncomingCallFragment
- Prevent empty screen when rotating the device and clicking on the empty part next to the list while in landscape and then rotating the device back to portrait
## [6.0.17] - 2025-09-02
### Changed
- Portuguese translation updated from Weblate (still not complete)
### Fixed
- Vibrator not stopped when call is terminated sometimes (SDK fix)
- Chat conversation not visible sometimes (SDK fix)
## [6.0.16] - 2025-08-25
## Added
- Access to Help/Troubleshooting pages from Assistant
## Fixed
- Some Core methods being called from UI thread causing either a crash or a deadlock sometimes
- Scrolling issue when doing a search in a conversation with only one result
- Contacts not updated after body less presence notify was received
- VFS issue due to encrypted.pref file being backed up by Android OS
## [6.0.15] - 2025-08-11
### Fixed
- Crash due to changes in SDK triggering fatal error if linphone_core_stop() is called from linphone_core_iterate() loop (which was done when scanning QR code)
### Changed
- Prevent leaving assistant after doing a remote provisioning if there is still no account after it (if there was no account before and no account was provided in downloaded config)
## [6.0.14] - 2025-08-06
### Fixed
- Fixed ANR due to deadlock caused by method being called from wrong thread
- Fixed microphone not always recording audio while app in background or if screen is turned off
- Fixed missing favorites in start call / create conversation views
- Fixed outgoing call view in full screen
- Fixed generated avatar for SIP URIs without username
## [6.0.13] - 2025-07-31
### Fixed
- Missing favourites if contacts list size exceeds magic search max results setting
- Muted call on some devices due to Telecom Manager quickly muting/unmuting call
- Full screen without video during outgoing early media call if video has been declined by remote end
- Removed duplicated week label if "no meeting today" is the first entry for current week
- Prevent crash during file export if no app on the device can handle it
- Prevent crash that could happen with chat message notification if sender name (or group chat room subject) is empty
### Changed
- Back gesture / navigation button will close the numpad bottom sheet if it's open instead of leaving the page directly
- Updated bell and bell_slash icons
## [6.0.12] - 2025-07-18
### Fixed
- Reactions list in bottom sheet update while opened
- Crashes due to late init properties being used before initialized
## [6.0.11] - 2025-07-11
### Added
- Added toggle in LDAP configuration to allow to quickly enable/disable it
### Changed
- Reduced maximum number of contacts displayed in contacts list, new call/conversation, meeting participant selection etc...
- Updated translations
### Fixed
- Calls top bar wrong notification label when going from two calls to one.
## [6.0.10] - 2025-06-27
### Added
- Added a new top bar alert area for pending file/text sharing.
### Changed
- Reworked in-app top bar alerts, now can show both an account alert and an active call alert.
- Hide SIP address/phone number picker dialog if contact has exactly one SIP address matching the default domain and currently default account domain.
### Fixed
- Bluetooth not being used automatically when device is connected during a call.
- Call encryption status label stuck in "Waiting for encryption".
- Group chat room creation if LIME server URL isn't set.
- Participant mention if more than one in the same chat message.
- Force default account in call params when starting one.
## [6.0.9] - 2025-06-06
### Added
- German translation (88% complete)
- Link to user guide in Help section
- Missing scroll views for help & debug layouts
### Changed
- Prevent port from being set in the SIP identity address in third party account login + remove port (if any) from SIP identity for existing accounts
- Show last message timestamp instead of conversation last updated timestamp in conversations list
### Fixed
- Prevent blinking in conversations list when removing message from chat room
- Prevent empty (can even lead to crash) display name in call notification (using all identification fields from vCard)
## [6.0.8] - 2025-05-23
### Added
- Ukrainian & simplified Chinese translations from Weblate
- Sliding answer/decline button in incoming call fragment if device is locked (will help prevent calls from being unintentionally picked up or hung up while the device is being removed from a pocket)
### Changed
- Show files with square design when more than one (as it is for media files)
- Outgoing chat bubbles will now display the sent file size (as it is for received messages)
### Fixed
- Fixed issue with bluetooth hearing aids
- Fixed audio call being answered on speakerphone
- Fixed events related to joined/left conversation being briefly visible sometimes for 1-1 conversations
- Fixed files/media grid in chat bubble using more than 3 columns in landscape
- Fixed logs upload server URL setting
## [6.0.7] - 2025-05-16
### Added
- CS, NL and RU translations from Weblate
### Changed
- Improved find contact performances
- Make sure speaker audio device is used for playing the ringtone during early media
- Reworked bottom navigation bar in portrait and unread count indicators
- No longer delete conversations when deleting account (for now); causes user to leave group which is an issue when using multiple devices
### Fixed
- Fixed no default account after remote provisioning
- Prevent lists from refreshing too many times when using LDAP or remote CardDAV contact directories
- Fixed black miniatures in conference if bundle mode is disabled in account params
- Fixed long press on a chat message containing a SIP URI triggering call
- Disable IMDN bottom sheet for incoming messages in groups instead of showing it empty
- Refresh conversations list after clearing conversation history
- Fixed another race condition issue related to foreground call service
## [6.0.6] - 2025-05-02
### Added
- Added recover phone account when clicking on "Forgotten password" in the assistant
- Improved message when contacts list is empty depending on the currently selected filter and added a button to open the filter popup menu for users that didn't notice the icon on the top right corner of the screen when contacts list is empty and "SIP contacts only" filter is set.
- Added "Logs collection sharing server URL" setting in developper area
- Added "Disable sending logs to Crashlytics" advanced setting.
### Changed
- Improved VFS message in confirmation dialog
- Moved "Print logs in logcat" and "File sharing server URL" settings to developper area
### Fixed
- Fixed crash when opening a password protected PDF
- Fixed chat room lookup while in 1-1 call, using SDK method for getting chat room from conference
- Fixed newly created contact not being visible in contacts list without reloading it
- Fixed missing event icon for group conversations
- Another attempts at preventing crashes due to In-Call service not being started as foreground before being stopped
## [6.0.5] - 2025-04-18
### Changed
- When calling a SIP URI that looks like a phone number in the username and an IP in the domain, replace the domain with the one of the currently selected account to workaround issue with PBXs using IPs instead of domains in From header
- Improved account creation page UI when push notifications aren't available
- Improved called account display on incoming call screen when more than one account configured
- Updated telecom package from beta to release candidate
### Fixed
- Fixed transfer call view numpad button starting a new call instead of forwarding the current one
- Fixed incoming call not displayed in call history depending on how the From & To headers are formatted (SDK fix)
- Fixed crashes related to foreground service not being started
- Fixed crash due to lateinit property not being initialized before used
## [6.0.4] - 2025-04-11
### Changed
- Third party SIP accounts push notifications will be disabled and setting will be hidden unless if list of supported domains (to prevent issues, specifically when used with UDP transport protocol causing bigger packets getting lost)
### Fixed
- Prevent refresh of views due to contacts changes to happen to frequently at startup
- Prevent crash in Help view if app is built without Firebase
## [6.0.3] - 2025-04-04
### Added
- Show alert when default account is disabled
- Refesh list details when going back from background after one hour or more (when keep app alive using service is enabled)
- Click to copy SIP URI in call history shortcut
- Added developper settings, must click 8 times on version (in Help) to make it appear (E2E encryption for meetings & group calls setting was moved there)
- Circular indicator while search is in progress in contacts lists
### Changed
- Force some default values on notifications channels
- Contacts list filter is now applied to new call / conversation & other contact pickers
- Attach file icon stays visible while typing message in conversation instead of emoji picker icon
### Fixed
- No default account being selected if the default one is removed
- Navigation bar turning orange when opening search bar
- Incoming call showed as video even if video is disabled locally
- Concurrent modification crash in Contacts loader
- Meetings list not properly sorted when CCMP is used
- POST_NOTIFICATIONS permission check on old Android devices
## [6.0.2] - 2025-03-28
### Added
- Show on top bar if FULL_SCREEN_INTENT permission isn't granted, clicking on it sends to the matching settings so user can fix it easily, without it incoming call screen won't be displayed if screen is off
- Ring during incoming early media call setting added back
- Added a floating action button to open dialpad during outgoing early media call
### Changed
- Delete all related call history / conversations / meetings when removing an account
- Delay / use a separated thread for heavy contacts related tasks to ensure call is correctly handled and foreground service is started quickly enough
- Newly created account in app will be kept disabled until SMS code validation is done
- Keep app alive foreground service notification no shows a content message to ease clicking on it to open the app & workaround a crash on some devices
- Automatically show dialpad setting will now also work on new / transfer call while in call as well
### Fixed
- Improved POST_NOTIFICATIONS permission check on Android 13 and newer, should prevent crashes
- Fixed contact lookup if phone number starts by "00" instead of "+"
- Fixed "delete all call history" sometimes not removing all call logs
- Fixed LDAP / remote CardDAV contacts sometimes not displayed in contacts list when doing a search
- Fixed issue where contact filter could be set to only show sip.linphone.org contacts even when third party account was being selected
- Fixed sometimes wrong displayed SIP URI in detailed call history
- Fixed invisible meeting icon in status bar
- Fixed missed call count indicator behavior with some third party providers
- Prevent today indicator & meeting icon in bottom nav bar from blinking / briefly appearing
- Fixed bottom nav bar sometimes being hidden
- Fixed missing share logs server URL when migrating from 5.2 if that value was removed back then
- Other crashes fixed
## [6.0.1] - 2025-03-21
### Added
- Start at boot & auto answer settings added back
- Interface setting to have dialpad automatically opened in start call view
- Replace "+" by "00" and do not apply prefix for calls & chat account settings
- Setting to let user choose whether to record calls using MKV or SMFF format (the later allows to record H265/AV1 video but is a proprietary file format that can't be read outside of Linphone)
### Changed
- Reverted the way of playing incoming call ringone (you may have to configure your own ringtone again), was causing various issues depending on devices/firmwares
- Show all call history entries if only one account is configured (workaround for missing history for now until a proper fix will be done in SDK)
### Fixed
- Issue preventing bluetooth Hearing Aids from working properly (and fixed earpiece/hearing aids icon)
- Prevent Qr Code scanner to use static picture camera
- Prevent user from connecting the same account multiple times
- Quit menu visibility not updated when changing Keep Alive setting
- Participant selection in group when typing "@"
- Recordings order has been reversed to have newest ones at top
- Improved message when network is not reachable due to "Wifi only mode" being enabled
- Various crash & bug fixes
## [6.0.0] - 2025-03-11 ## [6.0.0] - 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. 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 ### 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/ - 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. 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. 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. 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 # CONTRIBUTIONS
In order to submit a patch for inclusion in linphone's source code: In order to submit a patch for inclusion in linphone's source code:

View file

@ -1,7 +1,8 @@
import com.android.build.gradle.internal.tasks.factory.dependsOn import com.android.build.gradle.internal.tasks.factory.dependsOn
import com.google.firebase.crashlytics.buildtools.gradle.CrashlyticsExtension import com.google.firebase.crashlytics.buildtools.gradle.CrashlyticsExtension
import com.google.firebase.crashlytics.buildtools.gradle.CrashlyticsPlugin
import com.google.gms.googleservices.GoogleServicesPlugin import com.google.gms.googleservices.GoogleServicesPlugin
import java.io.ByteArrayOutputStream import java.io.BufferedReader
import java.io.FileInputStream import java.io.FileInputStream
import java.util.Properties import java.util.Properties
@ -11,7 +12,6 @@ plugins {
alias(libs.plugins.ktlint) alias(libs.plugins.ktlint)
alias(libs.plugins.jetbrainsKotlinAndroid) alias(libs.plugins.jetbrainsKotlinAndroid)
alias(libs.plugins.navigation) alias(libs.plugins.navigation)
alias(libs.plugins.crashlytics)
} }
val packageName = "org.linphone" val packageName = "org.linphone"
@ -25,63 +25,69 @@ val firebaseCloudMessagingAvailable = googleServices.exists()
val crashlyticsAvailable = googleServices.exists() && linphoneLibs.exists() && linphoneDebugLibs.exists() val crashlyticsAvailable = googleServices.exists() && linphoneLibs.exists() && linphoneDebugLibs.exists()
if (firebaseCloudMessagingAvailable) { if (firebaseCloudMessagingAvailable) {
println("google-services.json found, enabling CloudMessaging feature") println("google-services.json found, enabling Firebase CloudMessaging feature")
apply<GoogleServicesPlugin>() apply<GoogleServicesPlugin>()
} else { } else {
println("google-services.json not found, disabling CloudMessaging feature") println("google-services.json not found, disabling Firebase CloudMessaging feature")
}
if (crashlyticsAvailable) {
println("google-services.json found and Linphone SDK libs-debug folder found, enabling Crashlytics feature")
apply<CrashlyticsPlugin>()
} else {
println("Crashlytics has been disabled because either google-services.json file wasn't found or local Linphone SDK build folder isn't configured")
} }
var gitBranch = ByteArrayOutputStream() var gitVersion = "6.1.0-alpha"
var gitVersion = "6.0.0" 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 gitCommitsCount = ProcessBuilder()
val gitVersionStream = ByteArrayOutputStream() .command("git", "rev-list", "$gitDescribe..HEAD", "--count")
val gitCommitsCount = ByteArrayOutputStream() .directory(project.rootDir)
val gitCommitHash = ByteArrayOutputStream() .start()
.inputStream.bufferedReader().use(BufferedReader::readText)
.trim()
println("Git commits count: $gitCommitsCount")
try { val gitCommitHash = ProcessBuilder()
exec { .command("git", "rev-parse", "--short", "HEAD")
commandLine("git", "describe", "--abbrev=0") .directory(project.rootDir)
standardOutput = gitVersionStream .start()
} .inputStream.bufferedReader().use(BufferedReader::readText)
exec { .trim()
commandLine( println("Git commit hash: $gitCommitHash")
"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
}
gitVersion = gitBranch = ProcessBuilder()
if (gitCommitsCount.toString().trim().toInt() == 0) { .command("git", "name-rev", "--name-only", "HEAD")
gitVersionStream.toString().trim() .directory(project.rootDir)
} else { .start()
gitVersionStream.toString().trim() + "." + .inputStream.bufferedReader().use(BufferedReader::readText)
gitCommitsCount.toString() .trim()
.trim() + "+" + gitCommitHash.toString().trim() println("Git branch name: $gitBranch")
}
println("Git version: $gitVersion") gitVersion =
} catch (e: Exception) { if (gitCommitsCount.toInt() == 0) {
println("Git not found [$e], using $gitVersion") gitDescribe
} } else {
project.version = gitVersion "$gitDescribe.$gitCommitsCount+$gitCommitHash"
}
} catch (e: Exception) {
println("Git not found [$e], using $gitVersion")
} }
project.tasks.preBuild.dependsOn("getGitVersion") println("Computed git version: $gitVersion")
configurations { configurations {
implementation { isCanBeResolved = true } implementation { isCanBeResolved = true }
} }
task("linphoneSdkSource") {
tasks.register("linphoneSdkSource") {
doLast { doLast {
configurations.implementation.get().incoming.resolutionResult.allComponents.forEach { configurations.implementation.get().incoming.resolutionResult.allComponents.forEach {
if (it.id.displayName.contains("linphone-sdk-android")) { if (it.id.displayName.contains("linphone-sdk-android")) {
@ -94,14 +100,14 @@ project.tasks.preBuild.dependsOn("linphoneSdkSource")
android { android {
namespace = "org.linphone" namespace = "org.linphone"
compileSdk = 35 compileSdk = 36
defaultConfig { defaultConfig {
applicationId = packageName applicationId = packageName
minSdk = 28 minSdk = 28
targetSdk = 35 targetSdk = 36
versionCode = 600000 // 6.00.000 versionCode = 601002 // 6.01.002
versionName = "6.0.0" versionName = "6.1.0-alpha"
manifestPlaceholders["appAuthRedirectScheme"] = packageName manifestPlaceholders["appAuthRedirectScheme"] = packageName
@ -148,13 +154,16 @@ android {
isDebuggable = true isDebuggable = true
isJniDebuggable = true isJniDebuggable = true
val appVersion = gitVersion
val appBranch = gitBranch
println("Debug flavor app version is [$appVersion], app branch is [$appBranch]")
resValue("string", "linphone_app_version", appVersion)
resValue("string", "linphone_app_branch", appBranch)
if (useDifferentPackageNameForDebugBuild) { if (useDifferentPackageNameForDebugBuild) {
resValue("string", "file_provider", "$packageName.debug.fileprovider") resValue("string", "file_provider", "$packageName.debug.fileprovider")
} else { } else {
resValue("string", "file_provider", "$packageName.fileprovider") 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) resValue("string", "linphone_openid_callback_scheme", packageName)
if (crashlyticsAvailable) { if (crashlyticsAvailable) {
@ -169,15 +178,19 @@ android {
getByName("release") { getByName("release") {
isMinifyEnabled = true isMinifyEnabled = true
isShrinkResources = true
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro", "proguard-rules.pro",
) )
signingConfig = signingConfigs.getByName("release") signingConfig = signingConfigs.getByName("release")
val appVersion = gitVersion
val appBranch = gitBranch
println("Release flavor app version is [$appVersion], app branch is [$appBranch]")
resValue("string", "linphone_app_version", appVersion)
resValue("string", "linphone_app_branch", appBranch)
resValue("string", "file_provider", "$packageName.fileprovider") resValue("string", "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) resValue("string", "linphone_openid_callback_scheme", packageName)
if (crashlyticsAvailable) { if (crashlyticsAvailable) {
@ -192,17 +205,14 @@ android {
} }
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_17 sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_21
}
kotlinOptions {
jvmTarget = "17"
} }
buildFeatures { buildFeatures {
dataBinding = true dataBinding = true
buildConfig = true buildConfig = true
resValues = true
} }
lint { lint {
@ -212,7 +222,6 @@ android {
dependencies { dependencies {
implementation(libs.androidx.annotations) implementation(libs.androidx.annotations)
implementation(libs.androidx.activity)
implementation(libs.androidx.appcompat) implementation(libs.androidx.appcompat)
implementation(libs.androidx.constraint.layout) implementation(libs.androidx.constraint.layout)
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
@ -220,6 +229,7 @@ dependencies {
implementation(libs.androidx.telecom) implementation(libs.androidx.telecom)
implementation(libs.androidx.media) implementation(libs.androidx.media)
implementation(libs.androidx.recyclerview) implementation(libs.androidx.recyclerview)
implementation(libs.androidx.swiperefreshlayout)
implementation(libs.androidx.slidingpanelayout) implementation(libs.androidx.slidingpanelayout)
implementation(libs.androidx.window) implementation(libs.androidx.window)
implementation(libs.androidx.gridlayout) implementation(libs.androidx.gridlayout)

View file

@ -54,6 +54,7 @@
android:localeConfig="@xml/locales_config" android:localeConfig="@xml/locales_config"
android:theme="@style/Theme.Linphone" android:theme="@style/Theme.Linphone"
android:appCategory="social" android:appCategory="social"
android:largeHeap="true"
tools:targetApi="35"> tools:targetApi="35">
<!-- Required for chat message & call notifications to be displayed in Android auto --> <!-- Required for chat message & call notifications to be displayed in Android auto -->
@ -151,7 +152,7 @@
android:name=".ui.call.CallActivity" android:name=".ui.call.CallActivity"
android:windowSoftInputMode="adjustResize" android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.LinphoneInCall" android:theme="@style/Theme.LinphoneInCall"
android:launchMode="singleTask" android:launchMode="singleInstance"
android:turnScreenOn="true" android:turnScreenOn="true"
android:showWhenLocked="true" android:showWhenLocked="true"
android:resizeableActivity="true" android:resizeableActivity="true"

View file

@ -32,4 +32,7 @@
<entry name="media_encryption">srtp</entry> <entry name="media_encryption">srtp</entry>
<entry name="media_encryption_mandatory" overwrite="true">0</entry> <entry name="media_encryption_mandatory" overwrite="true">0</entry>
</section> </section>
<section name="ui">
<entry name="automatically_show_dialpad" overwrite="true">1</entry>
</section>
</config> </config>

View file

@ -26,7 +26,7 @@ update_presence_model_timestamp_before_publish_expires_refresh=1
[sound] [sound]
#remove this property for any application that is not Linphone public version itself #remove this property for any application that is not Linphone public version itself
ec_calibrator_cool_tones=1 ec_calibrator_cool_tones=1
disable_ringing=1 disable_ringing=0
[audio] [audio]
android_disable_audio_focus_requests=1 android_disable_audio_focus_requests=1

View file

@ -20,6 +20,7 @@
package org.linphone.compatibility package org.linphone.compatibility
import android.content.Intent import android.content.Intent
import android.net.InetAddresses.isNumericAddress
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.provider.MediaStore import android.provider.MediaStore
@ -62,5 +63,9 @@ class Api29Compatibility {
session.contentCaptureContext = ContentCaptureContext.forLocusId(conversationId) session.contentCaptureContext = ContentCaptureContext.forLocusId(conversationId)
} }
} }
fun isIpAddress(string: String): Boolean {
return isNumericAddress(string)
}
} }
} }

View file

@ -47,7 +47,9 @@ class Api31Compatibility {
.build() .build()
) )
Log.i("$TAG PiP auto enter has been [${if (enable) "enabled" else "disabled"}]") 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") Log.e("$TAG Can't set PiP params: $ise")
} }
} }

View file

@ -20,6 +20,9 @@
package org.linphone.compatibility package org.linphone.compatibility
import android.Manifest import android.Manifest
import android.app.ActivityOptions
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
@ -34,5 +37,17 @@ class Api33Compatibility {
Manifest.permission.CAMERA 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 package org.linphone.compatibility
import android.app.ActivityOptions
import android.app.Notification import android.app.Notification
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service import android.app.Service
import android.content.ActivityNotFoundException
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build import android.os.Build
import android.os.Bundle
import android.provider.Settings import android.provider.Settings
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import org.linphone.core.tools.Log import org.linphone.core.tools.Log
@ -71,7 +75,27 @@ class Api34Compatibility {
intent.data = "package:${context.packageName}".toUri() intent.data = "package:${context.packageName}".toUri()
intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY) intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY)
Log.i("$TAG Starting ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT") 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() Executors.newSingleThreadExecutor()
) { info -> ) { info ->
Log.i("==== Current startup information dump ====") Log.i("==== Current startup information dump ====")
Log.i("TYPE = ${startupTypeToString(info.startType)}") logAppStartupInfo(info)
Log.i("STATE = ${startupStateToString(info.startupState)}") }
Log.i("REASON = ${startupReasonToString(info.reason)}")
Log.i("FORCE STOPPED = ${if (info.wasForceStopped()) "yes" else "no"}") Log.i("==== Fetching last three startup reasons if available ====")
Log.i("PROCESS NAME = ${info.processName}") val lastStartupInfo = activityManager.getHistoricalProcessStartReasons(3)
Log.i("=========================================") for (info in lastStartupInfo) {
Log.i("==== Previous startup information dump ====")
logAppStartupInfo(info)
} }
} catch (iae: IllegalArgumentException) { } catch (iae: IllegalArgumentException) {
Log.e("$TAG Can't add application start info completion listener: $iae") 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 { private fun startupTypeToString(type: Int): String {
return when (type) { return when (type) {
ApplicationStartInfo.START_TYPE_COLD -> "Cold" 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.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
import android.app.ActivityOptions
import android.app.Notification import android.app.Notification
import android.app.PendingIntent
import android.app.Service import android.app.Service
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Bundle
import android.os.Environment import android.os.Environment
import android.util.Patterns
import android.view.View import android.view.View
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import org.linphone.core.tools.Log import org.linphone.core.tools.Log
@ -111,6 +115,13 @@ class Compatibility {
return false 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 { fun enterPipMode(activity: Activity): Boolean {
if (Version.sdkStrictlyBelow(Version.API31_ANDROID_12)) { if (Version.sdkStrictlyBelow(Version.API31_ANDROID_12)) {
return Api28Compatibility.enterPipMode(activity) return Api28Compatibility.enterPipMode(activity)
@ -179,5 +190,31 @@ class Compatibility {
Api35Compatibility.setupAppStartupListener(context) 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.Paint
import android.graphics.Rect import android.graphics.Rect
import android.graphics.RectF import android.graphics.RectF
import android.graphics.drawable.BitmapDrawable
import android.text.TextPaint import android.text.TextPaint
import android.util.TypedValue import android.util.TypedValue
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
@ -34,7 +33,6 @@ import androidx.core.graphics.drawable.IconCompat
import org.linphone.R import org.linphone.R
import org.linphone.utils.AppUtils import org.linphone.utils.AppUtils
import androidx.core.graphics.createBitmap import androidx.core.graphics.createBitmap
import androidx.core.graphics.drawable.toDrawable
class AvatarGenerator(private val context: Context) { class AvatarGenerator(private val context: Context) {
private var textSize: Float = AppUtils.getDimension(R.dimen.avatar_initials_text_size) private var textSize: Float = AppUtils.getDimension(R.dimen.avatar_initials_text_size)
@ -92,10 +90,6 @@ class AvatarGenerator(private val context: Context) {
return bitmap return bitmap
} }
fun buildDrawable(): BitmapDrawable {
return buildBitmap(true).toDrawable(context.resources)
}
fun buildIcon(): IconCompat { fun buildIcon(): IconCompat {
return IconCompat.createWithAdaptiveBitmap(buildBitmap(false)) return IconCompat.createWithAdaptiveBitmap(buildBitmap(false))
} }

View file

@ -29,8 +29,14 @@ import androidx.annotation.WorkerThread
import androidx.loader.app.LoaderManager import androidx.loader.app.LoaderManager
import androidx.loader.content.CursorLoader import androidx.loader.content.CursorLoader
import androidx.loader.content.Loader 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 java.lang.Exception
import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.core.Core
import org.linphone.core.Factory import org.linphone.core.Factory
import org.linphone.core.Friend import org.linphone.core.Friend
import org.linphone.core.FriendList 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 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 @MainThread
override fun onCreateLoader(id: Int, args: Bundle?): Loader<Cursor> { override fun onCreateLoader(id: Int, args: Bundle?): Loader<Cursor> {
@ -93,8 +99,10 @@ class ContactLoader : LoaderManager.LoaderCallbacks<Cursor> {
ContactsContract.Data.CONTACT_ID + " ASC" ContactsContract.Data.CONTACT_ID + " ASC"
) )
// Update at most once every X (see variable value for actual duration) // WARNING: this doesn't prevent to be called again in onLoadFinished,
loader.setUpdateThrottle(MIN_INTERVAL_TO_WAIT_BEFORE_REFRESH) // 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 return loader
} }
@ -104,29 +112,38 @@ class ContactLoader : LoaderManager.LoaderCallbacks<Cursor> {
if (cursor == null) { if (cursor == null) {
Log.e("$TAG Cursor is null!") Log.e("$TAG Cursor is null!")
return return
} else if (cursor.isClosed) {
Log.e("$TAG Cursor is closed!")
return
} }
Log.i("$TAG Load finished, found ${cursor.count} entries in cursor") 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 { 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 @MainThread
override fun onLoaderReset(loader: Loader<Cursor>) { override fun onLoaderReset(loader: Loader<Cursor>) {
Log.i("$TAG Loader reset") Log.i("$TAG Loader reset")
scope.cancel()
} }
@WorkerThread @WorkerThread
private fun parseFriends(cursor: Cursor) { private fun parseFriends(core: Core, 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
}
try { try {
val contactIdColumn = cursor.getColumnIndexOrThrow(ContactsContract.Data.CONTACT_ID) val contactIdColumn = cursor.getColumnIndexOrThrow(ContactsContract.Data.CONTACT_ID)
val mimetypeColumn = cursor.getColumnIndexOrThrow(ContactsContract.Data.MIMETYPE) val mimetypeColumn = cursor.getColumnIndexOrThrow(ContactsContract.Data.MIMETYPE)
@ -164,6 +181,8 @@ class ContactLoader : LoaderManager.LoaderCallbacks<Cursor> {
val familyNameColumn = cursor.getColumnIndexOrThrow( val familyNameColumn = cursor.getColumnIndexOrThrow(
ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME
) )
val friends = HashMap<String, Friend>()
while (!cursor.isClosed && cursor.moveToNext()) { while (!cursor.isClosed && cursor.moveToNext()) {
try { try {
val id: String = cursor.getString(contactIdColumn) val id: String = cursor.getString(contactIdColumn)
@ -219,14 +238,9 @@ class ContactLoader : LoaderManager.LoaderCallbacks<Cursor> {
} }
if (!number.isNullOrEmpty()) { if (!number.isNullOrEmpty()) {
if (friend.phoneNumbersWithLabel.find { val phoneNumber = Factory.instance()
PhoneNumberUtils.arePhoneNumberWeakEqual(it.phoneNumber, number) .createFriendPhoneNumber(number, label)
} == null friend.addPhoneNumberWithLabel(phoneNumber)
) {
val phoneNumber = Factory.instance()
.createFriendPhoneNumber(number, label)
friend.addPhoneNumberWithLabel(phoneNumber)
}
} }
} }
ContactsContract.CommonDataKinds.SipAddress.CONTENT_ITEM_TYPE -> { ContactsContract.CommonDataKinds.SipAddress.CONTENT_ITEM_TYPE -> {
@ -250,17 +264,14 @@ class ContactLoader : LoaderManager.LoaderCallbacks<Cursor> {
} }
} }
ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE -> { ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE -> {
val vCard = friend.vcard val givenName: String? = cursor.getString(givenNameColumn)
if (vCard != null) { if (!givenName.isNullOrEmpty()) {
val givenName: String? = cursor.getString(givenNameColumn) friend.firstName = givenName
if (!givenName.isNullOrEmpty()) { }
vCard.givenName = givenName
}
val familyName: String? = cursor.getString(familyNameColumn) val familyName: String? = cursor.getString(familyNameColumn)
if (!familyName.isNullOrEmpty()) { if (!familyName.isNullOrEmpty()) {
vCard.familyName = familyName 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)") 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 // Re-post another task to allow other tasks on Core thread
coreContext.postOnCoreThread { coreContext.postOnCoreThreadWhenAvailableForHeavyTask({
addFriendsIfNeeded() addFriendsIfNeeded(friends)
} }, "add friends to Core")
} catch (sde: StaleDataException) { } catch (sde: StaleDataException) {
Log.e("$TAG State Data Exception: $sde") Log.e("$TAG State Data Exception: $sde")
} catch (ise: IllegalStateException) { } catch (ise: IllegalStateException) {
@ -286,12 +297,12 @@ class ContactLoader : LoaderManager.LoaderCallbacks<Cursor> {
} }
@WorkerThread @WorkerThread
private fun addFriendsIfNeeded() { private fun addFriendsIfNeeded(friends: HashMap<String, Friend>) {
val core = coreContext.core val core = coreContext.core
if (core.globalState == GlobalState.Shutdown || core.globalState == GlobalState.Off) { if (core.globalState == GlobalState.Shutdown || core.globalState == GlobalState.Off) {
Log.w("$TAG Core is being stopped or already destroyed, abort") 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!") Log.w("$TAG No friend created!")
} else { } else {
Log.i("$TAG ${friends.size} friends fetched") Log.i("$TAG ${friends.size} friends fetched")
@ -313,83 +324,16 @@ class ContactLoader : LoaderManager.LoaderCallbacks<Cursor> {
} }
Log.i("$TAG Friends added") Log.i("$TAG Friends added")
} else { } else {
val friendsArray = friends.values.toTypedArray()
Log.i( Log.i(
"$TAG Friend list [$NATIVE_ADDRESS_BOOK_FRIEND_LIST] found, synchronizing existing friends with new ones" "$TAG Friend list [$NATIVE_ADDRESS_BOOK_FRIEND_LIST] found, synchronizing existing friends with new ones"
) )
for (localFriend in friendsList.friends) { val changes = friendsList.synchronizeFriendsWith(friendsArray)
val newlyFetchedFriend = friends[localFriend.refKey] if (changes) {
if (newlyFetchedFriend != null) { Log.i("$TAG Locally stored friends synchronized with native address book")
friends.remove(localFriend.refKey) } else {
localFriend.nativeUri = Log.i("$TAG No changes detected between native address book and local friends storage")
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)
}
} }
// 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() friends.clear()

View file

@ -19,9 +19,7 @@
*/ */
package org.linphone.contacts package org.linphone.contacts
import android.Manifest
import android.content.ContentUris import android.content.ContentUris
import android.content.pm.PackageManager
import android.graphics.Bitmap import android.graphics.Bitmap
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
@ -29,9 +27,9 @@ import android.provider.ContactsContract
import androidx.annotation.MainThread import androidx.annotation.MainThread
import androidx.annotation.UiThread import androidx.annotation.UiThread
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.core.app.ActivityCompat
import androidx.core.app.Person import androidx.core.app.Person
import androidx.core.graphics.drawable.IconCompat import androidx.core.graphics.drawable.IconCompat
import androidx.core.text.isDigitsOnly
import androidx.loader.app.LoaderManager import androidx.loader.app.LoaderManager
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -65,6 +63,7 @@ import org.linphone.utils.ImageUtils
import org.linphone.utils.LinphoneUtils import org.linphone.utils.LinphoneUtils
import org.linphone.utils.PhoneNumberUtils import org.linphone.utils.PhoneNumberUtils
import org.linphone.utils.ShortcutUtils import org.linphone.utils.ShortcutUtils
import java.io.FileNotFoundException
class ContactsManager class ContactsManager
@UiThread @UiThread
@ -73,7 +72,7 @@ class ContactsManager
private const val TAG = "[Contacts Manager]" private const val TAG = "[Contacts Manager]"
private const val DELAY_BEFORE_RELOADING_CONTACTS_AFTER_PRESENCE_RECEIVED = 1000L // 1 second 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" private const val FRIEND_LIST_TEMPORARY_STORED_REMOTE_DIRECTORY = "TempRemoteDirectoryContacts"
} }
@ -89,53 +88,72 @@ class ContactsManager
private val unknownRemoteContactDirectoriesContactsMap = arrayListOf<String>() private val unknownRemoteContactDirectoriesContactsMap = arrayListOf<String>()
private val coroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) 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 var loadContactsOnlyFromDefaultDirectory = true
private val magicSearchListener = object : MagicSearchListenerStub() { private val magicSearchListener = object : MagicSearchListenerStub() {
@WorkerThread @WorkerThread
override fun onSearchResultsReceived(magicSearch: MagicSearch) { override fun onSearchResultsReceived(magicSearch: MagicSearch) {
var queriedSipUri = ""
for ((key, value) in magicSearchMap.entries) {
if (value == magicSearch) {
queriedSipUri = key
}
}
val results = magicSearch.lastSearch 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 var found = false
if (results.isNotEmpty()) { if (results.isNotEmpty()) {
val result = results.first { val result = results.first { it.friend != null }
it.friend != null
}
if (result != null) { if (result != null) {
val friend = result.friend!! val friend = result.friend!!
Log.i("$TAG Found matching friend in source [${result.sourceFlags}]") 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... // Store friend in app's cache to be re-used in call history, conversations, etc...
val temporaryFriendList = getTemporaryFriendList(native = false) val temporaryFriendList = getRemoteContactDirectoriesCacheFriendList()
temporaryFriendList.addFriend(friend) temporaryFriendList.addFriend(friend)
newContactAdded(friend) newContactAdded(friend)
Log.i( Log.i(
"$TAG Stored discovered friend [${friend.name}] in temporary friend list, for later use" "$TAG Stored discovered friend [${friend.name}] in temporary friend list, for later use"
) )
for (listener in listeners) { for (listener in listeners) {
listener.onContactFoundInRemoteDirectory(friend) 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 = "" if (queriedSipUri.isNotEmpty()) {
for ((key, value) in magicSearchMap.entries) { magicSearchMap.remove(queriedSipUri)
if (value == magicSearch) {
foundKey = key
}
}
if (foundKey.isNotEmpty()) {
magicSearchMap.remove(foundKey)
if (!found) { if (!found) {
Log.i( 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) magicSearch.removeListener(this)
@ -147,7 +165,26 @@ class ContactsManager
override fun onPresenceReceived(friendList: FriendList, friends: Array<out Friend?>) { override fun onPresenceReceived(friendList: FriendList, friends: Array<out Friend?>) {
if (friendList.isSubscriptionBodyless) { if (friendList.isSubscriptionBodyless) {
Log.i("$TAG Bodyless friendlist [${friendList.displayName}] presence received") 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, friend: Friend,
sipUri: String sipUri: String
) { ) {
reloadContactsJob?.cancel() reloadPresenceContactsJob?.cancel()
Log.d( Log.d(
"$TAG Newly discovered SIP Address [$sipUri] for friend [${friend.name}] in list [${friendList.displayName}]" "$TAG Newly discovered SIP Address [$sipUri] for friend [${friend.name}] in list [${friendList.displayName}]"
) )
@ -168,12 +205,12 @@ class ContactsManager
friend.addAddress(address) friend.addAddress(address)
friend.done() friend.done()
newContactAddedWithSipUri(friend, sipUri) newContactAddedWithSipUri(friend, address)
} else { } else {
Log.e("$TAG Failed to parse SIP URI [$sipUri] as Address!") 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) delay(DELAY_BEFORE_RELOADING_CONTACTS_AFTER_PRESENCE_RECEIVED)
coreContext.postOnCoreThread { coreContext.postOnCoreThread {
Log.i("$TAG At least a new SIP address was discovered, reloading contacts") Log.i("$TAG At least a new SIP address was discovered, reloading contacts")
@ -306,7 +343,8 @@ class ContactsManager
} }
@WorkerThread @WorkerThread
private fun newContactAddedWithSipUri(friend: Friend, sipUri: String) { private fun newContactAddedWithSipUri(friend: Friend, address: Address) {
val sipUri = address.asStringUriOnly()
if (unknownContactsAvatarsMap.keys.contains(sipUri)) { if (unknownContactsAvatarsMap.keys.contains(sipUri)) {
Log.d("$TAG Found SIP URI [$sipUri] in unknownContactsAvatarsMap, removing it") Log.d("$TAG Found SIP URI [$sipUri] in unknownContactsAvatarsMap, removing it")
val oldModel = unknownContactsAvatarsMap[sipUri] val oldModel = unknownContactsAvatarsMap[sipUri]
@ -317,7 +355,6 @@ class ContactsManager
"$TAG Found SIP URI [$sipUri] in knownContactsAvatarsMap, forcing presence update" "$TAG Found SIP URI [$sipUri] in knownContactsAvatarsMap, forcing presence update"
) )
val oldModel = knownContactsAvatarsMap[sipUri] val oldModel = knownContactsAvatarsMap[sipUri]
val address = Factory.instance().createAddress(sipUri)
oldModel?.update(address) oldModel?.update(address)
} else { } else {
Log.i( Log.i(
@ -331,12 +368,8 @@ class ContactsManager
@WorkerThread @WorkerThread
fun newContactAdded(friend: Friend) { fun newContactAdded(friend: Friend) {
for (sipAddress in friend.addresses) { for (sipAddress in friend.addresses) {
newContactAddedWithSipUri(friend, sipAddress.asStringUriOnly()) newContactAddedWithSipUri(friend, sipAddress)
} }
conferenceAvatarMap.values.forEach(ContactAvatarModel::destroy)
conferenceAvatarMap.clear()
notifyContactsListChanged()
} }
@WorkerThread @WorkerThread
@ -369,14 +402,6 @@ class ContactsManager
nativeContactsLoaded = true nativeContactsLoaded = true
Log.i("$TAG Native contacts have been loaded, cleaning avatars maps") 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.values.forEach(ContactAvatarModel::destroy)
knownContactsAvatarsMap.clear() knownContactsAvatarsMap.clear()
unknownContactsAvatarsMap.values.forEach(ContactAvatarModel::destroy) unknownContactsAvatarsMap.values.forEach(ContactAvatarModel::destroy)
@ -387,8 +412,9 @@ class ContactsManager
notifyContactsListChanged() notifyContactsListChanged()
Log.i("$TAG Native contacts have been loaded, creating chat rooms shortcuts") Log.i("$TAG Native contacts have been loaded")
ShortcutUtils.createShortcutsToChatRooms(coreContext.context) // 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 @WorkerThread
@ -414,16 +440,15 @@ class ContactsManager
@WorkerThread @WorkerThread
fun findContactByAddress(address: Address): Friend? { fun findContactByAddress(address: Address): Friend? {
val sipUri = LinphoneUtils.getAddressAsCleanStringUriOnly(address) Log.i("$TAG Looking for friend matching SIP address [${address.asStringUriOnly()}]")
Log.d("$TAG Looking for friend with SIP URI [$sipUri]")
val username = address.username
val found = coreContext.core.findFriend(address) val found = coreContext.core.findFriend(address)
if (found != null) { 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 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 // Start an async query in Magic Search in case LDAP or remote CardDAV is configured
val remoteContactDirectories = coreContext.core.remoteContactDirectories val remoteContactDirectories = coreContext.core.remoteContactDirectories
if (remoteContactDirectories.isNotEmpty() && !magicSearchMap.keys.contains(sipUri) && !unknownRemoteContactDirectoriesContactsMap.contains( if (remoteContactDirectories.isNotEmpty() && !magicSearchMap.keys.contains(sipUri) && !unknownRemoteContactDirectoriesContactsMap.contains(
@ -446,33 +471,15 @@ class ContactsManager
) )
} }
val sipAddress = if (sipUri.startsWith("sip:")) { return if (!username.isNullOrEmpty() && (username.startsWith("+") || username.isDigitsOnly())) {
sipUri.substring("sip:".length) Log.i("$TAG Looking for friend using phone number [$username]")
} 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]")
val foundUsingPhoneNumber = coreContext.core.findFriendByPhoneNumber(username) val foundUsingPhoneNumber = coreContext.core.findFriendByPhoneNumber(username)
if (foundUsingPhoneNumber != null) { if (foundUsingPhoneNumber != null) {
Log.d( Log.i("$TAG Found friend [${foundUsingPhoneNumber.name}] matching phone number [$username]")
"$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)
} }
foundUsingPhoneNumber
} else { } else {
Log.d( null
"$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)
} }
} }
@ -516,7 +523,7 @@ class ContactsManager
model model
} else { } else {
Log.d("$TAG Looking for friend matching SIP URI [$key]") Log.d("$TAG Looking for friend matching SIP URI [$key]")
val friend = coreContext.contactsManager.findContactByAddress(clone) val friend = findContactByAddress(clone)
if (friend != null) { if (friend != null) {
Log.d("$TAG Matching friend [${friend.name}] found for SIP URI [$key]") Log.d("$TAG Matching friend [${friend.name}] found for SIP URI [$key]")
val model = ContactAvatarModel(friend, address) val model = ContactAvatarModel(friend, address)
@ -578,7 +585,7 @@ class ContactsManager
fun isContactTemporary(friend: Friend, allowNullFriendList: Boolean = false): Boolean { fun isContactTemporary(friend: Friend, allowNullFriendList: Boolean = false): Boolean {
val friendList = friend.friendList val friendList = friend.friendList
if (friendList == null && !allowNullFriendList) return true 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 @WorkerThread
@ -593,14 +600,16 @@ class ContactsManager
} }
val context = coreContext.context 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, context,
Manifest.permission.READ_CONTACTS Manifest.permission.READ_CONTACTS
) != PackageManager.PERMISSION_GRANTED ) != 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) ShortcutUtils.createShortcutsToChatRooms(context)
} }*/
for (list in core.friendsLists) { for (list in core.friendsLists) {
if (list.type == FriendList.Type.CardDAV && !list.uri.isNullOrEmpty()) { if (list.type == FriendList.Type.CardDAV && !list.uri.isNullOrEmpty()) {
@ -625,13 +634,14 @@ class ContactsManager
} }
@WorkerThread @WorkerThread
fun getTemporaryFriendList(native: Boolean): FriendList { fun getRemoteContactDirectoriesCacheFriendList(): FriendList {
val core = coreContext.core 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() val temporaryFriendList = core.getFriendListByName(name) ?: core.createFriendList()
if (temporaryFriendList.displayName.isNullOrEmpty()) { if (temporaryFriendList.displayName.isNullOrEmpty()) {
temporaryFriendList.isDatabaseStorageEnabled = false temporaryFriendList.isDatabaseStorageEnabled = false
temporaryFriendList.displayName = name temporaryFriendList.displayName = name
temporaryFriendList.type = FriendList.Type.ApplicationCache
core.addFriendList(temporaryFriendList) core.addFriendList(temporaryFriendList)
Log.i( Log.i(
"$TAG Created temporary friend list with name [$name]" "$TAG Created temporary friend list with name [$name]"
@ -640,14 +650,6 @@ class ContactsManager
return temporaryFriendList 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 @WorkerThread
fun getMePerson(localAddress: Address): Person { fun getMePerson(localAddress: Address): Person {
val account = coreContext.core.accountList.find { val account = coreContext.core.accountList.find {
@ -656,7 +658,7 @@ class ContactsManager
val name = account?.params?.identityAddress?.displayName ?: LinphoneUtils.getDisplayName( val name = account?.params?.identityAddress?.displayName ?: LinphoneUtils.getDisplayName(
localAddress localAddress
) )
val personBuilder = Person.Builder().setName(name) val personBuilder = Person.Builder().setName(name.ifEmpty { "Unknown" })
val photo = account?.params?.pictureUri.orEmpty() val photo = account?.params?.pictureUri.orEmpty()
val bm = ImageUtils.getBitmap(coreContext.context, photo) val bm = ImageUtils.getBitmap(coreContext.context, photo)
@ -711,7 +713,7 @@ fun Friend.getAvatarBitmap(round: Boolean = false): Bitmap? {
photo ?: getNativeContactPictureUri()?.toString(), photo ?: getNativeContactPictureUri()?.toString(),
round round
) )
} catch (numberFormatException: NumberFormatException) { } catch (_: NumberFormatException) {
// Expected for contacts created by Linphone // Expected for contacts created by Linphone
} }
return null return null
@ -739,6 +741,8 @@ fun Friend.getNativeContactPictureUri(): Uri? {
fd.close() fd.close()
return pictureUri return pictureUri
} }
} catch (fnfe: FileNotFoundException) {
Log.w("[Contacts Manager] Can't open [$pictureUri] for contact [$name]: $fnfe")
} catch (e: Exception) { } catch (e: Exception) {
Log.e("[Contacts Manager] Can't open [$pictureUri] for contact [$name]: $e") Log.e("[Contacts Manager] Can't open [$pictureUri] for contact [$name]: $e")
} }
@ -748,7 +752,7 @@ fun Friend.getNativeContactPictureUri(): Uri? {
lookupUri, lookupUri,
ContactsContract.Contacts.Photo.CONTENT_DIRECTORY ContactsContract.Contacts.Photo.CONTENT_DIRECTORY
) )
} catch (numberFormatException: NumberFormatException) { } catch (_: NumberFormatException) {
// Expected for contacts created by Linphone // Expected for contacts created by Linphone
} }
} }
@ -757,7 +761,25 @@ fun Friend.getNativeContactPictureUri(): Uri? {
@WorkerThread @WorkerThread
fun Friend.getPerson(): Person { 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() val bm: Bitmap? = getAvatarBitmap()
personBuilder.setIcon( personBuilder.setIcon(
@ -765,7 +787,7 @@ fun Friend.getPerson(): Person {
Log.i( Log.i(
"[Friend] Can't use friend [$name] picture path, generating avatar based on initials" "[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 { } else {
IconCompat.createWithAdaptiveBitmap(bm) IconCompat.createWithAdaptiveBitmap(bm)
} }
@ -780,6 +802,7 @@ fun Friend.getPerson(): Person {
@WorkerThread @WorkerThread
fun Friend.getListOfSipAddresses(): ArrayList<Address> { fun Friend.getListOfSipAddresses(): ArrayList<Address> {
val addressesList = arrayListOf<Address>() val addressesList = arrayListOf<Address>()
if (corePreferences.hideSipAddresses) return addressesList
for (address in addresses) { for (address in addresses) {
if (addressesList.find { it.weakEqual(address) } == null) { if (addressesList.find { it.weakEqual(address) } == null) {
@ -794,7 +817,12 @@ fun Friend.getListOfSipAddresses(): ArrayList<Address> {
fun Friend.getListOfSipAddressesAndPhoneNumbers(listener: ContactNumberOrAddressClickListener): ArrayList<ContactNumberOrAddressModel> { fun Friend.getListOfSipAddressesAndPhoneNumbers(listener: ContactNumberOrAddressClickListener): ArrayList<ContactNumberOrAddressModel> {
val addressesAndNumbers = arrayListOf<ContactNumberOrAddressModel>() val addressesAndNumbers = arrayListOf<ContactNumberOrAddressModel>()
// Will return an empty list if corePreferences.hideSipAddresses == true
for (address in getListOfSipAddresses()) { for (address in getListOfSipAddresses()) {
if (LinphoneUtils.isSipAddressLinkedToPhoneNumberByPresence(this, address.asStringUriOnly())) {
continue
}
val data = ContactNumberOrAddressModel( val data = ContactNumberOrAddressModel(
this, this,
address, address,
@ -805,39 +833,26 @@ fun Friend.getListOfSipAddressesAndPhoneNumbers(listener: ContactNumberOrAddress
) )
addressesAndNumbers.add(data) addressesAndNumbers.add(data)
} }
if (corePreferences.hidePhoneNumbers) { if (corePreferences.hidePhoneNumbers) {
return addressesAndNumbers return addressesAndNumbers
} }
val indexOfLastSipAddress = addressesAndNumbers.count()
for (number in phoneNumbersWithLabel) { for (number in phoneNumbersWithLabel) {
val presenceModel = getPresenceModelForUriOrTel(number.phoneNumber) val phoneNumber = number.phoneNumber
val presenceModel = getPresenceModelForUriOrTel(phoneNumber)
val hasPresenceInfo = !presenceModel?.contact.isNullOrEmpty() val hasPresenceInfo = !presenceModel?.contact.isNullOrEmpty()
var presenceAddress: Address? = null var presenceAddress: Address? = null
if (presenceModel != null && hasPresenceInfo) { 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 val contact = presenceModel.contact
if (!contact.isNullOrEmpty()) { if (!contact.isNullOrEmpty()) {
val address = core.interpretUrl(contact, false) val address = core.interpretUrl(contact, false)
if (address != null) { if (address != null) {
address.clean() // To remove ;user=phone address.clean() // To remove ;user=phone
presenceAddress = address presenceAddress = address
if (addressesAndNumbers.find { it.address?.weakEqual(address) == true } == null) { } else {
val data = ContactNumberOrAddressModel( Log.e("[Contacts Manager] Failed to parse phone number [$phoneNumber] contact address [$contact] from presence model!")
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()}]"
)
} }
} }
} }
@ -846,17 +861,20 @@ fun Friend.getListOfSipAddressesAndPhoneNumbers(listener: ContactNumberOrAddress
val defaultAccount = LinphoneUtils.getDefaultAccount() val defaultAccount = LinphoneUtils.getDefaultAccount()
val enablePhoneNumbers = hasPresenceInfo || !isEndToEndEncryptionMandatory() val enablePhoneNumbers = hasPresenceInfo || !isEndToEndEncryptionMandatory()
val address = presenceAddress ?: core.interpretUrl( val address = presenceAddress ?: core.interpretUrl(
number.phoneNumber, phoneNumber,
LinphoneUtils.applyInternationalPrefix(defaultAccount) LinphoneUtils.applyInternationalPrefix(defaultAccount)
) )
address ?: continue
val label = PhoneNumberUtils.vcardParamStringToAddressBookLabel( val label = PhoneNumberUtils.vcardParamStringToAddressBookLabel(
coreContext.context.resources, coreContext.context.resources,
number.label ?: "" 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( val data = ContactNumberOrAddressModel(
this, this,
address, address,
number.phoneNumber, phoneNumber,
enablePhoneNumbers, enablePhoneNumbers,
listener, listener,
false, false,

View file

@ -21,7 +21,9 @@ package org.linphone.core
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Application import android.app.Application
import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Context.POWER_SERVICE
import android.content.Intent import android.content.Intent
import android.media.AudioDeviceCallback import android.media.AudioDeviceCallback
import android.media.AudioDeviceInfo import android.media.AudioDeviceInfo
@ -29,15 +31,20 @@ import android.media.AudioManager
import android.os.Handler import android.os.Handler
import android.os.HandlerThread import android.os.HandlerThread
import android.os.Looper import android.os.Looper
import android.os.PowerManager
import android.provider.Settings import android.provider.Settings
import android.provider.Settings.SettingNotFoundException
import androidx.annotation.AnyThread import androidx.annotation.AnyThread
import androidx.annotation.UiThread import androidx.annotation.UiThread
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.core.text.isDigitsOnly
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import com.google.firebase.crashlytics.FirebaseCrashlytics import com.google.firebase.crashlytics.FirebaseCrashlytics
import kotlin.system.exitProcess import kotlin.system.exitProcess
import org.linphone.BuildConfig import org.linphone.BuildConfig
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.LinphoneApplication.Companion.corePreferences import org.linphone.LinphoneApplication.Companion.corePreferences
import org.linphone.compatibility.Compatibility
import org.linphone.contacts.ContactsManager import org.linphone.contacts.ContactsManager
import org.linphone.core.tools.Log import org.linphone.core.tools.Log
import org.linphone.notifications.NotificationsManager import org.linphone.notifications.NotificationsManager
@ -45,6 +52,7 @@ import org.linphone.telecom.TelecomManager
import org.linphone.ui.call.CallActivity import org.linphone.ui.call.CallActivity
import org.linphone.utils.ActivityMonitor import org.linphone.utils.ActivityMonitor
import org.linphone.utils.AppUtils import org.linphone.utils.AppUtils
import org.linphone.utils.AudioUtils
import org.linphone.utils.Event import org.linphone.utils.Event
import org.linphone.utils.FileUtils import org.linphone.utils.FileUtils
import org.linphone.utils.LinphoneUtils import org.linphone.utils.LinphoneUtils
@ -80,6 +88,8 @@ class CoreContext
private val mainThread = Handler(Looper.getMainLooper()) private val mainThread = Handler(Looper.getMainLooper())
var defaultAccountHasVideoConferenceFactoryUri: Boolean = false
var bearerAuthInfoPendingPasswordUpdate: AuthInfo? = null var bearerAuthInfoPendingPasswordUpdate: AuthInfo? = null
var digestAuthInfoPendingPasswordUpdate: AuthInfo? = null var digestAuthInfoPendingPasswordUpdate: AuthInfo? = null
@ -122,6 +132,10 @@ class CoreContext
MutableLiveData<Event<List<String>>>() MutableLiveData<Event<List<String>>>()
} }
private var keepAliveServiceStarted = false
private lateinit var proximityWakeLock: PowerManager.WakeLock
@SuppressLint("HandlerLeak") @SuppressLint("HandlerLeak")
private lateinit var coreThread: Handler private lateinit var coreThread: Handler
@ -130,14 +144,29 @@ class CoreContext
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>?) { override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>?) {
if (!addedDevices.isNullOrEmpty()) { if (!addedDevices.isNullOrEmpty()) {
Log.i("$TAG [${addedDevices.size}] new device(s) have been added:") Log.i("$TAG [${addedDevices.size}] new device(s) have been added:")
var atLeastOneNewDeviceIsBluetooth = false
for (device in addedDevices) { for (device in addedDevices) {
Log.i( Log.i(
"$TAG Added device [${device.productName}] with ID [${device.id}] and type [${device.type}]" "$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") 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}]" "$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") Log.i("$TAG Reloading sound devices in 500ms")
postOnCoreThreadDelayed({ core.reloadSoundDevices() }, 500) postOnCoreThreadDelayed({
} else { Log.i("$TAG Reloading sound devices")
Log.i( core.reloadSoundDevices()
"$TAG At least one active call in Telecom's CallsManager, let it handle the removed device" }, 500)
)
}
} }
} }
} }
@ -165,6 +192,26 @@ class CoreContext
private var previousCallState = Call.State.Idle private var previousCallState = Call.State.Idle
private val coreListener = object : CoreListenerStub() { 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 @WorkerThread
override fun onMessagesReceived( override fun onMessagesReceived(
core: Core, core: Core,
@ -222,6 +269,18 @@ class CoreContext
) { ) {
Log.i("$TAG Configuring state changed [$status], message is [$message]") Log.i("$TAG Configuring state changed [$status], message is [$message]")
if (status == ConfiguringState.Successful) { 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)) provisioningAppliedEvent.postValue(Event(true))
corePreferences.firstLaunch = false corePreferences.firstLaunch = false
showGreenToastEvent.postValue( showGreenToastEvent.postValue(
@ -256,6 +315,34 @@ class CoreContext
"$TAG Call [${call.remoteAddress.asStringUriOnly()}] state changed [$currentState]" "$TAG Call [${call.remoteAddress.asStringUriOnly()}] state changed [$currentState]"
) )
when (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 -> { Call.State.OutgoingInit -> {
val conferenceInfo = core.findConferenceInformationFromUri(call.remoteAddress) val conferenceInfo = core.findConferenceInformationFromUri(call.remoteAddress)
// Do not show outgoing call view for conference calls, wait for connected state // 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 -> { Call.State.Connected -> {
postOnMainThread { postOnMainThread {
showCallActivity() 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 -> { Call.State.StreamsRunning -> {
if (previousCallState == Call.State.Connected) { if (previousCallState == Call.State.Connected) {
@ -282,6 +379,15 @@ class CoreContext
call.startRecording() 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 -> { Call.State.Error -> {
@ -319,6 +425,11 @@ class CoreContext
Log.i("$TAG Available audio devices list was updated") Log.i("$TAG Available audio devices list was updated")
} }
@WorkerThread
override fun onFirstCallStarted(core: Core) {
Log.i("$TAG First call started")
}
@WorkerThread @WorkerThread
override fun onLastCallEnded(core: Core) { override fun onLastCallEnded(core: Core) {
Log.i("$TAG Last call ended") Log.i("$TAG Last call ended")
@ -332,6 +443,11 @@ class CoreContext
core.videoDevice = frontFacing core.videoDevice = frontFacing
} }
} }
postOnMainThread {
Log.i("$TAG Releasing proximity sensor if it was enabled")
enableProximitySensor(false)
}
} }
@WorkerThread @WorkerThread
@ -424,12 +540,32 @@ class CoreContext
if (account.findAuthInfo() == digestAuthInfoPendingPasswordUpdate) { if (account.findAuthInfo() == digestAuthInfoPendingPasswordUpdate) {
Log.i("$TAG Removed account matches auth info pending password update, removing dialog") Log.i("$TAG Removed account matches auth info pending password update, removing dialog")
clearAuthenticationRequestDialogEvent.postValue(Event(true)) 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 logcatEnabled: Boolean = corePreferences.printLogsInLogcat
private var crashlyticsEnabled: Boolean = corePreferences.sendLogsToCrashlytics
private var crashlyticsAvailable = true
private val loggingServiceListener = object : LoggingServiceListenerStub() { private val loggingServiceListener = object : LoggingServiceListenerStub() {
@WorkerThread @WorkerThread
override fun onLogMessageWritten( override fun onLogMessageWritten(
@ -447,7 +583,9 @@ class CoreContext
else -> android.util.Log.d(domain, message) 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) Factory.instance().loggingService.addListener(loggingServiceListener)
} catch (e: Exception) { } catch (e: Exception) {
Log.e("$TAG Failed to instantiate Crashlytics: $e") Log.e("$TAG Failed to instantiate Crashlytics: $e")
crashlyticsEnabled = false
crashlyticsAvailable = false
} }
} else { } else {
Log.i("$TAG Crashlytics is disabled") Log.i("$TAG Crashlytics is disabled")
crashlyticsAvailable = false
} }
Log.i("=========================================") Log.i("=========================================")
Log.i("==== Linphone-android information dump ====") Log.i("==== Linphone-android information dump ====")
@ -487,6 +628,8 @@ class CoreContext
core.isAutoIterateEnabled = true core.isAutoIterateEnabled = true
core.addListener(coreListener) core.addListener(coreListener)
defaultAccountHasVideoConferenceFactoryUri = core.defaultAccount?.params?.audioVideoConferenceFactoryAddress != null
coreThread.postDelayed({ startCore() }, 50) coreThread.postDelayed({ startCore() }, 50)
Looper.loop() Looper.loop()
@ -505,7 +648,6 @@ class CoreContext
@WorkerThread @WorkerThread
fun startCore() { fun startCore() {
Log.i("$TAG Starting Core") Log.i("$TAG Starting Core")
updateFriendListsSubscriptionDependingOnDefaultAccount()
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
audioManager.registerAudioDeviceCallback(audioDeviceCallback, coreThread) audioManager.registerAudioDeviceCallback(audioDeviceCallback, coreThread)
@ -537,6 +679,15 @@ class CoreContext
if (oldVersion < 600000) { // 6.0.0 initial release if (oldVersion < 600000) { // 6.0.0 initial release
configurationMigration5To6() 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 corePreferences.linphoneConfigurationVersion = currentVersion
@ -547,15 +698,29 @@ class CoreContext
Log.i("$TAG No configuration migration required") Log.i("$TAG No configuration migration required")
} }
if (corePreferences.keepServiceAlive) {
Log.i("$TAG Starting keep alive service")
startKeepAliveService()
}
contactsManager.onCoreStarted(core) contactsManager.onCoreStarted(core)
telecomManager.onCoreStarted(core) telecomManager.onCoreStarted(core)
notificationsManager.onCoreStarted(core, oldVersion < 600000) // Re-create channels when migrating from a non 6.0 version notificationsManager.onCoreStarted(core, oldVersion < 600000) // Re-create channels when migrating from a non 6.0 version
Log.i("$TAG Started contacts, telecom & notifications managers") 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 @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 @AnyThread
fun postOnMainThread( fun postOnMainThread(
@UiThread lambda: () -> Unit @UiThread lambda: () -> Unit
@ -643,6 +823,10 @@ class CoreContext
Log.i("$TAG App is in foreground, PUBLISHING presence as Online") Log.i("$TAG App is in foreground, PUBLISHING presence as Online")
core.consolidatedPresence = ConsolidatedPresence.Online core.consolidatedPresence = ConsolidatedPresence.Online
} }
if (corePreferences.keepServiceAlive && !keepAliveServiceStarted) {
startKeepAliveService()
}
} }
} }
@ -742,10 +926,6 @@ class CoreContext
if (forceZRTP) { if (forceZRTP) {
params.mediaEncryption = MediaEncryption.ZRTP params.mediaEncryption = MediaEncryption.ZRTP
} }
/*if (LinphoneUtils.checkIfNetworkHasLowBandwidth(context)) {
Log.w("$TAG Enabling low bandwidth mode!")
params.isLowBandwidthEnabled = true
}*/
params.recordFile = LinphoneUtils.getRecordingFilePathForAddress(address) params.recordFile = LinphoneUtils.getRecordingFilePathForAddress(address)
@ -759,14 +939,39 @@ class CoreContext
"$TAG Using account matching address ${localAddress.asStringUriOnly()} as From" "$TAG Using account matching address ${localAddress.asStringUriOnly()} as From"
) )
} else { } else {
val defaultAccount = core.defaultAccount
params.account = defaultAccount
Log.e( 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) core.inviteAddressWithParams(address, params)
Log.i("$TAG Starting call $call") Log.i("$TAG Starting call to [${address.asStringUriOnly()}]")
} }
@WorkerThread @WorkerThread
@ -796,7 +1001,7 @@ class CoreContext
} }
@WorkerThread @WorkerThread
fun answerCall(call: Call) { fun answerCall(call: Call, autoAnswer: Boolean = false) {
Log.i( Log.i(
"$TAG Answering call with remote address [${call.remoteAddress.asStringUriOnly()}] and to address [${call.toAddress.asStringUriOnly()}]" "$TAG Answering call with remote address [${call.remoteAddress.asStringUriOnly()}] and to address [${call.toAddress.asStringUriOnly()}]"
) )
@ -822,6 +1027,12 @@ class CoreContext
Log.i( Log.i(
"$TAG Enabling video on call params to prevent audio-only layout when answering" "$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) call.acceptWithParams(params)
@ -849,11 +1060,25 @@ class CoreContext
intent.addFlags( intent.addFlags(
Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT 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 @WorkerThread
fun startKeepAliveService() { fun startKeepAliveService() {
if (keepAliveServiceStarted) {
Log.w("$TAG Keep alive service already started, skipping")
}
val serviceIntent = Intent(Intent.ACTION_MAIN).setClass( val serviceIntent = Intent(Intent.ACTION_MAIN).setClass(
context, context,
CoreKeepAliveThirdPartyAccountsService::class.java CoreKeepAliveThirdPartyAccountsService::class.java
@ -861,6 +1086,7 @@ class CoreContext
Log.i("$TAG Starting Keep alive for third party accounts Service") Log.i("$TAG Starting Keep alive for third party accounts Service")
try { try {
context.startService(serviceIntent) context.startService(serviceIntent)
keepAliveServiceStarted = true
} catch (e: Exception) { } catch (e: Exception) {
Log.e("$TAG Failed to start keep alive service: $e") 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" "$TAG Stopping Keep alive for third party accounts Service"
) )
context.stopService(serviceIntent) context.stopService(serviceIntent)
} keepAliveServiceStarted = false
@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")
}
} }
@WorkerThread @WorkerThread
fun playDtmf(character: Char, duration: Int = 200, ignoreSystemPolicy: Boolean = false) { fun playDtmf(character: Char, duration: Int = 200, ignoreSystemPolicy: Boolean = false) {
if (ignoreSystemPolicy || Settings.System.getInt( try {
context.contentResolver, if (ignoreSystemPolicy || Settings.System.getInt(
Settings.System.DTMF_TONE_WHEN_DIALING context.contentResolver,
) != 0 Settings.System.DTMF_TONE_WHEN_DIALING
) { ) != 0
core.playDtmf(character, duration) ) {
} else { core.playDtmf(character, duration)
Log.w("$TAG Numpad DTMF tones are disabled in system settings, not playing them") } 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) core.setUserAgent(userAgent, sdkUserAgent)
} }
// Migration between versions related
@WorkerThread @WorkerThread
fun enableLogcat(enable: Boolean) { private fun removePortFromSipIdentity() {
logcatEnabled = enable 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 @WorkerThread
@ -966,7 +1208,7 @@ class CoreContext
for (account in core.accountList) { for (account in core.accountList) {
val params = account.params 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() val clone = params.clone()
clone.limeAlgo = "c25519" clone.limeAlgo = "c25519"
Log.i("$TAG Updating account [${params.identityAddress?.asStringUriOnly()}] params to use LIME algo 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)") Log.i("$TAG Removing previous grammar files (without .belr extension)")
corePreferences.clearPreviousGrammars() 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 package org.linphone.core
import android.Manifest import android.annotation.SuppressLint
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.os.IBinder import android.os.IBinder
import androidx.annotation.AnyThread import androidx.annotation.AnyThread
import androidx.annotation.MainThread import androidx.annotation.MainThread
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.R import org.linphone.R
import org.linphone.compatibility.Compatibility
import org.linphone.core.tools.Log import org.linphone.core.tools.Log
import org.linphone.core.tools.service.FileTransferService import org.linphone.core.tools.service.FileTransferService
import org.linphone.ui.main.MainActivity import org.linphone.ui.main.MainActivity
@ -171,14 +170,11 @@ class CoreFileTransferService : FileTransferService() {
postNotification() postNotification()
} }
@SuppressLint("MissingPermission")
@AnyThread @AnyThread
private fun postNotification() { private fun postNotification() {
val notificationsManager = NotificationManagerCompat.from(this) val notificationsManager = NotificationManagerCompat.from(this)
if (ActivityCompat.checkSelfPermission( if (Compatibility.isPostNotificationsPermissionGranted(this)) {
this,
Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED
) {
if (mServiceNotification != null) { if (mServiceNotification != null) {
Log.i("$TAG Sending notification to manager") Log.i("$TAG Sending notification to manager")
notificationsManager.notify(SERVICE_NOTIF_ID, mServiceNotification) notificationsManager.notify(SERVICE_NOTIF_ID, mServiceNotification)

View file

@ -61,6 +61,10 @@ class CoreInCallService : CoreService() {
return null return null
} }
override fun createServiceNotificationChannel() {
// Do nothing, app's Notifications Manager will do the job
}
override fun createServiceNotification() { override fun createServiceNotification() {
// Do nothing, app's Notifications Manager will do the job // 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.AnyThread
import androidx.annotation.UiThread import androidx.annotation.UiThread
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import org.linphone.BuildConfig
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.LinphoneApplication.Companion.coreContext
@ -39,116 +40,180 @@ class CorePreferences
private var _config: Config? = null private var _config: Config? = null
@get:WorkerThread @set:WorkerThread @get:AnyThread @set:WorkerThread
var config: Config var config: Config
get() = _config ?: coreContext.core.config get() = _config ?: coreContext.core.config
set(value) { set(value) {
_config = value _config = value
} }
@get:WorkerThread @set:WorkerThread @get:AnyThread @set:WorkerThread
var printLogsInLogcat: Boolean var printLogsInLogcat: Boolean
get() = config.getBool("app", "debug", org.linphone.BuildConfig.DEBUG) get() = config.getBool("app", "debug", BuildConfig.DEBUG)
set(value) { set(value) {
config.setBool("app", "debug", 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 var firstLaunch: Boolean
get() = config.getBool("app", "first_6.0_launch", true) get() = config.getBool("app", "first_6.0_launch", true)
set(value) { set(value) {
config.setBool("app", "first_6.0_launch", value) config.setBool("app", "first_6.0_launch", value)
} }
@get:WorkerThread @set:WorkerThread @get:AnyThread @set:WorkerThread
var linphoneConfigurationVersion: Int var linphoneConfigurationVersion: Int
get() = config.getInt("app", "config_version", 52005) get() = config.getInt("app", "config_version", 52005)
set(value) { set(value) {
config.setInt("app", "config_version", 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 var checkForUpdateServerUrl: String
get() = config.getString("misc", "version_check_url_root", "").orEmpty() get() = config.getString("misc", "version_check_url_root", "").orEmpty()
set(value) { set(value) {
config.setString("misc", "version_check_url_root", value) config.setString("misc", "version_check_url_root", value)
} }
@get:WorkerThread @set:WorkerThread @get:AnyThread @set:WorkerThread
var conditionsAndPrivacyPolicyAccepted: Boolean var conditionsAndPrivacyPolicyAccepted: Boolean
get() = config.getBool("app", "read_and_agree_terms_and_privacy", false) get() = config.getBool("app", "read_and_agree_terms_and_privacy", false)
set(value) { set(value) {
config.setBool("app", "read_and_agree_terms_and_privacy", value) config.setBool("app", "read_and_agree_terms_and_privacy", value)
} }
@get:WorkerThread @set:WorkerThread @get:AnyThread @set:WorkerThread
var publishPresence: Boolean var publishPresence: Boolean
get() = config.getBool("app", "publish_presence", true) get() = config.getBool("app", "publish_presence", true)
set(value) { set(value) {
config.setBool("app", "publish_presence", value) config.setBool("app", "publish_presence", value)
} }
@get:WorkerThread @set:WorkerThread @get:AnyThread @set:WorkerThread
var keepServiceAlive: Boolean var keepServiceAlive: Boolean
get() = config.getBool("app", "keep_service_alive", false) get() = config.getBool("app", "keep_service_alive", false)
set(value) { set(value) {
config.setBool("app", "keep_service_alive", value) config.setBool("app", "keep_service_alive", value)
} }
@get:WorkerThread @set:WorkerThread @get:AnyThread @set:WorkerThread
var deviceName: String var deviceName: String
get() = config.getString("app", "device", "").orEmpty().trim() get() = config.getString("app", "device", "").orEmpty().trim()
set(value) { set(value) {
config.setString("app", "device", value.trim()) 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 // Call settings
// This won't be done if bluetooth or wired headset is used // 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 var routeAudioToSpeakerWhenVideoIsEnabled: Boolean
get() = config.getBool("app", "route_audio_to_speaker_when_video_enabled", true) get() = config.getBool("app", "route_audio_to_speaker_when_video_enabled", true)
set(value) { set(value) {
config.setBool("app", "route_audio_to_speaker_when_video_enabled", 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 var automaticallyStartCallRecording: Boolean
get() = config.getBool("app", "auto_start_call_record", false) get() = config.getBool("app", "auto_start_call_record", false)
set(value) { set(value) {
config.setBool("app", "auto_start_call_record", value) config.setBool("app", "auto_start_call_record", value)
} }
@get:WorkerThread @set:WorkerThread @get:AnyThread @set:WorkerThread
var showDialogWhenCallingDeviceUuidDirectly: Boolean var showDialogWhenCallingDeviceUuidDirectly: Boolean
get() = config.getBool("app", "show_confirmation_dialog_zrtp_trust_call", true) get() = config.getBool("app", "show_confirmation_dialog_zrtp_trust_call", true)
set(value) { set(value) {
config.setBool("app", "show_confirmation_dialog_zrtp_trust_call", value) config.setBool("app", "show_confirmation_dialog_zrtp_trust_call", value)
} }
@get:WorkerThread @set:WorkerThread @get:AnyThread @set:WorkerThread
var acceptEarlyMedia: Boolean var acceptEarlyMedia: Boolean
get() = config.getBool("sip", "incoming_calls_early_media", false) get() = config.getBool("sip", "incoming_calls_early_media", false)
set(value) { set(value) {
config.setBool("sip", "incoming_calls_early_media", value) config.setBool("sip", "incoming_calls_early_media", value)
} }
@get:WorkerThread @set:WorkerThread @get:AnyThread @set:WorkerThread
var allowOutgoingEarlyMedia: Boolean var allowOutgoingEarlyMedia: Boolean
get() = config.getBool("misc", "real_early_media", false) get() = config.getBool("misc", "real_early_media", false)
set(value) { set(value) {
config.setBool("misc", "real_early_media", 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)
}
@get:AnyThread @set:WorkerThread
var showAdvancedCallStats: Boolean
get() = config.getBool("ui", "show_advanced_call_stats", false)
set(value) {
config.setBool("ui", "show_advanced_call_stats", value)
}
// Conversation related // Conversation related
@get:WorkerThread @set:WorkerThread @get:AnyThread @set:WorkerThread
var markConversationAsReadWhenDismissingMessageNotification: Boolean var markConversationAsReadWhenDismissingMessageNotification: Boolean
get() = config.getBool("app", "mark_as_read_notif_dismissal", false) get() = config.getBool("app", "mark_as_read_notif_dismissal", false)
set(value) { set(value) {
config.setBool("app", "mark_as_read_notif_dismissal", value) config.setBool("app", "mark_as_read_notif_dismissal", value)
} }
@get:AnyThread @set:WorkerThread
var makePublicMediaFilesDownloaded: Boolean var makePublicMediaFilesDownloaded: Boolean
// Keep old name for backward compatibility // Keep old name for backward compatibility
get() = config.getBool("app", "make_downloaded_images_public_in_gallery", false) get() = config.getBool("app", "make_downloaded_images_public_in_gallery", false)
@ -158,7 +223,7 @@ class CorePreferences
// Conference related // Conference related
@get:WorkerThread @set:WorkerThread @get:AnyThread @set:WorkerThread
var createEndToEndEncryptedMeetingsAndGroupCalls: Boolean var createEndToEndEncryptedMeetingsAndGroupCalls: Boolean
get() = config.getBool("app", "create_e2e_encrypted_conferences", false) get() = config.getBool("app", "create_e2e_encrypted_conferences", false)
set(value) { set(value) {
@ -167,21 +232,35 @@ class CorePreferences
// Contacts related // 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 var contactsFilter: String
get() = config.getString("ui", "contacts_filter", "")!! // Default value must be empty! get() = config.getString("ui", "contacts_filter", "")!! // Default value must be empty!
set(value) { set(value) {
config.setString("ui", "contacts_filter", value) config.setString("ui", "contacts_filter", value)
} }
@get:WorkerThread @set:WorkerThread @get:AnyThread @set:WorkerThread
var showFavoriteContacts: Boolean var showFavoriteContacts: Boolean
get() = config.getBool("ui", "show_favorites_contacts", true) get() = config.getBool("ui", "show_favorites_contacts", true)
set(value) { set(value) {
config.setBool("ui", "show_favorites_contacts", value) config.setBool("ui", "show_favorites_contacts", value)
} }
@get:WorkerThread @set:WorkerThread @get:AnyThread @set:WorkerThread
var friendListInWhichStoreNewlyCreatedFriends: String var friendListInWhichStoreNewlyCreatedFriends: String
get() = config.getString( get() = config.getString(
"app", "app",
@ -192,9 +271,23 @@ class CorePreferences
config.setString("app", "friend_list_to_store_newly_created_contacts", value) 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 // Voice recordings related
@get:WorkerThread @set:WorkerThread @get:AnyThread @set:WorkerThread
var voiceRecordingMaxDuration: Int var voiceRecordingMaxDuration: Int
get() = config.getInt("app", "voice_recording_max_duration", 600000) // in ms get() = config.getInt("app", "voice_recording_max_duration", 600000) // in ms
set(value) = config.setInt("app", "voice_recording_max_duration", value) set(value) = config.setInt("app", "voice_recording_max_duration", value)
@ -202,7 +295,7 @@ class CorePreferences
// User interface related // User interface related
// -1 means auto, 0 no, 1 yes // -1 means auto, 0 no, 1 yes
@get:WorkerThread @set:WorkerThread @get:AnyThread @set:WorkerThread
var darkMode: Int var darkMode: Int
get() { get() {
if (!darkModeAllowed) return 0 if (!darkModeAllowed) return 0
@ -213,93 +306,132 @@ class CorePreferences
} }
// Allows to make screenshots // Allows to make screenshots
@get:WorkerThread @set:WorkerThread @get:AnyThread @set:WorkerThread
var enableSecureMode: Boolean var enableSecureMode: Boolean
get() = config.getBool("ui", "enable_secure_mode", true) get() = config.getBool("ui", "enable_secure_mode", true)
set(value) { set(value) {
config.setBool("ui", "enable_secure_mode", 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 var themeMainColor: String
get() = config.getString("ui", "theme_main_color", "orange")!! get() = config.getString("ui", "theme_main_color", "orange")!!
set(value) { set(value) {
config.setString("ui", "theme_main_color", 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 val darkModeAllowed: Boolean
get() = config.getBool("ui", "dark_mode_allowed", true) get() = config.getBool("ui", "dark_mode_allowed", true)
@get:WorkerThread @get:AnyThread
val changeMainColorAllowed: Boolean val changeMainColorAllowed: Boolean
get() = config.getBool("ui", "change_main_color_allowed", false) get() = config.getBool("ui", "change_main_color_allowed", false)
@get:WorkerThread @get:AnyThread
val onlyDisplaySipUriUsername: Boolean val onlyDisplaySipUriUsername: Boolean
get() = config.getBool("ui", "only_display_sip_uri_username", false) 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 val disableChat: Boolean
get() = config.getBool("ui", "disable_chat_feature", false) get() = config.getBool("ui", "disable_chat_feature", false)
@get:WorkerThread @get:AnyThread
val disableMeetings: Boolean val disableMeetings: Boolean
get() = config.getBool("ui", "disable_meetings_feature", false) get() = config.getBool("ui", "disable_meetings_feature", false)
@get:WorkerThread @get:AnyThread
val disableBroadcasts: Boolean val disableBroadcasts: Boolean
get() = config.getBool("ui", "disable_broadcast_feature", true) // TODO FIXME: not implemented yet get() = config.getBool("ui", "disable_broadcast_feature", true) // TODO FIXME: not implemented yet
@get:WorkerThread @get:AnyThread
val disableCallRecordings: Boolean val disableCallRecordings: Boolean
get() = config.getBool("ui", "disable_call_recordings_feature", false) get() = config.getBool("ui", "disable_call_recordings_feature", false)
@get:WorkerThread @get:AnyThread
val maxAccountsCount: Int val maxAccountsCount: Int
get() = config.getInt("ui", "max_account", 0) // 0 means no max get() = config.getInt("ui", "max_account", 0) // 0 means no max
@get:WorkerThread @get:AnyThread
val hidePhoneNumbers: Boolean val hidePhoneNumbers: Boolean
get() = config.getBool("ui", "hide_phone_numbers", false) get() = config.getBool("ui", "hide_phone_numbers", false)
@get:WorkerThread @get:AnyThread
val hideSettings: Boolean val hideSettings: Boolean
get() = config.getBool("ui", "hide_settings", false) get() = config.getBool("ui", "hide_settings", false)
@get:WorkerThread @get:AnyThread
val hideAccountSettings: Boolean val hideAccountSettings: Boolean
get() = config.getBool("ui", "hide_account_settings", false) 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 val hideAssistantCreateAccount: Boolean
get() = config.getBool("ui", "assistant_hide_create_account", false) get() = config.getBool("ui", "assistant_hide_create_account", false)
@get:WorkerThread @get:AnyThread
val hideAssistantScanQrCode: Boolean val hideAssistantScanQrCode: Boolean
get() = config.getBool("ui", "assistant_disable_qr_code", false) get() = config.getBool("ui", "assistant_disable_qr_code", false)
@get:WorkerThread @get:AnyThread
val hideAssistantThirdPartySipAccount: Boolean val hideAssistantThirdPartySipAccount: Boolean
get() = config.getBool("ui", "assistant_hide_third_party_account", false) 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 val singleSignOnClientId: String
get() = config.getString("app", "oidc_client_id", "linphone")!! get() = config.getString("app", "oidc_client_id", "linphone")!!
@get:WorkerThread @get:AnyThread
val useUsernameAsSingleSignOnLoginHint: Boolean val useUsernameAsSingleSignOnLoginHint: Boolean
get() = config.getBool("ui", "use_username_as_sso_login_hint", false) get() = config.getBool("ui", "use_username_as_sso_login_hint", false)
@get:WorkerThread @get:AnyThread
val thirdPartySipAccountDefaultTransport: String val thirdPartySipAccountDefaultTransport: String
get() = config.getString("ui", "assistant_third_party_sip_account_transport", "tls")!! get() = config.getString("ui", "assistant_third_party_sip_account_transport", "tls")!!
@get:WorkerThread @get:AnyThread
val thirdPartySipAccountDefaultDomain: String val thirdPartySipAccountDefaultDomain: String
get() = config.getString("ui", "assistant_third_party_sip_account_domain", "")!! get() = config.getString("ui", "assistant_third_party_sip_account_domain", "")!!
@get:WorkerThread @get:AnyThread
val assistantDirectlyGoToThirdPartySipAccountLogin: Boolean val assistantDirectlyGoToThirdPartySipAccountLogin: Boolean
get() = config.getBool( get() = config.getBool(
"ui", "ui",
@ -307,24 +439,16 @@ class CorePreferences
false false
) )
@get:WorkerThread @get:AnyThread
val fetchContactsFromDefaultDirectory: Boolean val fetchContactsFromDefaultDirectory: Boolean
get() = config.getBool("app", "fetch_contacts_from_default_directory", true) get() = config.getBool("app", "fetch_contacts_from_default_directory", true)
@get:WorkerThread @get:AnyThread
val automaticallyShowDialpad: Boolean
get() = config.getBool("ui", "automatically_show_dialpad", false)
@get:WorkerThread
val showLettersOnDialpad: Boolean val showLettersOnDialpad: Boolean
get() = config.getBool("ui", "show_letters_on_dialpad", true) get() = config.getBool("ui", "show_letters_on_dialpad", true)
// Paths // Paths
@get:WorkerThread
val defaultDomain: String
get() = config.getString("app", "default_domain", "sip.linphone.org")!!
@get:AnyThread @get:AnyThread
val configPath: String val configPath: String
get() = context.filesDir.absolutePath + "/" + CONFIG_FILE_NAME get() = context.filesDir.absolutePath + "/" + CONFIG_FILE_NAME

View file

@ -175,7 +175,7 @@ class VFS {
val cipher = Cipher.getInstance(TRANSFORMATION) val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, getSecretKey()) cipher.init(Cipher.ENCRYPT_MODE, getSecretKey())
val iv = cipher.iv val iv = cipher.iv
return Pair<ByteArray, ByteArray>( return Pair(
iv, iv,
cipher.doFinal(textToEncrypt.toByteArray(StandardCharsets.UTF_8)) cipher.doFinal(textToEncrypt.toByteArray(StandardCharsets.UTF_8))
) )
@ -193,7 +193,7 @@ class VFS {
@Throws(java.lang.Exception::class) @Throws(java.lang.Exception::class)
private fun encryptToken(token: String): Pair<String?, String?> { private fun encryptToken(token: String): Pair<String?, String?> {
val encryptedData = encryptData(token) val encryptedData = encryptData(token)
return Pair<String?, String?>( return Pair(
Base64.encodeToString(encryptedData.first, Base64.DEFAULT), Base64.encodeToString(encryptedData.first, Base64.DEFAULT),
Base64.encodeToString(encryptedData.second, Base64.DEFAULT) Base64.encodeToString(encryptedData.second, Base64.DEFAULT)
) )

View file

@ -26,8 +26,10 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.core.Address import org.linphone.core.Address
import org.linphone.core.AudioDevice
import org.linphone.core.ConferenceParams import org.linphone.core.ConferenceParams
import org.linphone.core.tools.Log import org.linphone.core.tools.Log
import org.linphone.utils.AudioUtils
class NotificationBroadcastReceiver : BroadcastReceiver() { class NotificationBroadcastReceiver : BroadcastReceiver() {
companion object { companion object {
@ -36,47 +38,69 @@ class NotificationBroadcastReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
val notificationId = intent.getIntExtra(NotificationsManager.INTENT_NOTIF_ID, 0) val notificationId = intent.getIntExtra(NotificationsManager.INTENT_NOTIF_ID, 0)
Log.i( val action = intent.action
"$TAG Got notification broadcast for ID [$notificationId]" Log.i("$TAG Got notification broadcast for ID [$notificationId] with action [$action]")
)
// Wait for coreContext to be ready to handle intent // Wait for coreContext to be ready to handle intent
while (!coreContext.isReady()) { while (!coreContext.isReady()) {
Thread.sleep(50) Thread.sleep(50)
} }
if (intent.action == NotificationsManager.INTENT_ANSWER_CALL_NOTIF_ACTION || intent.action == NotificationsManager.INTENT_HANGUP_CALL_NOTIF_ACTION) { if (
handleCallIntent(intent, notificationId) action == NotificationsManager.INTENT_ANSWER_CALL_NOTIF_ACTION ||
} else if (intent.action == NotificationsManager.INTENT_REPLY_MESSAGE_NOTIF_ACTION || intent.action == NotificationsManager.INTENT_MARK_MESSAGE_AS_READ_NOTIF_ACTION) { action == NotificationsManager.INTENT_HANGUP_CALL_NOTIF_ACTION ||
handleChatIntent(context, intent, notificationId) 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) { private fun handleCallIntent(intent: Intent, notificationId: Int, action: String) {
val remoteSipAddress = intent.getStringExtra(NotificationsManager.INTENT_REMOTE_ADDRESS) val remoteSipUri = intent.getStringExtra(NotificationsManager.INTENT_REMOTE_SIP_URI)
if (remoteSipAddress == null) { if (remoteSipUri == null) {
Log.e("$TAG Remote SIP address is null for call notification ID [$notificationId]") Log.e("$TAG Remote SIP address is null for call notification ID [$notificationId]")
return return
} }
coreContext.postOnCoreThread { core -> coreContext.postOnCoreThread { core ->
val call = core.calls.find { val call = core.calls.find {
it.remoteAddress.asStringUriOnly() == remoteSipAddress it.remoteAddress.asStringUriOnly() == remoteSipUri
} }
if (call == null) { 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 { } else {
if (intent.action == NotificationsManager.INTENT_ANSWER_CALL_NOTIF_ACTION) { when (action) {
coreContext.answerCall(call) NotificationsManager.INTENT_ANSWER_CALL_NOTIF_ACTION -> {
} else { Log.i("$TAG Answering call with remote address [$remoteSipUri]")
coreContext.terminateCall(call) 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) { private fun handleChatIntent(context: Context, intent: Intent, notificationId: Int, action: String) {
val remoteSipAddress = intent.getStringExtra(NotificationsManager.INTENT_REMOTE_ADDRESS) val remoteSipAddress = intent.getStringExtra(NotificationsManager.INTENT_REMOTE_SIP_URI)
if (remoteSipAddress == null) { if (remoteSipAddress == null) {
Log.e("$TAG Remote SIP address is null for notification ID [$notificationId]") Log.e("$TAG Remote SIP address is null for notification ID [$notificationId]")
return return
@ -88,7 +112,7 @@ class NotificationBroadcastReceiver : BroadcastReceiver() {
} }
val reply = getMessageText(intent)?.toString() 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) { if (reply == null) {
Log.e("$TAG Couldn't get reply text") Log.e("$TAG Couldn't get reply text")
return return
@ -128,13 +152,13 @@ class NotificationBroadcastReceiver : BroadcastReceiver() {
return@postOnCoreThread return@postOnCoreThread
} }
if (intent.action == NotificationsManager.INTENT_REPLY_MESSAGE_NOTIF_ACTION) { if (action == NotificationsManager.INTENT_REPLY_MESSAGE_NOTIF_ACTION) {
val msg = room.createMessageFromUtf8(reply) val msg = room.createMessageFromUtf8(reply)
msg.userData = notificationId msg.userData = notificationId
msg.addListener(coreContext.notificationsManager.chatMessageListener) msg.addListener(coreContext.notificationsManager.chatMessageListener)
msg.send() msg.send()
Log.i("$TAG Reply sent for notif id [$notificationId]") 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") Log.i("$TAG Marking chat room from notification id [$notificationId] as read")
room.markAsRead() room.markAsRead()
if (!coreContext.notificationsManager.dismissChatNotification(room)) { 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.CallControlScope
import androidx.core.telecom.CallEndpointCompat import androidx.core.telecom.CallEndpointCompat
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.LinphoneApplication.Companion.corePreferences import org.linphone.LinphoneApplication.Companion.corePreferences
import org.linphone.core.AudioDevice
import org.linphone.core.Call import org.linphone.core.Call
import org.linphone.core.CallListenerStub import org.linphone.core.CallListenerStub
import org.linphone.core.Reason import org.linphone.core.Reason
@ -50,9 +48,7 @@ class TelecomCallControlCallback(
private const val TAG = "[Telecom Call Control Callback]" private const val TAG = "[Telecom Call Control Callback]"
} }
private var availableEndpoints: List<CallEndpointCompat> = arrayListOf() private var mutedByTelecomManager = false
private var currentEndpoint = CallEndpointCompat.TYPE_UNKNOWN
private var endpointUpdateRequestFromLinphone: Boolean = false
private val callListener = object : CallListenerStub() { private val callListener = object : CallListenerStub() {
@WorkerThread @WorkerThread
@ -60,72 +56,35 @@ class TelecomCallControlCallback(
Log.i("$TAG Call [${call.remoteAddress.asStringUriOnly()}] state changed [$state]") Log.i("$TAG Call [${call.remoteAddress.asStringUriOnly()}] state changed [$state]")
if (state == Call.State.Connected) { if (state == Call.State.Connected) {
if (call.dir == Call.Dir.Incoming) { if (call.dir == Call.Dir.Incoming) {
val isVideo = LinphoneUtils.isVideoEnabled(call) answerCall()
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)
}
} else { } else {
scope.launch { scope.launch {
Log.i("$TAG Setting call active") 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) { } else if (state == Call.State.End) {
val reason = call.reason callEnded()
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")
}
}
} else if (state == Call.State.Error) { } else if (state == Call.State.Error) {
val reason = call.reason callError(message)
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")
}
}
} else if (state == Call.State.Pausing) { } else if (state == Call.State.Pausing) {
scope.launch { scope.launch {
Log.i("$TAG Pausing call") 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) { } else if (state == Call.State.Resuming) {
scope.launch { scope.launch {
Log.i("$TAG Resuming call") 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()}]" "$TAG Callback have been set for call, Telecom call ID is [${callControl.getCallId()}]"
) )
callControl.availableEndpoints.onEach { list -> coreContext.postOnCoreThread {
Log.i("$TAG New available audio endpoints list") val state = call.state
if (availableEndpoints != list) { Log.i("$TAG Call state currently is [$state]")
Log.i( when (state) {
"$TAG List size of available audio endpoints has changed, reload sound devices in SDK" Call.State.Connected, Call.State.StreamsRunning -> answerCall()
) Call.State.End -> callEnded()
coreContext.postOnCoreThread { core -> Call.State.Error -> callError("")
core.reloadSoundDevices() Call.State.Released -> callEnded()
Log.i("$TAG Sound devices reloaded") else -> {} // doing nothing
}
} }
}
availableEndpoints = list callControl.availableEndpoints.onEach { list ->
for (endpoint in list) { Log.i("$TAG New available audio endpoints list but ignoring it")
Log.i("$TAG Available audio endpoint [${endpoint.name}]")
}
}.launchIn(scope) }.launchIn(scope)
callControl.currentCallEndpoint.onEach { endpoint -> callControl.currentCallEndpoint.onEach { endpoint ->
val type = endpoint.type Log.i("$TAG Android requests us to use [${endpoint.name}] audio endpoint with type [${endpointTypeToString(endpoint.type)}], ignoring it")
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
}.launchIn(scope) }.launchIn(scope)
callControl.isMuted.onEach { muted -> 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]" "$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) // 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. // 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)) { // 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 call.microphoneMuted = muted
coreContext.refreshMicrophoneMuteStateEvent.postValue(Event(true)) coreContext.refreshMicrophoneMuteStateEvent.postValue(Event(true))
} else { } else {
@ -233,74 +151,71 @@ class TelecomCallControlCallback(
}.launchIn(scope) }.launchIn(scope)
} }
fun applyAudioRouteToCallWithId(routes: List<AudioDevice.Type>): Boolean { private fun answerCall() {
endpointUpdateRequestFromLinphone = true val isVideo = LinphoneUtils.isVideoEnabled(call)
Log.i("$TAG Looking for audio endpoint with type [${routes.first()}]") val type = if (isVideo) {
CallAttributesCompat.CALL_TYPE_VIDEO_CALL
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!")
} else { } 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 { private fun disconnectCauseToString(cause: Int): String {
@ -321,4 +236,16 @@ class TelecomCallControlCallback(
else -> "UNEXPECTED: $cause" 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.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.core.AudioDevice
import org.linphone.core.Call import org.linphone.core.Call
import org.linphone.core.Core import org.linphone.core.Core
import org.linphone.core.CoreListenerStub import org.linphone.core.CoreListenerStub
@ -69,31 +68,22 @@ class TelecomManager
} }
} }
private val hasTelecomFeature = context.packageManager.hasSystemFeature("android.software.telecom")
private var currentlyFollowedCalls: Int = 0 private var currentlyFollowedCalls: Int = 0
init { init {
val hasTelecomFeature =
context.packageManager.hasSystemFeature("android.software.telecom")
Log.i( Log.i(
"$TAG android.software.telecom feature is [${if (hasTelecomFeature) "available" else "not available"}]" "$TAG android.software.telecom feature is [${if (hasTelecomFeature) "available" else "not available"}]"
) )
try { try {
callsManager.registerAppWithTelecom( callsManager.registerAppWithTelecom(CallsManager.CAPABILITY_SUPPORTS_VIDEO_CALLING)
CallsManager.CAPABILITY_BASELINE or
CallsManager.Companion.CAPABILITY_SUPPORTS_VIDEO_CALLING
)
Log.i("$TAG App has been registered with Telecom") Log.i("$TAG App has been registered with Telecom")
} catch (e: Exception) { } catch (e: Exception) {
Log.e("$TAG Can't init TelecomManager: $e") Log.e("$TAG Can't init TelecomManager: $e")
} }
} }
@WorkerThread
fun getCurrentlyFollowedCalls(): Int {
return currentlyFollowedCalls
}
@WorkerThread @WorkerThread
fun onCallCreated(call: Call) { fun onCallCreated(call: Call) {
Log.i("$TAG Call to [${call.remoteAddress.asStringUriOnly()}] created in state [${call.state}]") Log.i("$TAG Call to [${call.remoteAddress.asStringUriOnly()}] created in state [${call.state}]")
@ -117,11 +107,12 @@ class TelecomManager
friend?.name ?: LinphoneUtils.getDisplayName(address) friend?.name ?: LinphoneUtils.getDisplayName(address)
} }
val isVideo = LinphoneUtils.isVideoEnabled(call) // Always set type to video (if enabled in Core) as it indicates that video is supported, not that it's being used at the time
val type = if (isVideo) { // https://developer.android.com/reference/kotlin/androidx/core/telecom/CallAttributesCompat#CALL_TYPE_VIDEO_CALL()
CallAttributesCompat.Companion.CALL_TYPE_VIDEO_CALL val type = if (!call.core.isVideoEnabled) {
CallAttributesCompat.CALL_TYPE_AUDIO_CALL
} else { } else {
CallAttributesCompat.Companion.CALL_TYPE_AUDIO_CALL CallAttributesCompat.CALL_TYPE_VIDEO_CALL
} }
scope.launch { scope.launch {
@ -185,8 +176,14 @@ class TelecomManager
} }
} }
} }
} catch (e: CallException) { } catch (ce: CallException) {
Log.e("$TAG Failed to add call to Telecom's CallsManager: $e") 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 +191,21 @@ class TelecomManager
@WorkerThread @WorkerThread
fun onCoreStarted(core: Core) { fun onCoreStarted(core: Core) {
Log.i("$TAG Core has been started") 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 @WorkerThread
fun onCoreStopped(core: Core) { fun onCoreStopped(core: Core) {
Log.i("$TAG Core is being stopped") Log.i("$TAG Core is being stopped")
core.removeListener(coreListener) if (hasTelecomFeature) {
} 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
} }
return callControlCallback.applyAudioRouteToCallWithId(routes)
} }
} }

View file

@ -20,6 +20,7 @@
package org.linphone.ui package org.linphone.ui
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.ActivityNotFoundException
import android.content.Intent import android.content.Intent
import android.content.res.Configuration import android.content.res.Configuration
import android.content.res.Resources import android.content.res.Resources
@ -224,15 +225,19 @@ open class GenericActivity : AppCompatActivity() {
fun goToAndroidPermissionSettings() { fun goToAndroidPermissionSettings() {
Log.i("$TAG Going into Android settings for our app") Log.i("$TAG Going into Android settings for our app")
val intent = Intent( try {
Settings.ACTION_APPLICATION_DETAILS_SETTINGS, val intent = Intent(
Uri.fromParts( Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
"package", Uri.fromParts(
packageName, null "package",
packageName, null
)
) )
) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) startActivity(intent)
startActivity(intent) } catch (anfe: ActivityNotFoundException) {
Log.e("$TAG Failed to go to android settings: $anfe")
}
} }
protected fun enableWindowSecureMode(enable: Boolean) { protected fun enableWindowSecureMode(enable: Boolean) {

View file

@ -19,6 +19,7 @@
*/ */
package org.linphone.ui.assistant.fragment package org.linphone.ui.assistant.fragment
import android.content.ActivityNotFoundException
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
@ -77,6 +78,14 @@ class LandingFragment : GenericFragment() {
requireActivity().finish() requireActivity().finish()
} }
binding.setHelpClickListener {
if (findNavController().currentDestination?.id == R.id.landingFragment) {
val action =
LandingFragmentDirections.actionLandingFragmentToHelpFragment()
findNavController().navigate(action)
}
}
binding.setRegisterClickListener { binding.setRegisterClickListener {
if (viewModel.conditionsAndPrivacyPolicyAccepted) { if (viewModel.conditionsAndPrivacyPolicyAccepted) {
goToRegisterFragment() goToRegisterFragment()
@ -102,14 +111,10 @@ class LandingFragment : GenericFragment() {
} }
binding.setForgottenPasswordClickListener { binding.setForgottenPasswordClickListener {
val url = getString(R.string.web_platform_forgotten_password_url) if (findNavController().currentDestination?.id == R.id.landingFragment) {
try { val action =
val browserIntent = Intent(Intent.ACTION_VIEW, url.toUri()) LandingFragmentDirections.actionLandingFragmentToRecoverAccountFragment()
startActivity(browserIntent) findNavController().navigate(action)
} catch (ise: IllegalStateException) {
Log.e(
"$TAG Can't start ACTION_VIEW intent for URL [$url], IllegalStateException: $ise"
)
} }
} }
@ -206,31 +211,36 @@ class LandingFragment : GenericFragment() {
model.privacyPolicyClickedEvent.observe(viewLifecycleOwner) { model.privacyPolicyClickedEvent.observe(viewLifecycleOwner) {
it.consume { it.consume {
val url = getString(R.string.website_privacy_policy_url) val url = getString(R.string.website_privacy_policy_url)
try { openUrlInBrowser(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"
)
}
} }
} }
model.generalTermsClickedEvent.observe(viewLifecycleOwner) { model.generalTermsClickedEvent.observe(viewLifecycleOwner) {
it.consume { it.consume {
val url = getString(R.string.website_terms_and_conditions_url) val url = getString(R.string.website_terms_and_conditions_url)
try { openUrlInBrowser(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"
)
}
} }
} }
dialog.show() 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.annotation.UiThread
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.navGraphViewModels
import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.R import org.linphone.R
import org.linphone.compatibility.Compatibility import org.linphone.compatibility.Compatibility
@ -36,6 +37,8 @@ import org.linphone.core.tools.Log
import org.linphone.databinding.AssistantPermissionsFragmentBinding import org.linphone.databinding.AssistantPermissionsFragmentBinding
import org.linphone.ui.GenericFragment import org.linphone.ui.GenericFragment
import org.linphone.ui.assistant.AssistantActivity import org.linphone.ui.assistant.AssistantActivity
import org.linphone.ui.assistant.viewmodel.PermissionsViewModel
import kotlin.getValue
@UiThread @UiThread
class PermissionsFragment : GenericFragment() { class PermissionsFragment : GenericFragment() {
@ -45,6 +48,10 @@ class PermissionsFragment : GenericFragment() {
private lateinit var binding: AssistantPermissionsFragmentBinding private lateinit var binding: AssistantPermissionsFragmentBinding
private val viewModel: PermissionsViewModel by navGraphViewModels(
R.id.assistant_nav_graph
)
private var leaving = false private var leaving = false
private val requestPermissionLauncher = registerForActivityResult( private val requestPermissionLauncher = registerForActivityResult(
@ -93,6 +100,7 @@ class PermissionsFragment : GenericFragment() {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
binding.lifecycleOwner = viewLifecycleOwner binding.lifecycleOwner = viewLifecycleOwner
binding.viewModel = viewModel
binding.setBackClickListener { binding.setBackClickListener {
findNavController().popBackStack() findNavController().popBackStack()
@ -180,10 +188,13 @@ class PermissionsFragment : GenericFragment() {
private fun areAllPermissionsGranted(): Boolean { private fun areAllPermissionsGranted(): Boolean {
for (permission in Compatibility.getAllRequiredPermissionsArray()) { 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!") Log.w("$TAG Permission [$permission] hasn't been granted yet!")
return false return false
} }
} }
return Compatibility.hasFullScreenIntentPermission(requireContext()) return Compatibility.hasFullScreenIntentPermission(requireContext())
} }

View file

@ -84,10 +84,12 @@ class QrCodeScannerFragment : GenericFragment() {
goBack() goBack()
} }
viewModel.qrCodeFoundEvent.observe(viewLifecycleOwner) { viewModel.remoteProvisioningSuccessfulEvent.observe(viewLifecycleOwner) {
it.consume { isValid -> it.consume { atLeastOneAccountFound ->
if (isValid) { if (atLeastOneAccountFound) {
requireActivity().finish() requireActivity().finish()
} else {
goBack()
} }
} }
} }
@ -145,6 +147,8 @@ class QrCodeScannerFragment : GenericFragment() {
core.nativePreviewWindowId = null core.nativePreviewWindowId = null
core.isVideoPreviewEnabled = false core.isVideoPreviewEnabled = false
core.isQrcodeVideoPreviewEnabled = false core.isQrcodeVideoPreviewEnabled = false
coreContext.setFrontCamera()
} }
super.onPause() super.onPause()

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

@ -80,7 +80,7 @@ class RegisterCodeConfirmationFragment : GenericFragment() {
clipboard.addPrimaryClipChangedListener { clipboard.addPrimaryClipChangedListener {
val data = clipboard.primaryClip val data = clipboard.primaryClip
if (data != null && data.itemCount > 0) { if (data != null && data.itemCount > 0) {
val clip = data.getItemAt(0).text.toString() val clip = data.getItemAt(0).text?.toString() ?: ""
if (clip.length == 4) { if (clip.length == 4) {
Log.i( Log.i(
"$TAG Found 4 digits [$clip] as primary clip in clipboard, using it and clear it" "$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 package org.linphone.ui.assistant.fragment
import android.content.ActivityNotFoundException
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
@ -41,7 +42,6 @@ import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.R import org.linphone.R
import org.linphone.core.tools.Log import org.linphone.core.tools.Log
import org.linphone.databinding.AssistantRegisterFragmentBinding import org.linphone.databinding.AssistantRegisterFragmentBinding
import org.linphone.ui.GenericActivity
import org.linphone.ui.GenericFragment import org.linphone.ui.GenericFragment
import org.linphone.ui.assistant.viewmodel.AccountCreationViewModel import org.linphone.ui.assistant.viewmodel.AccountCreationViewModel
import org.linphone.utils.ConfirmationDialogModel import org.linphone.utils.ConfirmationDialogModel
@ -108,6 +108,14 @@ class RegisterFragment : GenericFragment() {
Log.e( Log.e(
"$TAG Can't start ACTION_VIEW intent for URL [$url], IllegalStateException: $ise" "$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) {} 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) { viewModel.normalizedPhoneNumberEvent.observe(viewLifecycleOwner) {
it.consume { number -> it.consume { number ->
showPhoneNumberConfirmationDialog(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 telephonyManager = requireContext().getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager
val countryIso = telephonyManager.networkCountryIso val countryIso = telephonyManager.networkCountryIso
coreContext.postOnCoreThread { coreContext.postOnCoreThread {
val fragmentContext = context ?: return@postOnCoreThread
val adapter = object : ArrayAdapter<String>( val adapter = object : ArrayAdapter<String>(
requireContext(), fragmentContext,
R.layout.drop_down_item, R.layout.drop_down_item,
viewModel.dialPlansLabelList viewModel.dialPlansLabelList
) { ) {

View file

@ -41,6 +41,7 @@ import org.linphone.ui.GenericActivity
import org.linphone.ui.GenericFragment import org.linphone.ui.GenericFragment
import org.linphone.ui.assistant.viewmodel.ThirdPartySipAccountLoginViewModel import org.linphone.ui.assistant.viewmodel.ThirdPartySipAccountLoginViewModel
import org.linphone.ui.main.sso.fragment.SingleSignOnFragmentDirections import org.linphone.ui.main.sso.fragment.SingleSignOnFragmentDirections
import org.linphone.utils.DialogUtils
import org.linphone.utils.PhoneNumberUtils import org.linphone.utils.PhoneNumberUtils
@UiThread @UiThread
@ -98,6 +99,10 @@ class ThirdPartySipAccountLoginFragment : GenericFragment() {
goBack() goBack()
} }
binding.setOutboundProxyTooltipClickListener {
showOutboundProxyInfoDialog()
}
viewModel.showPassword.observe(viewLifecycleOwner) { viewModel.showPassword.observe(viewLifecycleOwner) {
lifecycleScope.launch { lifecycleScope.launch {
delay(50) delay(50)
@ -159,4 +164,9 @@ class ThirdPartySipAccountLoginFragment : GenericFragment() {
private fun goBack() { private fun goBack() {
findNavController().popBackStack() findNavController().popBackStack()
} }
private fun showOutboundProxyInfoDialog() {
val dialog = DialogUtils.getAccountOutboundProxyHelpDialog(requireActivity())
dialog.show()
}
} }

View file

@ -19,6 +19,7 @@
*/ */
package org.linphone.ui.assistant.fragment package org.linphone.ui.assistant.fragment
import android.content.ActivityNotFoundException
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
@ -67,6 +68,14 @@ class ThirdPartySipAccountWarningFragment : GenericFragment() {
Log.e( Log.e(
"$TAG Can't start ACTION_VIEW intent for URL [$url], IllegalStateException: $ise" "$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.Factory
import org.linphone.core.tools.Log import org.linphone.core.tools.Log
import org.linphone.ui.GenericViewModel import org.linphone.ui.GenericViewModel
import org.linphone.utils.AppUtils
import org.linphone.utils.Event import org.linphone.utils.Event
import org.linphone.utils.LinphoneUtils import org.linphone.utils.LinphoneUtils
@ -105,7 +104,7 @@ class AccountCreationViewModel
val accountCreatedEvent = MutableLiveData<Event<Boolean>>() val accountCreatedEvent = MutableLiveData<Event<Boolean>>()
val errorHappenedEvent: MutableLiveData<Event<String>> by lazy { val accountRecoveryTokenReceivedEvent: MutableLiveData<Event<String>> by lazy {
MutableLiveData<Event<String>>() MutableLiveData<Event<String>>()
} }
@ -113,7 +112,9 @@ class AccountCreationViewModel
private var waitForPushJob: Job? = null private var waitForPushJob: Job? = null
private lateinit var accountManagerServices: AccountManagerServices private lateinit var accountManagerServices: AccountManagerServices
private var requestedTokenIsForAccountCreation: Boolean = true
private var accountCreationToken: String? = null private var accountCreationToken: String? = null
private var accountRecoveryToken: String? = null
private var accountCreatedAuthInfo: AuthInfo? = null private var accountCreatedAuthInfo: AuthInfo? = null
private var accountCreated: Account? = null private var accountCreated: Account? = null
@ -124,7 +125,7 @@ class AccountCreationViewModel
request: AccountManagerServicesRequest, request: AccountManagerServicesRequest,
data: String? 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) operationInProgress.postValue(false)
when (request.type) { 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 -> { AccountManagerServicesRequest.Type.SendPhoneNumberLinkingCodeBySms -> {
goToSmsCodeConfirmationViewEvent.postValue(Event(true)) goToSmsCodeConfirmationViewEvent.postValue(Event(true))
} }
AccountManagerServicesRequest.Type.LinkPhoneNumberUsingCode -> { AccountManagerServicesRequest.Type.LinkPhoneNumberUsingCode -> {
val account = accountCreated enableAccountAndSetItAsDefault()
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))
} }
else -> { } else -> { }
} }
@ -163,7 +161,7 @@ class AccountCreationViewModel
parameterErrors: Dictionary? parameterErrors: Dictionary?
) { ) {
Log.e( 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) operationInProgress.postValue(false)
@ -181,7 +179,8 @@ class AccountCreationViewModel
} }
when (request.type) { when (request.type) {
AccountManagerServicesRequest.Type.SendAccountCreationTokenByPush -> { AccountManagerServicesRequest.Type.SendAccountCreationTokenByPush,
AccountManagerServicesRequest.Type.SendAccountRecoveryTokenByPush -> {
Log.w("$TAG Cancelling job waiting for push notification") Log.w("$TAG Cancelling job waiting for push notification")
waitingForFlexiApiPushToken = false waitingForFlexiApiPushToken = false
waitForPushJob?.cancel() waitForPushJob?.cancel()
@ -227,11 +226,19 @@ class AccountCreationViewModel
val token = customPayload.getString("token") val token = customPayload.getString("token")
if (token.isNotEmpty()) { if (token.isNotEmpty()) {
accountCreationToken = token if (requestedTokenIsForAccountCreation) {
Log.i( accountCreationToken = token
"$TAG Extracted token [$accountCreationToken] from push payload, creating account" Log.i(
) "$TAG Extracted token [$accountCreationToken] from push payload, creating account"
createAccount() )
createAccount()
} else {
accountRecoveryToken = token
Log.i(
"$TAG Extracted token [$accountRecoveryToken] from push payload, opening browser"
)
accountRecoveryTokenReceivedEvent.postValue(Event(token))
}
} else { } else {
Log.e("$TAG Push payload JSON object has an empty 'token'!") Log.e("$TAG Push payload JSON object has an empty 'token'!")
onFlexiApiTokenRequestError() onFlexiApiTokenRequestError()
@ -301,7 +308,7 @@ class AccountCreationViewModel
} }
@UiThread @UiThread
fun phoneNumberConfirmedByUser() { fun askUserToConfirmPhoneNumber() {
coreContext.postOnCoreThread { coreContext.postOnCoreThread {
if (::accountManagerServices.isInitialized) { if (::accountManagerServices.isInitialized) {
val dialPlan = selectedDialPlan.value val dialPlan = selectedDialPlan.value
@ -324,9 +331,7 @@ class AccountCreationViewModel
normalizedPhoneNumberEvent.postValue(Event(formattedPhoneNumber)) normalizedPhoneNumberEvent.postValue(Event(formattedPhoneNumber))
} else { } else {
Log.e("$TAG Account manager services hasn't been initialized!") Log.e("$TAG Account manager services hasn't been initialized!")
errorHappenedEvent.postValue( showRedToast(R.string.assistant_account_register_unexpected_error, R.drawable.warning_circle)
Event(AppUtils.getString(R.string.assistant_account_register_unexpected_error))
)
} }
} }
} }
@ -337,8 +342,8 @@ class AccountCreationViewModel
coreContext.postOnCoreThread { coreContext.postOnCoreThread {
if (accountCreationToken.isNullOrEmpty()) { if (accountCreationToken.isNullOrEmpty()) {
Log.i("$TAG We don't have a creation token, let's request one") Log.i("$TAG We don't have an account creation token yet, let's request one")
requestFlexiApiToken() requestFlexiApiToken(requestAccountCreationToken = true)
} else { } else {
val authInfo = accountCreatedAuthInfo val authInfo = accountCreatedAuthInfo
if (authInfo != null) { 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 @UiThread
fun toggleShowPassword() { fun toggleShowPassword() {
showPassword.value = showPassword.value == false showPassword.value = showPassword.value == false
@ -372,7 +391,7 @@ class AccountCreationViewModel
val account = accountCreated val account = accountCreated
if (::accountManagerServices.isInitialized && account != null) { if (::accountManagerServices.isInitialized && account != null) {
val code = 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 val identity = account.params.identityAddress
if (identity != null) { if (identity != null) {
Log.i( Log.i(
@ -496,6 +515,9 @@ class AccountCreationViewModel
) )
accountParams.internationalPrefix = dialPlan.internationalCallPrefix accountParams.internationalPrefix = dialPlan.internationalCallPrefix
accountParams.internationalPrefixIsoCountryCode = dialPlan.isoCountryCode 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) val account = core.createAccount(accountParams)
core.addAccount(account) core.addAccount(account)
@ -508,7 +530,23 @@ class AccountCreationViewModel
} }
@WorkerThread @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) { if (!coreContext.core.isPushNotificationAvailable) {
Log.e( Log.e(
"$TAG Core says push notification aren't available, can't request a token from FlexiAPI" "$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 // Request an auth token, will be sent by push
val request = accountManagerServices.createSendAccountCreationTokenByPushRequest( val request = if (requestAccountCreationToken) {
provider, Log.i("$TAG Requesting account creation token")
param, accountManagerServices.createSendAccountCreationTokenByPushRequest(
prid provider,
) param,
prid
)
} else {
Log.i("$TAG Requesting account recovery token")
accountManagerServices.createSendAccountRecoveryTokenByPushRequest(
provider,
param,
prid
)
}
request.addListener(accountManagerServicesListener) request.addListener(accountManagerServicesListener)
request.submit() request.submit()
@ -569,12 +617,6 @@ class AccountCreationViewModel
private fun onFlexiApiTokenRequestError() { private fun onFlexiApiTokenRequestError() {
Log.e("$TAG Flexi API token request by push error!") Log.e("$TAG Flexi API token request by push error!")
operationInProgress.postValue(false) operationInProgress.postValue(false)
errorHappenedEvent.postValue( showRedToast(R.string.assistant_account_register_push_notification_not_received_error, R.drawable.warning_circle)
Event(
AppUtils.getString(
R.string.assistant_account_register_push_notification_not_received_error
)
)
)
} }
} }

View file

@ -181,6 +181,16 @@ open class AccountLoginViewModel
return@postOnCoreThread 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 val user = identityAddress.username
if (user == null) { if (user == null) {
Log.e( 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 package org.linphone.ui.assistant.viewmodel
import android.util.Patterns
import androidx.annotation.UiThread import androidx.annotation.UiThread
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
@ -31,6 +30,8 @@ import org.linphone.core.tools.Log
import org.linphone.ui.GenericViewModel import org.linphone.ui.GenericViewModel
import org.linphone.utils.Event import org.linphone.utils.Event
import org.linphone.R import org.linphone.R
import org.linphone.core.GlobalState
import org.linphone.utils.LinphoneUtils
class QrCodeViewModel class QrCodeViewModel
@UiThread @UiThread
@ -39,7 +40,7 @@ class QrCodeViewModel
private const val TAG = "[Qr Code Scanner ViewModel]" private const val TAG = "[Qr Code Scanner ViewModel]"
} }
val qrCodeFoundEvent = MutableLiveData<Event<Boolean>>() val remoteProvisioningSuccessfulEvent = MutableLiveData<Event<Boolean>>()
val onErrorEvent = MutableLiveData<Event<Boolean>>() val onErrorEvent = MutableLiveData<Event<Boolean>>()
@ -47,37 +48,54 @@ class QrCodeViewModel
@WorkerThread @WorkerThread
override fun onConfiguringStatus(core: Core, status: ConfiguringState, message: String?) { override fun onConfiguringStatus(core: Core, status: ConfiguringState, message: String?) {
Log.i("$TAG Configuring state is [$status]") Log.i("$TAG Configuring state is [$status]")
if (status == ConfiguringState.Successful) { if (status == ConfiguringState.Failed) {
qrCodeFoundEvent.postValue(Event(true))
} else if (status == ConfiguringState.Failed) {
Log.e("$TAG Failure applying remote provisioning: $message") Log.e("$TAG Failure applying remote provisioning: $message")
showRedToast(R.string.remote_provisioning_config_failed_toast, R.drawable.warning_circle) showRedToast(R.string.remote_provisioning_config_failed_toast, R.drawable.warning_circle)
onErrorEvent.postValue(Event(true)) 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 @WorkerThread
override fun onQrcodeFound(core: Core, result: String?) { override fun onQrcodeFound(core: Core, result: String?) {
Log.i("$TAG QR Code found: [$result]") Log.i("$TAG QR Code found: [$result]")
if (result == null) { if (result == null) {
showRedToast(R.string.assistant_qr_code_invalid_toast, R.drawable.warning_circle) showRedToast(R.string.assistant_qr_code_invalid_toast, R.drawable.warning_circle)
} else { } else {
val isValidUrl = Patterns.WEB_URL.matcher(result).matches() val url = LinphoneUtils.getRemoteProvisioningUrlFromUri(result)
if (!isValidUrl) { if (url == null) {
Log.e("$TAG The content of the QR Code doesn't seem to be a valid web URL") 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) showRedToast(R.string.assistant_qr_code_invalid_toast, R.drawable.warning_circle)
} else { return
Log.i( }
"$TAG QR code URL set, restarting the Core to apply configuration changes"
)
core.nativePreviewWindowId = null
core.isVideoPreviewEnabled = false
core.isQrcodeVideoPreviewEnabled = false
core.provisioningUri = result Log.i(
coreContext.core.stop() "$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") Log.i("$TAG Core has been stopped, restarting it")
coreContext.core.start() core.start()
Log.i("$TAG Core has been restarted") Log.i("$TAG Core has been restarted")
} }
} }
@ -101,18 +119,20 @@ class QrCodeViewModel
@UiThread @UiThread
fun setBackCamera() { fun setBackCamera() {
coreContext.postOnCoreThread { core -> coreContext.postOnCoreThread { core ->
for (camera in core.videoDevicesList) { // Just in case, on some devices such as Xiaomi Redmi Note 5
if (camera.contains("Back")) { // this is required right after granting the CAMERA permission
Log.i("$TAG Found back facing camera [$camera], using it") core.reloadVideoDevices()
coreContext.core.videoDevice = camera
return@postOnCoreThread
}
}
val first = core.videoDevicesList.firstOrNull() if (!coreContext.setBackCamera()) {
if (first != null) { for (camera in core.videoDevicesList) {
Log.w("$TAG No back facing camera found, using first one available [$first]") if (camera != "StaticImage: Static picture") {
coreContext.core.videoDevice = first 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 expandAdvancedSettings = MutableLiveData<Boolean>()
val proxy = MutableLiveData<String>()
val outboundProxy = MutableLiveData<String>() val outboundProxy = MutableLiveData<String>()
val loginEnabled = MediatorLiveData<Boolean>() val loginEnabled = MediatorLiveData<Boolean>()
@ -173,11 +175,17 @@ class ThirdPartySipAccountLoginViewModel
// Remove sip: in front of domain, just in case... // Remove sip: in front of domain, just in case...
val domainValue = domain.value.orEmpty().trim() val domainValue = domain.value.orEmpty().trim()
val domain = if (domainValue.startsWith("sip:")) { val domainWithoutSip = if (domainValue.startsWith("sip:")) {
domainValue.substring("sip:".length) domainValue.substring("sip:".length)
} else { } else {
domainValue 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 // Allow to enter SIP identity instead of simply username
// in case identity domain doesn't match proxy domain // in case identity domain doesn't match proxy domain
@ -194,7 +202,6 @@ class ThirdPartySipAccountLoginViewModel
val userId = authId.value.orEmpty().trim() val userId = authId.value.orEmpty().trim()
Log.i("$TAG Parsed username is [$user], user ID [$userId] and domain [$domain]") Log.i("$TAG Parsed username is [$user], user ID [$userId] and domain [$domain]")
val identity = "sip:$user@$domain" val identity = "sip:$user@$domain"
val identityAddress = Factory.instance().createAddress(identity) val identityAddress = Factory.instance().createAddress(identity)
if (identityAddress == null) { if (identityAddress == null) {
@ -202,6 +209,17 @@ class ThirdPartySipAccountLoginViewModel
showRedToast(R.string.assistant_login_cant_parse_address_toast, R.drawable.warning_circle) showRedToast(R.string.assistant_login_cant_parse_address_toast, R.drawable.warning_circle)
return@postOnCoreThread 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( newlyCreatedAuthInfo = Factory.instance().createAuthInfo(
user, user,
@ -209,7 +227,7 @@ class ThirdPartySipAccountLoginViewModel
password.value.orEmpty().trim(), password.value.orEmpty().trim(),
null, null,
null, null,
domainValue domainAddress?.domain ?: domainValue
) )
core.addAuthInfo(newlyCreatedAuthInfo) core.addAuthInfo(newlyCreatedAuthInfo)
@ -220,8 +238,27 @@ class ThirdPartySipAccountLoginViewModel
} }
accountParams.identityAddress = identityAddress 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 outboundProxyValue = outboundProxy.value.orEmpty().trim()
val serverAddress = if (outboundProxyValue.isNotEmpty()) { val outboundProxyAddress = if (outboundProxyValue.isNotEmpty()) {
val server = if (outboundProxyValue.startsWith("sip:")) { val server = if (outboundProxyValue.startsWith("sip:")) {
outboundProxyValue outboundProxyValue
} else { } else {
@ -229,15 +266,17 @@ class ThirdPartySipAccountLoginViewModel
} }
Factory.instance().createAddress(server) Factory.instance().createAddress(server)
} else { } else {
Factory.instance().createAddress("sip:$domain") null
} }
if (outboundProxyAddress != null) {
serverAddress?.transport = when (transport.value.orEmpty().trim()) { outboundProxyAddress.transport = when (transport.value.orEmpty().trim()) {
TransportType.Tcp.name.uppercase(Locale.getDefault()) -> TransportType.Tcp TransportType.Tcp.name.uppercase(Locale.getDefault()) -> TransportType.Tcp
TransportType.Tls.name.uppercase(Locale.getDefault()) -> TransportType.Tls TransportType.Tls.name.uppercase(Locale.getDefault()) -> TransportType.Tls
else -> TransportType.Udp 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 prefix = internationalPrefix.value.orEmpty().trim()
val isoCountryCode = internationalPrefixIsoCountryCode.value.orEmpty() val isoCountryCode = internationalPrefixIsoCountryCode.value.orEmpty()

View file

@ -25,7 +25,10 @@ import android.content.pm.PackageManager
import android.content.res.Resources import android.content.res.Resources
import android.graphics.Color import android.graphics.Color
import android.os.Bundle 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.SystemBarStyle
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts 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.CurrentCallViewModel
import org.linphone.ui.call.viewmodel.SharedCallViewModel import org.linphone.ui.call.viewmodel.SharedCallViewModel
import org.linphone.ui.main.MainActivity import org.linphone.ui.main.MainActivity
import org.linphone.utils.AppUtils
@UiThread @UiThread
class CallActivity : GenericActivity() { class CallActivity : GenericActivity() {
@ -80,8 +84,6 @@ class CallActivity : GenericActivity() {
private lateinit var callsViewModel: CallsViewModel private lateinit var callsViewModel: CallsViewModel
private lateinit var callViewModel: CurrentCallViewModel private lateinit var callViewModel: CurrentCallViewModel
private lateinit var proximityWakeLock: PowerManager.WakeLock
private var bottomSheetDialog: BottomSheetDialogFragment? = null private var bottomSheetDialog: BottomSheetDialogFragment? = null
private var isPipSupported = false private var isPipSupported = false
@ -150,16 +152,6 @@ class CallActivity : GenericActivity() {
WindowInsetsCompat.CONSUMED 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) { lifecycleScope.launch(Dispatchers.Main) {
WindowInfoTracker WindowInfoTracker
.getOrCreate(this@CallActivity) .getOrCreate(this@CallActivity)
@ -269,7 +261,7 @@ class CallActivity : GenericActivity() {
callViewModel.proximitySensorEnabled.observe(this) { enabled -> callViewModel.proximitySensorEnabled.observe(this) { enabled ->
Log.i("$TAG ${if (enabled) "Enabling" else "Disabling"} proximity sensor") Log.i("$TAG ${if (enabled) "Enabling" else "Disabling"} proximity sensor")
enableProximitySensor(enabled) coreContext.enableProximitySensor(enabled)
} }
callsViewModel.showIncomingCallEvent.observe(this) { callsViewModel.showIncomingCallEvent.observe(this) {
@ -374,7 +366,7 @@ class CallActivity : GenericActivity() {
} }
override fun onPause() { override fun onPause() {
enableProximitySensor(false) coreContext.enableProximitySensor(false)
super.onPause() super.onPause()
@ -383,7 +375,7 @@ class CallActivity : GenericActivity() {
} }
override fun onDestroy() { override fun onDestroy() {
enableProximitySensor(false) coreContext.enableProximitySensor(false)
super.onDestroy() 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() { fun goToMainActivity() {
if (isPipSupported && callViewModel.isVideoEnabled.value == true) { if (isPipSupported && callViewModel.isVideoEnabled.value == true) {
Log.i("$TAG User is going back to MainActivity, try entering PiP mode") 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) modalBottomSheet.show(supportFragmentManager, ConferenceLayoutMenuDialogFragment.TAG)
bottomSheetDialog = modalBottomSheet 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)) (holder as ViewHolder).bind(getItem(position))
} }
inner class ViewHolder( class ViewHolder(
val binding: CallConferenceParticipantListCellBinding val binding: CallConferenceParticipantListCellBinding
) : RecyclerView.ViewHolder(binding.root) { ) : RecyclerView.ViewHolder(binding.root) {
@UiThread @UiThread

View file

@ -19,9 +19,6 @@
*/ */
package org.linphone.ui.call.conference.fragment 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.Bundle
import android.os.SystemClock import android.os.SystemClock
import android.view.LayoutInflater 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.fragment.GenericCallFragment
import org.linphone.ui.call.viewmodel.CallsViewModel import org.linphone.ui.call.viewmodel.CallsViewModel
import org.linphone.ui.call.viewmodel.CurrentCallViewModel import org.linphone.ui.call.viewmodel.CurrentCallViewModel
import org.linphone.utils.AppUtils
import org.linphone.utils.Event import org.linphone.utils.Event
import org.linphone.utils.startAnimatedDrawable import org.linphone.utils.startAnimatedDrawable
@ -272,14 +270,12 @@ class ActiveConferenceCallFragment : GenericCallFragment() {
} }
} }
binding.setShareConferenceClickListener { binding.setCopyConferenceUriToClipboardClickListener {
val sipUri = callViewModel.conferenceModel.sipUri.value.orEmpty() val sipUri = callViewModel.conferenceModel.sipUri.value.orEmpty()
if (sipUri.isNotEmpty()) { if (sipUri.isNotEmpty()) {
Log.i("$TAG Sharing conference SIP URI [$sipUri]") Log.i("$TAG Copying conference SIP URI [$sipUri] into clipboard")
val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val label = "Conference SIP address" 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.core.view.doOnPreDraw
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import org.linphone.core.Address
import org.linphone.core.Friend
import org.linphone.core.tools.Log import org.linphone.core.tools.Log
import org.linphone.databinding.GenericAddParticipantsFragmentBinding import org.linphone.databinding.GenericAddParticipantsFragmentBinding
import org.linphone.ui.call.viewmodel.CurrentCallViewModel import org.linphone.ui.call.viewmodel.CurrentCallViewModel
@ -65,10 +63,6 @@ class ConferenceAddParticipantsFragment : GenericAddressPickerFragment() {
return false 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?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
viewModel = ViewModelProvider(this)[AddParticipantsViewModel::class.java] viewModel = ViewModelProvider(this)[AddParticipantsViewModel::class.java]

View file

@ -19,9 +19,6 @@
*/ */
package org.linphone.ui.call.conference.fragment 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.Bundle
import android.view.Gravity import android.view.Gravity
import android.view.LayoutInflater 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.adapter.ConferenceParticipantsListAdapter
import org.linphone.ui.call.fragment.GenericCallFragment import org.linphone.ui.call.fragment.GenericCallFragment
import org.linphone.ui.call.viewmodel.CurrentCallViewModel import org.linphone.ui.call.viewmodel.CurrentCallViewModel
import org.linphone.utils.AppUtils
import org.linphone.utils.ConfirmationDialogModel import org.linphone.utils.ConfirmationDialogModel
import org.linphone.utils.DialogUtils import org.linphone.utils.DialogUtils
@ -175,7 +173,9 @@ class ConferenceParticipantsListFragment : GenericCallFragment() {
model.confirmEvent.observe(viewLifecycleOwner) { model.confirmEvent.observe(viewLifecycleOwner) {
it.consume { it.consume {
viewModel.conferenceModel.kickParticipant(participant) coreContext.postOnCoreThread {
viewModel.conferenceModel.kickParticipant(participant)
}
val message = getString(R.string.conference_participant_was_kicked_out_toast) val message = getString(R.string.conference_participant_was_kicked_out_toast)
val icon = R.drawable.check val icon = R.drawable.check
(requireActivity() as GenericActivity).showGreenToast(message, icon) (requireActivity() as GenericActivity).showGreenToast(message, icon)
@ -205,10 +205,8 @@ class ConferenceParticipantsListFragment : GenericCallFragment() {
val sipUri = viewModel.conferenceModel.sipUri.value.orEmpty() val sipUri = viewModel.conferenceModel.sipUri.value.orEmpty()
if (sipUri.isNotEmpty()) { if (sipUri.isNotEmpty()) {
Log.i("$TAG Sharing conference SIP URI [$sipUri]") Log.i("$TAG Sharing conference SIP URI [$sipUri]")
val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val label = "Conference SIP address" val label = "Conference SIP address"
clipboard.setPrimaryClip(ClipData.newPlainText(label, sipUri)) AppUtils.copyToClipboard(requireContext(), label, sipUri)
} }
popupWindow.dismiss() popupWindow.dismiss()

View file

@ -24,7 +24,6 @@ import androidx.annotation.UiThread
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.core.MediaDirection
import org.linphone.core.ParticipantDevice import org.linphone.core.ParticipantDevice
import org.linphone.core.ParticipantDeviceListenerStub import org.linphone.core.ParticipantDeviceListenerStub
import org.linphone.core.StreamType import org.linphone.core.StreamType
@ -43,9 +42,9 @@ class ConferenceParticipantDeviceModel
val avatarModel = coreContext.contactsManager.getContactAvatarModelForAddress(device.address) val avatarModel = coreContext.contactsManager.getContactAvatarModelForAddress(device.address)
val name = avatarModel.contactName ?: device.name ?: LinphoneUtils.getDisplayName( val name = avatarModel.contactName ?: device.name.orEmpty().ifEmpty {
device.address LinphoneUtils.getDisplayName(device.address)
) }
val isMuted = MutableLiveData<Boolean>() val isMuted = MutableLiveData<Boolean>()
@ -57,7 +56,7 @@ class ConferenceParticipantDeviceModel
val isVideoAvailable = MutableLiveData<Boolean>() val isVideoAvailable = MutableLiveData<Boolean>()
val isSendingVideo = MutableLiveData<Boolean>() val isThumbnailAvailable = MutableLiveData<Boolean>()
val isJoining = MutableLiveData<Boolean>() val isJoining = MutableLiveData<Boolean>()
@ -108,28 +107,6 @@ class ConferenceParticipantDeviceModel
isSpeaking.postValue(speaking) 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 @WorkerThread
override fun onScreenSharingChanged( override fun onScreenSharingChanged(
participantDevice: ParticipantDevice, participantDevice: ParticipantDevice,
@ -149,19 +126,21 @@ class ConferenceParticipantDeviceModel
Log.i( Log.i(
"$TAG Participant device [${participantDevice.address.asStringUriOnly()}] thumbnail availability changed to ${if (available) "available" else "not available"}" "$TAG Participant device [${participantDevice.address.asStringUriOnly()}] thumbnail availability changed to ${if (available) "available" else "not available"}"
) )
isVideoAvailable.postValue(available) isThumbnailAvailable.postValue(available)
} }
@WorkerThread @WorkerThread
override fun onThumbnailStreamCapabilityChanged( override fun onStreamAvailabilityChanged(
participantDevice: ParticipantDevice, participantDevice: ParticipantDevice,
direction: MediaDirection? available: Boolean,
streamType: StreamType?
) { ) {
Log.i( if (streamType == StreamType.Video) {
"$TAG Participant device [${participantDevice.address.asStringUriOnly()}] thumbnail capability changed to [$direction]" Log.i(
) "$TAG Participant device [${participantDevice.address.asStringUriOnly()}] video stream availability changed to ${if (available) "available" else "not available"}"
val sending = direction == MediaDirection.SendRecv || direction == MediaDirection.SendOnly )
isSendingVideo.postValue(sending) isVideoAvailable.postValue(available)
}
} }
} }
@ -196,18 +175,33 @@ class ConferenceParticipantDeviceModel
Log.i("$TAG Participant [${device.address.asStringUriOnly()}] is sharing its screen") Log.i("$TAG Participant [${device.address.asStringUriOnly()}] is sharing its screen")
} }
isVideoAvailable.postValue(device.getStreamAvailability(StreamType.Video)) val videoAvailability = device.getStreamAvailability(StreamType.Video)
val videoCapability = device.getStreamCapability(StreamType.Video) isVideoAvailable.postValue(videoAvailability)
isSendingVideo.postValue( Log.i(
videoCapability == MediaDirection.SendRecv || videoCapability == MediaDirection.SendOnly "$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 @WorkerThread
fun destroy() { fun destroy() {
clearWindowId()
device.removeListener(deviceListener) device.removeListener(deviceListener)
} }
@WorkerThread
fun clearWindowId() {
Log.i("$TAG Clearing participant [${device.address.asStringUriOnly()}] device window ID")
device.nativeVideoWindowId = null
}
@UiThread @UiThread
fun setTextureView(view: TextureView) { fun setTextureView(view: TextureView) {
Log.i( Log.i(

View file

@ -68,6 +68,8 @@ class ConferenceViewModel
val conferenceLayout = MutableLiveData<Int>() val conferenceLayout = MutableLiveData<Int>()
val screenSharingParticipantName = MutableLiveData<String>()
val isScreenSharing = MutableLiveData<Boolean>() val isScreenSharing = MutableLiveData<Boolean>()
val isPaused = MutableLiveData<Boolean>() val isPaused = MutableLiveData<Boolean>()
@ -125,10 +127,13 @@ class ConferenceViewModel
conference: Conference, conference: Conference,
device: ParticipantDevice 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 direction = device.getStreamCapability(StreamType.Video)
val sendingVideo = direction == MediaDirection.SendRecv || direction == MediaDirection.SendOnly val sendingVideo = direction == MediaDirection.SendRecv || direction == MediaDirection.SendOnly
localVideoStreamToggled(sendingVideo) localVideoStreamToggled(sendingVideo)
} else {
Log.i("$TAG Participant [${device.address.asStringUriOnly()}] device media capability changed")
} }
} }
@ -156,7 +161,7 @@ class ConferenceViewModel
} else { } else {
Log.w("$TAG Notified active speaker participant device is null, using first one that's not us") Log.w("$TAG Notified active speaker participant device is null, using first one that's not us")
val firstNotUs = participantDevices.value.orEmpty().find { val firstNotUs = participantDevices.value.orEmpty().find {
it.isMe == false !it.isMe
} }
if (firstNotUs != null) { if (firstNotUs != null) {
Log.i("$TAG Newly active speaker participant is [${firstNotUs.name}]") 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"}" "$TAG Participant device [${device.address.asStringUriOnly()}] is ${if (enabled) "sharing it's screen" else "no longer sharing it's screen"}"
) )
isScreenSharing.postValue(enabled) isScreenSharing.postValue(enabled)
if (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 val call = conference.call
if (call != null) { if (call != null) {
val currentLayout = getCurrentLayout(call) val currentLayout = getCurrentLayout(call)
@ -250,6 +265,8 @@ class ConferenceViewModel
} else { } else {
Log.e("$TAG Screen sharing was enabled but conference's call is null!") 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) isPaused.postValue(!isIn)
Log.i("$TAG We [${if (isIn) "are" else "aren't"}] in the conference") Log.i("$TAG We [${if (isIn) "are" else "aren't"}] in the conference")
subject.postValue(conference.subjectUtf8.orEmpty())
computeParticipants(false) computeParticipants(false)
if (conference.participantList.size >= 1) { // we do not count if (conference.participantList.size >= 1) { // we do not count
Log.i("$TAG Joined conference already has at least another participant") Log.i("$TAG Joined conference already has at least another participant")
@ -312,7 +330,7 @@ class ConferenceViewModel
val chatEnabled = conference.currentParams.isChatEnabled val chatEnabled = conference.currentParams.isChatEnabled
isConversationAvailable.postValue(chatEnabled) isConversationAvailable.postValue(chatEnabled)
val confSubject = conference.subject.orEmpty() val confSubject = conference.subjectUtf8.orEmpty()
Log.i( Log.i(
"$TAG Configuring conference with subject [$confSubject] from call [${call.callLog.callId}]" "$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" "$TAG Conference has a participant sharing its screen, changing layout from mosaic to active speaker"
) )
setNewLayout(ACTIVE_SPEAKER_LAYOUT) 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 @UiThread
fun goToConversation() { fun goToConversation() {
coreContext.postOnCoreThread { core -> if (::conference.isInitialized) {
Log.i("$TAG Navigating to conference's conversation") coreContext.postOnCoreThread { core ->
val chatRoom = conference.chatRoom Log.i("$TAG Navigating to conference's conversation")
if (chatRoom != null) { val chatRoom = conference.chatRoom
goToConversationEvent.postValue(Event(LinphoneUtils.getConversationId(chatRoom))) if (chatRoom != null) {
} else { goToConversationEvent.postValue(Event(LinphoneUtils.getConversationId(chatRoom)))
Log.e( } else {
"$TAG No chat room available for current conference [${conference.conferenceAddress?.asStringUriOnly()}]" Log.e(
) "$TAG No chat room available for current conference [${conference.conferenceAddress?.asStringUriOnly()}]"
)
}
} }
} }
} }
@ -393,82 +422,96 @@ class ConferenceViewModel
@UiThread @UiThread
fun inviteSipUrisIntoConference(uris: List<String>) { fun inviteSipUrisIntoConference(uris: List<String>) {
coreContext.postOnCoreThread { core -> if (::conference.isInitialized) {
val addresses = arrayListOf<Address>() coreContext.postOnCoreThread { core ->
for (uri in uris) { val addresses = arrayListOf<Address>()
val address = core.interpretUrl(uri, false) for (uri in uris) {
if (address != null) { val address = core.interpretUrl(uri, false)
addresses.add(address) if (address != null) {
Log.i("$TAG Address [${address.asStringUriOnly()}] will be added to conference") addresses.add(address)
} else { Log.i("$TAG Address [${address.asStringUriOnly()}] will be added to conference")
Log.e( } else {
"$TAG Failed to parse SIP URI [$uri] into address, can't add it to the conference!" 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) )
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 @WorkerThread
fun kickParticipant(participant: Participant) { fun kickParticipant(participant: Participant) {
coreContext.postOnCoreThread { if (::conference.isInitialized) {
Log.i( coreContext.postOnCoreThread {
"$TAG Kicking participant [${participant.address.asStringUriOnly()}] out of conference" Log.i(
) "$TAG Kicking participant [${participant.address.asStringUriOnly()}] out of conference"
conference.removeParticipant(participant) )
conference.removeParticipant(participant)
}
} }
} }
@WorkerThread @WorkerThread
fun setNewLayout(newLayout: Int) { fun setNewLayout(newLayout: Int) {
val call = conference.call if (::conference.isInitialized) {
if (call != null) { val call = conference.call
val params = call.core.createCallParams(call) if (call != null) {
if (params != null) { val params = call.core.createCallParams(call)
val currentLayout = getCurrentLayout(call) if (params != null) {
if (currentLayout != newLayout) { val currentLayout = getCurrentLayout(call)
when (newLayout) { if (currentLayout != newLayout) {
AUDIO_ONLY_LAYOUT -> { when (newLayout) {
Log.i("$TAG Changing conference layout to [Audio Only]") AUDIO_ONLY_LAYOUT -> {
params.isVideoEnabled = false 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 (currentLayout == AUDIO_ONLY_LAYOUT) { ACTIVE_SPEAKER_LAYOUT -> {
// Previous layout was audio only, make sure video isn't sent without user consent when switching layout Log.i("$TAG Changing conference layout to [Active Speaker]")
Log.i( params.conferenceVideoLayout = Conference.Layout.ActiveSpeaker
"$TAG Previous layout was [Audio Only], enabling video but in receive only direction" }
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 { } else {
Log.w( Log.e("$TAG Failed to create call params, aborting layout change")
"$TAG The conference is already using selected layout, aborting layout change"
)
} }
} else { } 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) activeSpeaker.postValue(model)
activeSpeakerParticipantDeviceFound = true 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 @WorkerThread
private fun addParticipant(participant: Participant) { private fun addParticipant(participant: Participant) {
val list = arrayListOf<ConferenceParticipantModel>() if (::conference.isInitialized) {
list.addAll(participants.value.orEmpty()) val list = arrayListOf<ConferenceParticipantModel>()
list.addAll(participants.value.orEmpty())
val avatarModel = coreContext.contactsManager.getContactAvatarModelForAddress( val avatarModel = coreContext.contactsManager.getContactAvatarModelForAddress(
participant.address 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 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 @WorkerThread

View file

@ -258,7 +258,7 @@ class ActiveCallFragment : GenericCallFragment() {
) )
} else { } else {
// Only allow "trying again" once // 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) { callViewModel.goToConversationEvent.observe(viewLifecycleOwner) {
it.consume { conversationId -> it.consume { conversationId ->
if (findNavController().currentDestination?.id == R.id.activeCallFragment) { if (findNavController().currentDestination?.id == R.id.activeCallFragment) {
@ -408,7 +399,7 @@ class ActiveCallFragment : GenericCallFragment() {
if (callViewModel.isZrtpAlertDialogVisible) { if (callViewModel.isZrtpAlertDialogVisible) {
Log.i("$TAG Fragment resuming, showing ZRTP alert dialog") Log.i("$TAG Fragment resuming, showing ZRTP alert dialog")
showZrtpAlertDialog(false) showZrtpAlertDialog()
} else if (callViewModel.isZrtpDialogVisible) { } else if (callViewModel.isZrtpDialogVisible) {
Log.i("$TAG Fragment resuming, showing ZRTP SAS validation dialog") Log.i("$TAG Fragment resuming, showing ZRTP SAS validation dialog")
callViewModel.showZrtpSasDialogIfPossible() callViewModel.showZrtpSasDialogIfPossible()
@ -481,12 +472,12 @@ class ActiveCallFragment : GenericCallFragment() {
callViewModel.isZrtpDialogVisible = true callViewModel.isZrtpDialogVisible = true
} }
private fun showZrtpAlertDialog(allowTryAgain: Boolean = true) { private fun showZrtpAlertDialog() {
if (zrtpSasDialog != null) { if (zrtpSasDialog != null) {
zrtpSasDialog?.dismiss() zrtpSasDialog?.dismiss()
} }
val model = ZrtpAlertDialogModel(allowTryAgain) val model = ZrtpAlertDialogModel(false)
val dialog = DialogUtils.getZrtpAlertDialog(requireActivity(), model) val dialog = DialogUtils.getZrtpAlertDialog(requireActivity(), model)
model.tryAgainEvent.observe(viewLifecycleOwner) { event -> model.tryAgainEvent.observe(viewLifecycleOwner) { event ->

View file

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

View file

@ -19,16 +19,22 @@
*/ */
package org.linphone.ui.call.fragment package org.linphone.ui.call.fragment
import android.annotation.SuppressLint
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.annotation.UiThread import androidx.annotation.UiThread
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.R
import org.linphone.core.tools.Log import org.linphone.core.tools.Log
import org.linphone.databinding.CallIncomingFragmentBinding import org.linphone.databinding.CallIncomingFragmentBinding
import org.linphone.ui.call.viewmodel.CurrentCallViewModel import org.linphone.ui.call.viewmodel.CurrentCallViewModel
import org.linphone.utils.AppUtils
import kotlin.math.max
import kotlin.math.min
@UiThread @UiThread
class IncomingCallFragment : GenericCallFragment() { class IncomingCallFragment : GenericCallFragment() {
@ -40,6 +46,53 @@ class IncomingCallFragment : GenericCallFragment() {
private lateinit var callViewModel: CurrentCallViewModel 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( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
@ -49,6 +102,7 @@ class IncomingCallFragment : GenericCallFragment() {
return binding.root return binding.root
} }
@SuppressLint("ClickableViewAccessibility")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
@ -68,11 +122,14 @@ class IncomingCallFragment : GenericCallFragment() {
} }
} }
} }
binding.bottomBar.lockedScreenBottomBar.slidingButton.setOnTouchListener(slidingButtonTouchListener)
} }
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
callViewModel.refreshKeyguardLockedStatus()
coreContext.notificationsManager.setIncomingCallFragmentCurrentlyDisplayed(true) coreContext.notificationsManager.setIncomingCallFragmentCurrentlyDisplayed(true)
} }

View file

@ -32,6 +32,7 @@ import androidx.navigation.navGraphViewModels
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior
import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.LinphoneApplication.Companion.corePreferences
import org.linphone.R import org.linphone.R
import org.linphone.contacts.getListOfSipAddressesAndPhoneNumbers import org.linphone.contacts.getListOfSipAddressesAndPhoneNumbers
import org.linphone.core.Address import org.linphone.core.Address
@ -61,6 +62,16 @@ class NewCallFragment : GenericCallFragment() {
R.id.call_nav_graph 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 lateinit var adapter: ConversationsContactsAndSuggestionsListAdapter
private val listener = object : ContactNumberOrAddressClickListener { 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 -> viewModel.isNumpadVisible.observe(viewLifecycleOwner) { visible ->
val standardBottomSheetBehavior = BottomSheetBehavior.from(binding.numpadLayout.root)
if (visible) { if (visible) {
standardBottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED bottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED
} else { } 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() { override fun onPause() {
super.onPause() super.onPause()

View file

@ -26,10 +26,13 @@ import android.view.ViewGroup
import androidx.annotation.UiThread import androidx.annotation.UiThread
import androidx.core.view.doOnLayout import androidx.core.view.doOnLayout
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import com.google.android.material.bottomsheet.BottomSheetBehavior
import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.core.Call
import org.linphone.core.tools.Log import org.linphone.core.tools.Log
import org.linphone.databinding.CallOutgoingFragmentBinding import org.linphone.databinding.CallOutgoingFragmentBinding
import org.linphone.ui.call.viewmodel.CurrentCallViewModel import org.linphone.ui.call.viewmodel.CurrentCallViewModel
import org.linphone.utils.LinphoneUtils
@UiThread @UiThread
class OutgoingCallFragment : GenericCallFragment() { class OutgoingCallFragment : GenericCallFragment() {
@ -60,15 +63,31 @@ class OutgoingCallFragment : GenericCallFragment() {
binding.lifecycleOwner = viewLifecycleOwner binding.lifecycleOwner = viewLifecycleOwner
binding.viewModel = callViewModel binding.viewModel = callViewModel
binding.numpadModel = callViewModel.numpadModel
callViewModel.isOutgoingEarlyMedia.observe(viewLifecycleOwner) { earlyMedia -> callViewModel.isOutgoingEarlyMedia.observe(viewLifecycleOwner) { earlyMedia ->
if (earlyMedia) { if (earlyMedia) {
coreContext.postOnCoreThread { core -> coreContext.postOnCoreThread { core ->
Log.i("$TAG Outgoing early-media call with video, setting preview surface") val call = core.calls.find {
core.nativePreviewWindowId = binding.localPreviewVideoSurface 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() { override fun onResume() {

View file

@ -33,7 +33,9 @@ import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior
import kotlin.getValue import kotlin.getValue
import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.LinphoneApplication.Companion.corePreferences
import org.linphone.R import org.linphone.R
import org.linphone.core.Address
import org.linphone.core.tools.Log import org.linphone.core.tools.Log
import org.linphone.databinding.CallTransferFragmentBinding import org.linphone.databinding.CallTransferFragmentBinding
import org.linphone.ui.call.adapter.CallsListAdapter 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.adapter.ConversationsContactsAndSuggestionsListAdapter
import org.linphone.ui.main.history.viewmodel.StartCallViewModel import org.linphone.ui.main.history.viewmodel.StartCallViewModel
import org.linphone.utils.ConfirmationDialogModel import org.linphone.utils.ConfirmationDialogModel
import org.linphone.ui.main.model.ConversationContactOrSuggestionModel
import org.linphone.utils.AppUtils import org.linphone.utils.AppUtils
import org.linphone.utils.DialogUtils import org.linphone.utils.DialogUtils
import org.linphone.utils.RecyclerViewHeaderDecoration import org.linphone.utils.RecyclerViewHeaderDecoration
@ -63,6 +64,16 @@ class TransferCallFragment : GenericCallFragment() {
R.id.call_nav_graph 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 callViewModel: CurrentCallViewModel
private lateinit var callsViewModel: CallsViewModel private lateinit var callsViewModel: CallsViewModel
@ -119,18 +130,21 @@ class TransferCallFragment : GenericCallFragment() {
binding.callsList.setHasFixedSize(true) binding.callsList.setHasFixedSize(true)
binding.contactsAndSuggestionsList.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) { callsAdapter.callClickedEvent.observe(viewLifecycleOwner) {
it.consume { model -> it.consume { model ->
showConfirmAttendedTransferDialog(model) showConfirmAttendedTransferDialog(model)
} }
} }
val headerItemDecoration = RecyclerViewHeaderDecoration(requireContext(), contactsAdapter)
binding.contactsAndSuggestionsList.addItemDecoration(headerItemDecoration)
contactsAdapter.onClickedEvent.observe(viewLifecycleOwner) { contactsAdapter.onClickedEvent.observe(viewLifecycleOwner) {
it.consume { model -> 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( viewModel.modelsList.observe(
viewLifecycleOwner 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 -> viewModel.isNumpadVisible.observe(viewLifecycleOwner) { visible ->
val standardBottomSheetBehavior = BottomSheetBehavior.from(binding.numpadLayout.root)
if (visible) { if (visible) {
standardBottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED bottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED
} else { } 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, R.string.call_transfer_current_call_title,
callViewModel.displayedName.value ?: callViewModel.displayedAddress.value callViewModel.displayedName.value ?: callViewModel.displayedAddress.value
) )
coreContext.postOnCoreThread {
if (corePreferences.automaticallyShowDialpad) {
viewModel.isNumpadVisible.postValue(true)
}
}
} }
private fun showConfirmAttendedTransferDialog(callModel: CallModel) { 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( val label = AppUtils.getFormattedString(
R.string.call_transfer_confirm_dialog_message, R.string.call_transfer_confirm_dialog_message,
callViewModel.displayedName.value.orEmpty(), from,
callModel.displayName.value.orEmpty() to
) )
val model = ConfirmationDialogModel(label) val model = ConfirmationDialogModel(label)
val dialog = DialogUtils.getConfirmCallTransferCallDialog( val dialog = DialogUtils.getConfirmCallTransferCallDialog(
@ -252,8 +283,9 @@ class TransferCallFragment : GenericCallFragment() {
model model
) )
model.cancelEvent.observe(viewLifecycleOwner) { model.dismissEvent.observe(viewLifecycleOwner) {
it.consume { it.consume {
Log.i("$TAG Attended transfer was cancelled by user")
dialog.dismiss() dialog.dismiss()
} }
} }
@ -276,11 +308,13 @@ class TransferCallFragment : GenericCallFragment() {
dialog.show() 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( val label = AppUtils.getFormattedString(
R.string.call_transfer_confirm_dialog_message, R.string.call_transfer_confirm_dialog_message,
callViewModel.displayedName.value.orEmpty(), from,
contactModel.name toDisplayName
) )
val model = ConfirmationDialogModel(label) val model = ConfirmationDialogModel(label)
val dialog = DialogUtils.getConfirmCallTransferCallDialog( val dialog = DialogUtils.getConfirmCallTransferCallDialog(
@ -288,8 +322,9 @@ class TransferCallFragment : GenericCallFragment() {
model model
) )
model.cancelEvent.observe(viewLifecycleOwner) { model.dismissEvent.observe(viewLifecycleOwner) {
it.consume { it.consume {
Log.i("$TAG Blind transfer was cancelled by user")
dialog.dismiss() dialog.dismiss()
} }
} }
@ -297,9 +332,8 @@ class TransferCallFragment : GenericCallFragment() {
model.confirmEvent.observe(viewLifecycleOwner) { model.confirmEvent.observe(viewLifecycleOwner) {
it.consume { it.consume {
coreContext.postOnCoreThread { coreContext.postOnCoreThread {
val address = contactModel.address Log.i("$TAG Transferring (blind) call to [${toAddress.asStringUriOnly()}]")
Log.i("$TAG Transferring (blind) call to [${address.asStringUriOnly()}]") callViewModel.blindTransferCallTo(toAddress)
callViewModel.blindTransferCallTo(address)
} }
dialog.dismiss() dialog.dismiss()

View file

@ -21,8 +21,10 @@ package org.linphone.ui.call.model
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import org.linphone.LinphoneApplication.Companion.corePreferences
import kotlin.math.roundToInt import kotlin.math.roundToInt
import org.linphone.R import org.linphone.R
import org.linphone.core.Address
import org.linphone.core.Call import org.linphone.core.Call
import org.linphone.core.CallStats import org.linphone.core.CallStats
import org.linphone.core.MediaDirection import org.linphone.core.MediaDirection
@ -36,6 +38,8 @@ class CallStatsModel
val audioBandwidth = MutableLiveData<String>() val audioBandwidth = MutableLiveData<String>()
val lossRate = MutableLiveData<String>() val lossRate = MutableLiveData<String>()
val jitterBuffer = MutableLiveData<String>() val jitterBuffer = MutableLiveData<String>()
val audioIce = MutableLiveData<String>()
val audioIpFamily = MutableLiveData<String>()
val isVideoEnabled = MutableLiveData<Boolean>() val isVideoEnabled = MutableLiveData<Boolean>()
val videoCodec = MutableLiveData<String>() val videoCodec = MutableLiveData<String>()
@ -43,12 +47,20 @@ class CallStatsModel
val videoLossRate = MutableLiveData<String>() val videoLossRate = MutableLiveData<String>()
val videoResolution = MutableLiveData<String>() val videoResolution = MutableLiveData<String>()
val videoFps = MutableLiveData<String>() val videoFps = MutableLiveData<String>()
val videoIce = MutableLiveData<String>()
val videoIpFamily = MutableLiveData<String>()
val fecEnabled = MutableLiveData<Boolean>() val fecEnabled = MutableLiveData<Boolean>()
val lostPackets = MutableLiveData<String>() val lostPackets = MutableLiveData<String>()
val repairedPackets = MutableLiveData<String>() val repairedPackets = MutableLiveData<String>()
val fecBandwidth = MutableLiveData<String>() val fecBandwidth = MutableLiveData<String>()
val showAdvancedStats = MutableLiveData<Boolean>()
init {
showAdvancedStats.postValue(corePreferences.showAdvancedCallStats)
}
@WorkerThread @WorkerThread
fun update(call: Call, stats: CallStats?) { fun update(call: Call, stats: CallStats?) {
stats ?: return stats ?: return
@ -96,6 +108,18 @@ class CallStatsModel
"$jitterBufferSize ms" "$jitterBufferSize ms"
) )
jitterBuffer.postValue(jitterBufferLabel) jitterBuffer.postValue(jitterBufferLabel)
val iceLabel = AppUtils.getFormattedString(
R.string.call_stats_ice_label,
stats.iceState
)
audioIce.postValue(iceLabel)
val ipLabel = AppUtils.getFormattedString(
R.string.call_stats_ip_family_label,
if (stats.ipFamilyOfRemote == Address.Family.Inet6) "IPv6" else "IPv4"
)
audioIpFamily.postValue(ipLabel)
} }
StreamType.Video -> { StreamType.Video -> {
val payloadType = call.currentParams.usedVideoPayloadType val payloadType = call.currentParams.usedVideoPayloadType
@ -138,6 +162,18 @@ class CallStatsModel
) )
videoFps.postValue(fpsLabel) videoFps.postValue(fpsLabel)
val iceLabel = AppUtils.getFormattedString(
R.string.call_stats_ice_label,
stats.iceState
)
videoIce.postValue(iceLabel)
val ipLabel = AppUtils.getFormattedString(
R.string.call_stats_ip_family_label,
if (stats.ipFamilyOfRemote == Address.Family.Inet6) "IPv6" else "IPv4"
)
videoIpFamily.postValue(ipLabel)
if (isFecEnabled) { if (isFecEnabled) {
val lostPacketsValue = stats.fecCumulativeLostPacketsNumber val lostPacketsValue = stats.fecCumulativeLostPacketsNumber
val lostPacketsLabel = AppUtils.getFormattedString( val lostPacketsLabel = AppUtils.getFormattedString(

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 package org.linphone.ui.call.viewmodel
import android.Manifest import android.Manifest
import android.app.KeyguardManager
import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import androidx.annotation.AnyThread import androidx.annotation.AnyThread
import androidx.annotation.UiThread import androidx.annotation.UiThread
@ -29,6 +31,9 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay 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.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.LinphoneApplication.Companion.coreContext
@ -69,6 +74,8 @@ class CurrentCallViewModel
constructor() : GenericViewModel() { constructor() : GenericViewModel() {
companion object { companion object {
private const val TAG = "[Current Call ViewModel]" 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>() val contact = MutableLiveData<ContactAvatarModel>()
@ -107,12 +114,20 @@ class CurrentCallViewModel
val isMicrophoneMuted = MutableLiveData<Boolean>() val isMicrophoneMuted = MutableLiveData<Boolean>()
val microphoneRecordingVolume = MutableLiveData<Float>()
val playbackVolume = MutableLiveData<Float>()
val isSpeakerEnabled = MutableLiveData<Boolean>() val isSpeakerEnabled = MutableLiveData<Boolean>()
val isHeadsetEnabled = MutableLiveData<Boolean>() val isHeadsetEnabled = MutableLiveData<Boolean>()
val isHearingAidEnabled = MutableLiveData<Boolean>()
val isBluetoothEnabled = MutableLiveData<Boolean>() val isBluetoothEnabled = MutableLiveData<Boolean>()
val isHdmiEnabled = MutableLiveData<Boolean>()
val fullScreenMode = MutableLiveData<Boolean>() val fullScreenMode = MutableLiveData<Boolean>()
val pipMode = MutableLiveData<Boolean>() val pipMode = MutableLiveData<Boolean>()
@ -143,6 +158,8 @@ class CurrentCallViewModel
val qualityIcon = MutableLiveData<Int>() val qualityIcon = MutableLiveData<Int>()
val hideSipAddresses = MutableLiveData<Boolean>()
var terminatedByUser = false var terminatedByUser = false
val isRemoteRecordingEvent: MutableLiveData<Event<Pair<Boolean, String>>> by lazy { val isRemoteRecordingEvent: MutableLiveData<Event<Pair<Boolean, String>>> by lazy {
@ -203,10 +220,6 @@ class CurrentCallViewModel
MutableLiveData<Event<String>>() MutableLiveData<Event<String>>()
} }
val chatRoomCreationErrorEvent: MutableLiveData<Event<Int>> by lazy {
MutableLiveData<Event<Int>>()
}
// Conference // Conference
val conferenceModel = ConferenceViewModel() val conferenceModel = ConferenceViewModel()
@ -247,6 +260,10 @@ class CurrentCallViewModel
MutableLiveData<Event<Boolean>>() MutableLiveData<Event<Boolean>>()
} }
// Sliding answer/decline button
val isScreenLocked = MutableLiveData<Boolean>()
lateinit var currentCall: Call lateinit var currentCall: Call
private val contactsListener = object : ContactsListener { private val contactsListener = object : ContactsListener {
@ -278,6 +295,7 @@ class CurrentCallViewModel
updateEncryption() updateEncryption()
} }
@WorkerThread
override fun onAuthenticationTokenVerified(call: Call, verified: Boolean) { override fun onAuthenticationTokenVerified(call: Call, verified: Boolean) {
Log.w( Log.w(
"$TAG Notified that authentication token is [${if (verified) "verified" else "not verified!"}]" "$TAG Notified that authentication token is [${if (verified) "verified" else "not verified!"}]"
@ -291,11 +309,13 @@ class CurrentCallViewModel
updateAvatarModelSecurityLevel(verified) updateAvatarModelSecurityLevel(verified)
} }
@WorkerThread
override fun onRemoteRecording(call: Call, recording: Boolean) { override fun onRemoteRecording(call: Call, recording: Boolean) {
Log.i("$TAG Remote recording changed: $recording") Log.i("$TAG Remote recording changed: $recording")
isRemoteRecordingEvent.postValue(Event(Pair(recording, displayedName.value.orEmpty()))) isRemoteRecordingEvent.postValue(Event(Pair(recording, displayedName.value.orEmpty())))
} }
@WorkerThread
override fun onStatsUpdated(call: Call, stats: CallStats) { override fun onStatsUpdated(call: Call, stats: CallStats) {
callStatsModel.update(call, stats) callStatsModel.update(call, stats)
} }
@ -320,7 +340,6 @@ class CurrentCallViewModel
"$TAG From now on current call will be [${newCurrentCall.remoteAddress.asStringUriOnly()}]" "$TAG From now on current call will be [${newCurrentCall.remoteAddress.asStringUriOnly()}]"
) )
configureCall(newCurrentCall) configureCall(newCurrentCall)
updateEncryption()
} else { } else {
Log.e("$TAG Failed to get a valid call to display") Log.e("$TAG Failed to get a valid call to display")
endCall(call) endCall(call)
@ -329,7 +348,7 @@ class CurrentCallViewModel
endCall(call) endCall(call)
} }
} else { } else {
val videoEnabled = call.currentParams.isVideoEnabled val videoEnabled = LinphoneUtils.isVideoEnabled(call)
if (videoEnabled && isVideoEnabled.value == false) { if (videoEnabled && isVideoEnabled.value == false) {
if (isBluetoothEnabled.value == true || isHeadsetEnabled.value == true) { if (isBluetoothEnabled.value == true || isHeadsetEnabled.value == true) {
Log.i( Log.i(
@ -341,7 +360,7 @@ class CurrentCallViewModel
} }
} }
isVideoEnabled.postValue(videoEnabled) isVideoEnabled.postValue(videoEnabled)
updateVideoDirection(call.currentParams.videoDirection) updateVideoDirection(call.currentParams.videoDirection, skipIfNotStreamsRunning = true)
if (call.state == Call.State.Connected) { if (call.state == Call.State.Connected) {
updateCallDuration() updateCallDuration()
@ -406,14 +425,13 @@ class CurrentCallViewModel
Log.e("$TAG Conversation [$id] creation has failed!") Log.e("$TAG Conversation [$id] creation has failed!")
chatRoom.removeListener(this) chatRoom.removeListener(this)
operationInProgress.postValue(false) operationInProgress.postValue(false)
chatRoomCreationErrorEvent.postValue( showRedToast(R.string.conversation_failed_to_create_toast, R.drawable.warning_circle)
Event(R.string.conversation_creation_error_toast)
)
} }
} }
} }
private val coreListener = object : CoreListenerStub() { private val coreListener = object : CoreListenerStub() {
@WorkerThread
override fun onCallStateChanged( override fun onCallStateChanged(
core: Core, core: Core,
call: Call, call: Call,
@ -432,7 +450,6 @@ class CurrentCallViewModel
) )
currentCall.removeListener(callListener) currentCall.removeListener(callListener)
configureCall(call) configureCall(call)
updateEncryption()
} else if (LinphoneUtils.isCallIncoming(call.state)) { } else if (LinphoneUtils.isCallIncoming(call.state)) {
Log.w( Log.w(
"$TAG A call is being received [${call.remoteAddress.asStringUriOnly()}], using it as current call unless declined" "$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) unreadMessagesCount.postValue(0)
} }
} }
}
@WorkerThread @WorkerThread
private fun updateProximitySensor() { override fun onAudioDevicesListUpdated(core: Core) {
if (::currentCall.isInitialized) { Log.i("$TAG Audio devices list has been updated")
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)
} }
} }
@ -528,8 +524,13 @@ class CurrentCallViewModel
operationInProgress.value = false operationInProgress.value = false
proximitySensorEnabled.value = false proximitySensorEnabled.value = false
videoUpdateInProgress.value = false videoUpdateInProgress.value = false
microphoneRecordingVolume.value = 0f
playbackVolume.value = 0f
refreshKeyguardLockedStatus()
coreContext.postOnCoreThread { core -> coreContext.postOnCoreThread { core ->
hideSipAddresses.postValue(corePreferences.hideSipAddresses)
coreContext.contactsManager.addListener(contactsListener) coreContext.contactsManager.addListener(contactsListener)
core.addListener(coreListener) core.addListener(coreListener)
@ -566,6 +567,8 @@ class CurrentCallViewModel
}, },
{ // OnCallClicked { // OnCallClicked
}, },
{ // OnBlindTransferClicked
},
{ // OnClearInput { // 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 @UiThread
fun answer() { fun answer() {
coreContext.postOnCoreThread { core -> coreContext.postOnCoreThread { core ->
@ -600,6 +611,7 @@ class CurrentCallViewModel
coreContext.answerCall(call) coreContext.answerCall(call)
} else { } else {
Log.e("$TAG No call found in incoming state, can't answer any!") 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()}]") Log.i("$TAG Terminating call [${currentCall.remoteAddress.asStringUriOnly()}]")
terminatedByUser = true terminatedByUser = true
coreContext.terminateCall(currentCall) coreContext.terminateCall(currentCall)
} else {
Log.e("$TAG No call to decline!")
finishActivityEvent.postValue(Event(true))
} }
} }
} }
@ -701,6 +716,10 @@ class CurrentCallViewModel
@UiThread @UiThread
fun changeAudioOutputDevice() { fun changeAudioOutputDevice() {
val routeAudioToSpeaker = isSpeakerEnabled.value != true 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 -> coreContext.postOnCoreThread { core ->
var earpieceFound = false var earpieceFound = false
@ -713,36 +732,17 @@ class CurrentCallViewModel
for (device in audioDevices) { for (device in audioDevices) {
// Only list output audio devices // Only list output audio devices
if (!device.hasCapability(AudioDevice.Capabilities.CapabilityPlay)) continue if (!device.hasCapability(AudioDevice.Capabilities.CapabilityPlay)) continue
when (device.type) {
val name = when (device.type) {
AudioDevice.Type.Earpiece -> { AudioDevice.Type.Earpiece -> {
earpieceFound = true earpieceFound = true
AppUtils.getString(R.string.call_audio_device_type_earpiece)
} }
AudioDevice.Type.Speaker -> { AudioDevice.Type.Speaker -> {
speakerFound = true speakerFound = true
AppUtils.getString(R.string.call_audio_device_type_speaker)
} }
AudioDevice.Type.Headset -> { else -> {}
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
} }
val name = LinphoneUtils.getAudioDeviceName(device)
val isCurrentlyInUse = device.type == currentDevice?.type && device.deviceName == currentDevice.deviceName val isCurrentlyInUse = device.type == currentDevice?.type && device.deviceName == currentDevice.deviceName
val model = AudioDeviceModel(device, name, device.type, isCurrentlyInUse, true) { val model = AudioDeviceModel(device, name, device.type, isCurrentlyInUse, true) {
// onSelected // onSelected
@ -753,12 +753,18 @@ class CurrentCallViewModel
AudioDevice.Type.Headset, AudioDevice.Type.Headphones -> AudioUtils.routeAudioToHeadset( AudioDevice.Type.Headset, AudioDevice.Type.Headphones -> AudioUtils.routeAudioToHeadset(
currentCall currentCall
) )
AudioDevice.Type.Bluetooth, AudioDevice.Type.HearingAid -> AudioUtils.routeAudioToBluetooth( AudioDevice.Type.Bluetooth -> AudioUtils.routeAudioToBluetooth(
currentCall
)
AudioDevice.Type.HearingAid -> AudioUtils.routeAudioToHearingAid(
currentCall currentCall
) )
AudioDevice.Type.Speaker -> AudioUtils.routeAudioToSpeaker( AudioDevice.Type.Speaker -> AudioUtils.routeAudioToSpeaker(
currentCall currentCall
) )
AudioDevice.Type.Hdmi -> AudioUtils.routeAudioToHdmi(
currentCall
)
else -> AudioUtils.routeAudioToEarpiece(currentCall) else -> AudioUtils.routeAudioToEarpiece(currentCall)
} }
} }
@ -775,12 +781,10 @@ class CurrentCallViewModel
Log.i( Log.i(
"$TAG Found less than two devices, simply switching between earpiece & speaker" "$TAG Found less than two devices, simply switching between earpiece & speaker"
) )
if (::currentCall.isInitialized) { if (routeAudioToSpeaker) {
if (routeAudioToSpeaker) { AudioUtils.routeAudioToSpeaker(currentCall)
AudioUtils.routeAudioToSpeaker(currentCall) } else {
} else { AudioUtils.routeAudioToEarpiece(currentCall)
AudioUtils.routeAudioToEarpiece(currentCall)
}
} }
} }
} }
@ -851,15 +855,16 @@ class CurrentCallViewModel
fun toggleRecording() { fun toggleRecording() {
coreContext.postOnCoreThread { coreContext.postOnCoreThread {
if (::currentCall.isInitialized) { if (::currentCall.isInitialized) {
if (currentCall.params.isRecording) { val recording = if (currentCall.params.isRecording) {
Log.i("$TAG Stopping call recording") Log.i("$TAG Stopping call recording")
currentCall.stopRecording() currentCall.stopRecording()
false
} else { } else {
Log.i("$TAG Starting call recording") Log.i("$TAG Starting call recording")
currentCall.startRecording() currentCall.startRecording()
true
} }
val recording = currentCall.params.isRecording
isRecording.postValue(recording) isRecording.postValue(recording)
if (recording) { if (recording) {
showRecordingToast() showRecordingToast()
@ -924,12 +929,10 @@ class CurrentCallViewModel
fun createConversation() { fun createConversation() {
if (::currentCall.isInitialized) { if (::currentCall.isInitialized) {
coreContext.postOnCoreThread { coreContext.postOnCoreThread {
val existingConversation = lookupCurrentCallConversation(currentCall) val existingConversation = currentCallConversation ?: lookupCurrentCallConversation(currentCall)
if (existingConversation != null) { if (existingConversation != null) {
Log.i( Log.i(
"$TAG Found existing conversation [${ "$TAG Found existing conversation [${LinphoneUtils.getConversationId(existingConversation)}], going to it"
LinphoneUtils.getConversationId(existingConversation)
}], going to it"
) )
goToConversationEvent.postValue(Event(LinphoneUtils.getConversationId(existingConversation))) goToConversationEvent.postValue(Event(LinphoneUtils.getConversationId(existingConversation)))
} else { } else {
@ -943,6 +946,17 @@ class CurrentCallViewModel
@WorkerThread @WorkerThread
fun attendedTransferCallTo(to: Call) { fun attendedTransferCallTo(to: Call) {
if (::currentCall.isInitialized) { 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( Log.i(
"$TAG Doing an attended transfer between currently displayed call [${currentCall.remoteAddress.asStringUriOnly()}] and paused call [${to.remoteAddress.asStringUriOnly()}]" "$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 @WorkerThread
fun blindTransferCallTo(to: Address) { fun blindTransferCallTo(to: Address) {
if (::currentCall.isInitialized) { 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( Log.i(
"$TAG Call [${currentCall.remoteAddress.asStringUriOnly()}] is being blindly transferred to [${to.asStringUriOnly()}]" "$TAG Call [${currentCall.remoteAddress.asStringUriOnly()}] is being blindly transferred to [${to.asStringUriOnly()}]"
) )
@ -1058,9 +1078,14 @@ class CurrentCallViewModel
callMediaEncryptionModel.update(call) callMediaEncryptionModel.update(call)
call.addListener(callListener) call.addListener(callListener)
if (call.currentParams.mediaEncryption == MediaEncryption.None) { val state = call.state
waitingForEncryptionInfo.postValue(true) if (LinphoneUtils.isCallOutgoing(state) || LinphoneUtils.isCallIncoming(state)) {
isMediaEncrypted.postValue(false) if (call.currentParams.mediaEncryption == MediaEncryption.None) {
waitingForEncryptionInfo.postValue(true)
isMediaEncrypted.postValue(false)
} else {
updateEncryption()
}
} else { } else {
updateEncryption() updateEncryption()
} }
@ -1079,7 +1104,19 @@ class CurrentCallViewModel
if (call.dir == Call.Dir.Incoming) { if (call.dir == Call.Dir.Incoming) {
val isVideo = call.remoteParams?.isVideoEnabled == true && call.remoteParams?.videoDirection != MediaDirection.Inactive val isVideo = call.remoteParams?.isVideoEnabled == true && call.remoteParams?.videoDirection != MediaDirection.Inactive
if (call.core.accountList.size > 1) { 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) { if (isVideo) {
incomingCallTitle.postValue( incomingCallTitle.postValue(
AppUtils.getFormattedString( AppUtils.getFormattedString(
@ -1113,7 +1150,7 @@ class CurrentCallViewModel
) )
} else { } else {
isVideoEnabled.postValue(call.currentParams.isVideoEnabled) isVideoEnabled.postValue(call.currentParams.isVideoEnabled)
updateVideoDirection(call.currentParams.videoDirection) updateVideoDirection(call.currentParams.videoDirection, skipIfNotStreamsRunning = true)
} }
if (ActivityCompat.checkSelfPermission( if (ActivityCompat.checkSelfPermission(
@ -1137,7 +1174,6 @@ class CurrentCallViewModel
updateOutputAudioDevice(audioDevice) updateOutputAudioDevice(audioDevice)
isOutgoing.postValue(call.dir == Call.Dir.Outgoing) isOutgoing.postValue(call.dir == Call.Dir.Outgoing)
val state = call.state
isOutgoingRinging.postValue(state == Call.State.OutgoingRinging) isOutgoingRinging.postValue(state == Call.State.OutgoingRinging)
isIncomingEarlyMedia.postValue(state == Call.State.IncomingEarlyMedia) isIncomingEarlyMedia.postValue(state == Call.State.IncomingEarlyMedia)
isOutgoingEarlyMedia.postValue(state == Call.State.OutgoingEarlyMedia) isOutgoingEarlyMedia.postValue(state == Call.State.OutgoingEarlyMedia)
@ -1157,7 +1193,8 @@ class CurrentCallViewModel
val model = if (conferenceInfo != null) { val model = if (conferenceInfo != null) {
coreContext.contactsManager.getContactAvatarModelForConferenceInfo(conferenceInfo) coreContext.contactsManager.getContactAvatarModelForConferenceInfo(conferenceInfo)
} else { } 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) val friend = coreContext.contactsManager.findContactByAddress(address)
if (friend != null) { if (friend != null) {
ContactAvatarModel(friend, address) ContactAvatarModel(friend, address)
@ -1165,6 +1202,12 @@ class CurrentCallViewModel
val fakeFriend = coreContext.core.createFriend() val fakeFriend = coreContext.core.createFriend()
fakeFriend.name = LinphoneUtils.getDisplayName(address) fakeFriend.name = LinphoneUtils.getDisplayName(address)
fakeFriend.address = 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) ContactAvatarModel(fakeFriend, address)
} }
} }
@ -1197,6 +1240,19 @@ class CurrentCallViewModel
} else { } else {
Log.i("$TAG Failed to find an existing 1-1 conversation for current call") 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 @WorkerThread
@ -1213,7 +1269,9 @@ class CurrentCallViewModel
isHeadsetEnabled.postValue( isHeadsetEnabled.postValue(
audioDevice?.type == AudioDevice.Type.Headphones || audioDevice?.type == AudioDevice.Type.Headset audioDevice?.type == AudioDevice.Type.Headphones || audioDevice?.type == AudioDevice.Type.Headset
) )
isHearingAidEnabled.postValue(audioDevice?.type == AudioDevice.Type.HearingAid)
isBluetoothEnabled.postValue(audioDevice?.type == AudioDevice.Type.Bluetooth) isBluetoothEnabled.postValue(audioDevice?.type == AudioDevice.Type.Bluetooth)
isHdmiEnabled.postValue(audioDevice?.type == AudioDevice.Type.Hdmi)
updateProximitySensor() updateProximitySensor()
} }
@ -1238,14 +1296,15 @@ class CurrentCallViewModel
} }
@WorkerThread @WorkerThread
private fun updateVideoDirection(direction: MediaDirection) { private fun updateVideoDirection(direction: MediaDirection, skipIfNotStreamsRunning: Boolean = false) {
val state = currentCall.state val state = currentCall.state
if (state != Call.State.StreamsRunning) { if (skipIfNotStreamsRunning && state != Call.State.StreamsRunning) {
return return
} }
val isSending = direction == MediaDirection.SendRecv || direction == MediaDirection.SendOnly val isConnected = state == Call.State.Connected || state == Call.State.StreamsRunning
val isReceiving = direction == MediaDirection.SendRecv || direction == MediaDirection.RecvOnly 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 wasSending = isSendingVideo.value == true
val wasReceiving = isReceivingVideo.value == true val wasReceiving = isReceivingVideo.value == true
@ -1324,16 +1383,13 @@ class CurrentCallViewModel
val localAddress = call.callLog.localAddress val localAddress = call.callLog.localAddress
val remoteAddress = call.remoteAddress val remoteAddress = call.remoteAddress
val params: ConferenceParams? = null
val existingConversation = if (call.conference != null) { val existingConversation = if (call.conference != null) {
call.core.searchChatRoom( Log.i("$TAG We're in [${remoteAddress.asStringUriOnly()}] conference, using it as chat room if possible")
params, call.conference?.chatRoom
localAddress,
remoteAddress,
arrayOf()
)
} else { } else {
val params = getChatRoomParams(call)
val participants = arrayOf(remoteAddress) val participants = arrayOf(remoteAddress)
Log.i("$TAG Looking for conversation with local address [${localAddress.asStringUriOnly()}] and participant [${remoteAddress.asStringUriOnly()}]")
call.core.searchChatRoom( call.core.searchChatRoom(
params, params,
localAddress, localAddress,
@ -1380,9 +1436,7 @@ class CurrentCallViewModel
"$TAG Failed to create 1-1 conversation with [${remoteAddress.asStringUriOnly()}]!" "$TAG Failed to create 1-1 conversation with [${remoteAddress.asStringUriOnly()}]!"
) )
operationInProgress.postValue(false) operationInProgress.postValue(false)
chatRoomCreationErrorEvent.postValue( showRedToast(R.string.conversation_failed_to_create_toast, R.drawable.warning_circle)
Event(R.string.conversation_creation_error_toast)
)
} }
} }
@ -1468,4 +1522,43 @@ class CurrentCallViewModel
private fun showRecordingToast() { private fun showRecordingToast() {
showGreenToast(R.string.call_is_being_recorded, R.drawable.record_fill) 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 package org.linphone.ui.fileviewer
import android.content.ActivityNotFoundException
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.util.DisplayMetrics import android.util.DisplayMetrics
@ -127,23 +128,13 @@ class FileViewerActivity : GenericActivity() {
viewModel.exportPlainTextFileEvent.observe(this) { viewModel.exportPlainTextFileEvent.observe(this) {
it.consume { name -> it.consume { name ->
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply { exportFile(name, "text/plain")
addCategory(Intent.CATEGORY_OPENABLE)
type = "text/plain"
putExtra(Intent.EXTRA_TITLE, name)
}
startActivityForResult(intent, EXPORT_FILE_AS_DOCUMENT)
} }
} }
viewModel.exportPdfEvent.observe(this) { viewModel.exportPdfEvent.observe(this) {
it.consume { name -> it.consume { name ->
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply { exportFile(name, "application/pdf")
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/pdf"
putExtra(Intent.EXTRA_TITLE, name)
}
startActivityForResult(intent, EXPORT_FILE_AS_DOCUMENT)
} }
} }
} }
@ -206,10 +197,27 @@ class FileViewerActivity : GenericActivity() {
} }
val shareIntent = Intent.createChooser(sendIntent, null) val shareIntent = Intent.createChooser(sendIntent, null)
startActivity(shareIntent) try {
startActivity(shareIntent)
} catch (anfe: ActivityNotFoundException) {
Log.e("$TAG Failed to start intent chooser: $anfe")
}
} else { } else {
Log.e("$TAG Failed to copy file [$filePath] to share!") 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 package org.linphone.ui.fileviewer
import android.content.ActivityNotFoundException
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
@ -269,7 +270,11 @@ class MediaViewerActivity : GenericActivity() {
} }
val shareIntent = Intent.createChooser(sendIntent, null) val shareIntent = Intent.createChooser(sendIntent, null)
startActivity(shareIntent) try {
startActivity(shareIntent)
} catch (anfe: ActivityNotFoundException) {
Log.e("$TAG Failed to start intent chooser: $anfe")
}
} else { } else {
Log.e( Log.e(
"$TAG Failed to copy file [$filePath] to share!" "$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.TextureView.SurfaceTextureListener
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.SeekBar
import androidx.annotation.UiThread import androidx.annotation.UiThread
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import org.linphone.core.tools.Log import org.linphone.core.tools.Log
@ -45,6 +46,21 @@ class MediaViewerFragment : GenericMainFragment() {
private lateinit var viewModel: MediaViewModel 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( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
@ -87,6 +103,8 @@ class MediaViewerFragment : GenericMainFragment() {
sharedViewModel.mediaViewerFullScreenMode.value = fullScreenMode sharedViewModel.mediaViewerFullScreenMode.value = fullScreenMode
} }
binding.setSeekBarListener(seekBarListener)
viewModel.videoSizeChangedEvent.observe(viewLifecycleOwner) { viewModel.videoSizeChangedEvent.observe(viewLifecycleOwner) {
it.consume { pair -> it.consume { pair ->
val width = pair.first val width = pair.first
@ -109,7 +127,7 @@ class MediaViewerFragment : GenericMainFragment() {
val textureView = binding.videoPlayer val textureView = binding.videoPlayer
if (textureView.isAvailable) { if (textureView.isAvailable) {
Log.i("$TAG Surface created, setting display in mediaPlayer") Log.i("$TAG Surface created, setting display in mediaPlayer")
viewModel.mediaPlayer.setSurface((Surface(textureView.surfaceTexture))) viewModel.setMediaPlayerSurface((Surface(textureView.surfaceTexture)))
} else { } else {
Log.i("$TAG Surface not available yet, setting listener") Log.i("$TAG Surface not available yet, setting listener")
textureView.surfaceTextureListener = object : SurfaceTextureListener { textureView.surfaceTextureListener = object : SurfaceTextureListener {
@ -119,7 +137,7 @@ class MediaViewerFragment : GenericMainFragment() {
p2: Int p2: Int
) { ) {
Log.i("$TAG Surface available, setting display in mediaPlayer") Log.i("$TAG Surface available, setting display in mediaPlayer")
viewModel.mediaPlayer.setSurface(Surface(surfaceTexture)) viewModel.setMediaPlayerSurface(Surface(surfaceTexture))
} }
override fun onSurfaceTextureSizeChanged( override fun onSurfaceTextureSizeChanged(

View file

@ -130,8 +130,7 @@ class FileViewModel
val extension = FileUtils.getExtensionFromFileName(file) val extension = FileUtils.getExtensionFromFileName(file)
val mime = FileUtils.getMimeTypeFromExtension(extension) val mime = FileUtils.getMimeTypeFromExtension(extension)
mimeType.postValue(mime) mimeType.postValue(mime)
val mimeType = FileUtils.getMimeType(mime) when (val mimeType = FileUtils.getMimeType(mime)) {
when (mimeType) {
FileUtils.MimeType.Pdf -> { FileUtils.MimeType.Pdf -> {
Log.d("$TAG File [$file] seems to be a PDF") Log.d("$TAG File [$file] seems to be a PDF")
loadPdf() loadPdf()
@ -278,13 +277,26 @@ class FileViewModel
File(filePath), File(filePath),
ParcelFileDescriptor.MODE_READ_ONLY ParcelFileDescriptor.MODE_READ_ONLY
) )
pdfRenderer = PdfRenderer(input) try {
val count = pdfRenderer.pageCount pdfRenderer = PdfRenderer(input)
Log.i("$TAG $count pages in file $filePath") val count = pdfRenderer.pageCount
pdfPages.postValue(count.toString()) Log.i("$TAG $count pages in file $filePath")
pdfCurrentPage.postValue("1") pdfPages.postValue(count.toString())
pdfRendererReadyEvent.postValue(Event(true)) pdfCurrentPage.postValue("1")
fileReadyEvent.postValue(Event(true)) 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.AudioAttributes
import android.media.MediaPlayer import android.media.MediaPlayer
import android.view.Surface
import androidx.annotation.UiThread import androidx.annotation.UiThread
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@ -160,6 +161,14 @@ class MediaViewModel
} }
} }
@UiThread
fun seekTo(position: Int) {
if (::mediaPlayer.isInitialized) {
mediaPlayer.seekTo(position)
play()
}
}
@UiThread @UiThread
private fun initMediaPlayer() { private fun initMediaPlayer() {
isMediaPlaying.value = false isMediaPlaying.value = false
@ -225,4 +234,11 @@ class MediaViewModel
updatePositionJob?.cancel() updatePositionJob?.cancel()
updatePositionJob = null 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.GenericActivity
import org.linphone.ui.assistant.AssistantActivity import org.linphone.ui.assistant.AssistantActivity
import org.linphone.ui.main.chat.fragment.ConversationsListFragmentDirections import org.linphone.ui.main.chat.fragment.ConversationsListFragmentDirections
import org.linphone.ui.main.help.fragment.DebugFragmentDirections
import org.linphone.utils.PasswordDialogModel import org.linphone.utils.PasswordDialogModel
import org.linphone.ui.main.sso.fragment.SingleSignOnFragmentDirections import org.linphone.ui.main.sso.fragment.SingleSignOnFragmentDirections
import org.linphone.ui.main.viewmodel.MainViewModel 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.Event
import org.linphone.utils.FileUtils import org.linphone.utils.FileUtils
import org.linphone.utils.LinphoneUtils import org.linphone.utils.LinphoneUtils
import androidx.core.content.edit
@UiThread @UiThread
class MainActivity : GenericActivity() { class MainActivity : GenericActivity() {
@ -120,12 +120,23 @@ class MainActivity : GenericActivity() {
) { isGranted -> ) { isGranted ->
if (isGranted) { if (isGranted) {
Log.i("$TAG POST_NOTIFICATIONS permission has been granted") Log.i("$TAG POST_NOTIFICATIONS permission has been granted")
viewModel.updatePostNotificationsPermission() viewModel.updateMissingPermissionAlert()
} else { } else {
Log.w("$TAG POST_NOTIFICATIONS permission has been denied!") 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") @SuppressLint("InlinedApi")
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
// Must be done before the setContentView // Must be done before the setContentView
@ -143,7 +154,8 @@ class MainActivity : GenericActivity() {
binding.lifecycleOwner = this binding.lifecycleOwner = this
setUpToastsArea(binding.toastsArea) 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()) val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
v.updatePadding(0, insets.top, 0, 0) v.updatePadding(0, insets.top, 0, 0)
windowInsets windowInsets
@ -204,17 +216,14 @@ class MainActivity : GenericActivity() {
} }
} }
viewModel.defaultAccountRegistrationErrorEvent.observe(this) { viewModel.askFullScreenIntentPermissionEvent.observe(this) {
it.consume { error -> it.consume {
val tag = "DEFAULT_ACCOUNT_REGISTRATION_ERROR" if (ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.USE_FULL_SCREEN_INTENT)) {
if (error) { Log.w("$TAG Asking for USE_FULL_SCREEN_INTENT permission")
// First remove any already existing connection error toast fullScreenIntentPermissionLauncher.launch(Manifest.permission.USE_FULL_SCREEN_INTENT)
removePersistentRedToast(tag)
val message = getString(R.string.default_account_connection_state_error_toast)
showPersistentRedToast(message, R.drawable.warning_circle, tag)
} else { } 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 // Wait for latest visited fragment to be displayed before hiding the splashscreen
binding.root.viewTreeObserver.addOnPreDrawListener(object : ViewTreeObserver.OnPreDrawListener { binding.root.viewTreeObserver.addOnPreDrawListener(object : ViewTreeObserver.OnPreDrawListener {
override fun onPreDraw(): Boolean { override fun onPreDraw(): Boolean {
@ -277,7 +309,9 @@ class MainActivity : GenericActivity() {
coreContext.digestAuthenticationRequestedEvent.observe(this) { coreContext.digestAuthenticationRequestedEvent.observe(this) {
it.consume { identity -> it.consume { identity ->
try { try {
showAuthenticationRequestedDialog(identity) if (coreContext.digestAuthInfoPendingPasswordUpdate != null) {
showAuthenticationRequestedDialog(identity)
}
} catch (e: WindowManager.BadTokenException) { } catch (e: WindowManager.BadTokenException) {
Log.e("$TAG Failed to show authentication dialog: $e") Log.e("$TAG Failed to show authentication dialog: $e")
} }
@ -382,9 +416,8 @@ class MainActivity : GenericActivity() {
HISTORY_FRAGMENT_ID HISTORY_FRAGMENT_ID
} }
} }
with(getPreferences(MODE_PRIVATE).edit()) { getPreferences(MODE_PRIVATE).edit {
putInt(DEFAULT_FRAGMENT_KEY, defaultFragmentId) putInt(DEFAULT_FRAGMENT_KEY, defaultFragmentId)
apply()
} }
Log.i("$TAG Stored [$defaultFragmentId] as default page") Log.i("$TAG Stored [$defaultFragmentId] as default page")
@ -395,9 +428,8 @@ class MainActivity : GenericActivity() {
super.onResume() super.onResume()
viewModel.enableAccountMonitoring(true) viewModel.enableAccountMonitoring(true)
viewModel.checkForNewAccount() viewModel.updateMissingPermissionAlert()
viewModel.updateNetworkReachability() viewModel.updateAccountsAndNetworkReachability()
viewModel.updatePostNotificationsPermission()
} }
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
@ -688,8 +720,13 @@ class MainActivity : GenericActivity() {
sharedViewModel.showConversationEvent.value = Event(conversationId) sharedViewModel.showConversationEvent.value = Event(conversationId)
} }
val action = DebugFragmentDirections.actionDebugFragmentToConversationsListFragment() val action = ConversationsListFragmentDirections.actionGlobalConversationsListFragment()
findNavController().navigate(action) val options = NavOptions.Builder()
options.apply {
setPopUpTo(R.id.helpFragment, true)
setLaunchSingleTop(true)
}
findNavController().navigate(action, options.build())
} else { } else {
val conversationId = parseShortcutIfAny(intent) val conversationId = parseShortcutIfAny(intent)
if (conversationId != null) { if (conversationId != null) {
@ -753,11 +790,11 @@ class MainActivity : GenericActivity() {
} }
private fun handleConfigIntent(uri: String) { private fun handleConfigIntent(uri: String) {
val remoteConfigUri = uri.substring("linphone-config:".length) Log.i("$TAG Trying to parse config intent [$uri] as remote provisioning URL")
val url = when { val url = LinphoneUtils.getRemoteProvisioningUrlFromUri(uri)
remoteConfigUri.startsWith("http://") || remoteConfigUri.startsWith("https://") -> remoteConfigUri if (url == null) {
remoteConfigUri.startsWith("file://") -> remoteConfigUri Log.e("$TAG Couldn't parse URI [$uri] into a valid remote provisioning URL, aborting")
else -> "https://$remoteConfigUri" return
} }
coreContext.postOnCoreThread { core -> coreContext.postOnCoreThread { core ->

View file

@ -161,7 +161,7 @@ class ConversationsContactsAndSuggestionsListAdapter :
} }
} }
inner class ConversationViewHolder( class ConversationViewHolder(
val binding: GenericAddressPickerConversationListCellBinding val binding: GenericAddressPickerConversationListCellBinding
) : RecyclerView.ViewHolder(binding.root) { ) : RecyclerView.ViewHolder(binding.root) {
@UiThread @UiThread
@ -198,7 +198,7 @@ class ConversationsContactsAndSuggestionsListAdapter :
} }
} }
inner class SuggestionViewHolder( class SuggestionViewHolder(
val binding: GenericAddressPickerSuggestionListCellBinding val binding: GenericAddressPickerSuggestionListCellBinding
) : RecyclerView.ViewHolder(binding.root) { ) : RecyclerView.ViewHolder(binding.root) {
@UiThread @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 * This file is part of linphone-android
* (see https://www.linphone.org). * (see https://www.linphone.org).
@ -21,13 +21,12 @@ package org.linphone.ui.main.chat
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView 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() { RecyclerView.OnScrollListener() {
companion object { companion object {
// The minimum amount of items to have below your current scroll position private const val TAG = "[RecyclerView Scroll Listener]"
// before loading more.
private const val VISIBLE_THRESHOLD = 5
} }
// The total number of items in the data set after the last load // 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, // 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. // but first we check if we are waiting for the previous load to finish.
override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) { override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) {
val totalItemCount = mLayoutManager.itemCount val totalItemCount = layoutManager.itemCount
val firstVisibleItemPosition: Int = mLayoutManager.findFirstVisibleItemPosition() val firstVisibleItemPosition: Int = layoutManager.findFirstVisibleItemPosition()
val lastVisibleItemPosition: Int = mLayoutManager.findLastVisibleItemPosition() val lastVisibleItemPosition: Int = layoutManager.findLastVisibleItemPosition()
// If the total item count is zero and the previous isn't, assume the // 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 // 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 val userHasScrolledUp = lastVisibleItemPosition != totalItemCount - 1
if (userHasScrolledUp) { if (userHasScrolledUp) {
onScrolledUp() onScrolledUp()
Log.d("$TAG Scrolled up")
} else { } else {
onScrolledToEnd() onScrolledToEnd()
Log.d("$TAG Scrolled to end")
} }
// If it isnt currently loading, we check to see if we have breached // 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. // 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 // threshold should reflect how many total columns there are too
if (!loading && if (!loading) {
firstVisibleItemPosition < VISIBLE_THRESHOLD && if (scrollingTopToBottom) {
firstVisibleItemPosition >= 0 && if (lastVisibleItemPosition >= totalItemCount - visibleThreshold) {
lastVisibleItemPosition < totalItemCount - VISIBLE_THRESHOLD Log.d(
) { "$TAG Last visible item position [$lastVisibleItemPosition] reached [${totalItemCount - visibleThreshold}], loading more (current total items is [$totalItemCount])"
onLoadMore(totalItemCount) )
loading = true 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.ChatBubbleOutgoingBinding
import org.linphone.databinding.ChatConversationEventBinding import org.linphone.databinding.ChatConversationEventBinding
import org.linphone.databinding.ChatConversationE2eEncryptedFirstEventBinding 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.EventLogModel
import org.linphone.ui.main.chat.model.EventModel import org.linphone.ui.main.chat.model.EventModel
import org.linphone.ui.main.chat.model.MessageModel import org.linphone.ui.main.chat.model.MessageModel
@ -82,7 +83,11 @@ class ConversationEventAdapter :
} }
override fun getHeaderViewForPosition(context: Context, position: Int): View { 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 return binding.root
} }
@ -197,7 +202,7 @@ class ConversationEventAdapter :
} }
} }
inner class IncomingBubbleViewHolder( class IncomingBubbleViewHolder(
val binding: ChatBubbleIncomingBinding val binding: ChatBubbleIncomingBinding
) : RecyclerView.ViewHolder(binding.root) { ) : RecyclerView.ViewHolder(binding.root) {
fun bind(message: MessageModel) { fun bind(message: MessageModel) {
@ -212,7 +217,7 @@ class ConversationEventAdapter :
} }
} }
inner class OutgoingBubbleViewHolder( class OutgoingBubbleViewHolder(
val binding: ChatBubbleOutgoingBinding val binding: ChatBubbleOutgoingBinding
) : RecyclerView.ViewHolder(binding.root) { ) : RecyclerView.ViewHolder(binding.root) {
fun bind(message: MessageModel) { fun bind(message: MessageModel) {
@ -227,7 +232,7 @@ class ConversationEventAdapter :
} }
} }
inner class EventViewHolder( class EventViewHolder(
val binding: ChatConversationEventBinding val binding: ChatConversationEventBinding
) : RecyclerView.ViewHolder(binding.root) { ) : RecyclerView.ViewHolder(binding.root) {
fun bind(event: EventModel) { fun bind(event: EventModel) {

View file

@ -50,7 +50,7 @@ class ConversationParticipantsAdapter : ListAdapter<ParticipantModel, RecyclerVi
(holder as ViewHolder).bind(getItem(position)) (holder as ViewHolder).bind(getItem(position))
} }
inner class ViewHolder( class ViewHolder(
val binding: ChatParticipantListCellBinding val binding: ChatParticipantListCellBinding
) : RecyclerView.ViewHolder(binding.root) { ) : RecyclerView.ViewHolder(binding.root) {
@UiThread @UiThread

View file

@ -30,10 +30,11 @@ import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.linphone.R import org.linphone.R
import org.linphone.databinding.ChatDocumentContentListCellBinding import org.linphone.databinding.ChatBubbleSingleFileContentBinding
import org.linphone.databinding.ChatMediaContentGridCellBinding import org.linphone.databinding.ChatMediaContentGridCellBinding
import org.linphone.databinding.MeetingsListDecorationBinding import org.linphone.databinding.MeetingsListDecorationBinding
import org.linphone.ui.main.chat.model.FileModel import org.linphone.ui.main.chat.model.FileModel
import org.linphone.utils.AppUtils
import org.linphone.utils.HeaderAdapter import org.linphone.utils.HeaderAdapter
class ConversationsFilesAdapter : class ConversationsFilesAdapter :
@ -46,6 +47,9 @@ class ConversationsFilesAdapter :
const val DOCUMENT_FILE = 2 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 { override fun displayHeaderForPosition(position: Int): Boolean {
if (position == 0) return true if (position == 0) return true
@ -89,15 +93,16 @@ class ConversationsFilesAdapter :
} }
private fun createDocumentFileViewHolder(parent: ViewGroup): RecyclerView.ViewHolder { private fun createDocumentFileViewHolder(parent: ViewGroup): RecyclerView.ViewHolder {
val binding: ChatDocumentContentListCellBinding = DataBindingUtil.inflate( val binding: ChatBubbleSingleFileContentBinding = DataBindingUtil.inflate(
LayoutInflater.from(parent.context), LayoutInflater.from(parent.context),
R.layout.chat_document_content_list_cell, R.layout.chat_bubble_single_file_content,
parent, parent,
false false
) )
val viewHolder = DocumentFileViewHolder(binding) val viewHolder = DocumentFileViewHolder(binding)
binding.apply { binding.apply {
lifecycleOwner = parent.findViewTreeLifecycleOwner() lifecycleOwner = parent.findViewTreeLifecycleOwner()
root.setPadding(startEndPadding, topBottomPadding, startEndPadding, topBottomPadding)
} }
return viewHolder return viewHolder
} }
@ -110,7 +115,7 @@ class ConversationsFilesAdapter :
} }
} }
inner class MediaFileViewHolder( class MediaFileViewHolder(
val binding: ChatMediaContentGridCellBinding val binding: ChatMediaContentGridCellBinding
) : RecyclerView.ViewHolder(binding.root) { ) : RecyclerView.ViewHolder(binding.root) {
@UiThread @UiThread
@ -122,8 +127,8 @@ class ConversationsFilesAdapter :
} }
} }
inner class DocumentFileViewHolder( class DocumentFileViewHolder(
val binding: ChatDocumentContentListCellBinding val binding: ChatBubbleSingleFileContentBinding
) : RecyclerView.ViewHolder(binding.root) { ) : RecyclerView.ViewHolder(binding.root) {
@UiThread @UiThread
fun bind(fileModel: FileModel) { fun bind(fileModel: FileModel) {

View file

@ -50,7 +50,7 @@ class MessageBottomSheetAdapter : ListAdapter<MessageBottomSheetParticipantModel
(holder as ViewHolder).bind(getItem(position)) (holder as ViewHolder).bind(getItem(position))
} }
inner class ViewHolder( class ViewHolder(
val binding: ChatMessageBottomSheetListCellBinding val binding: ChatMessageBottomSheetListCellBinding
) : RecyclerView.ViewHolder(binding.root) { ) : RecyclerView.ViewHolder(binding.root) {
@UiThread @UiThread

View file

@ -33,6 +33,7 @@ import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import org.linphone.core.tools.Log import org.linphone.core.tools.Log
import org.linphone.databinding.ChatDocumentsFragmentBinding 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.adapter.ConversationsFilesAdapter
import org.linphone.ui.main.chat.model.FileModel import org.linphone.ui.main.chat.model.FileModel
import org.linphone.ui.main.chat.viewmodel.ConversationDocumentsListViewModel import org.linphone.ui.main.chat.viewmodel.ConversationDocumentsListViewModel
@ -57,6 +58,8 @@ class ConversationDocumentsListFragment : SlidingPaneChildFragment() {
private val args: ConversationMediaListFragmentArgs by navArgs() private val args: ConversationMediaListFragmentArgs by navArgs()
private lateinit var scrollListener: RecyclerViewScrollListener
override fun goBack(): Boolean { override fun goBack(): Boolean {
try { try {
return findNavController().popBackStack() return findNavController().popBackStack()
@ -130,6 +133,40 @@ class ConversationDocumentsListFragment : SlidingPaneChildFragment() {
goToFileViewer(model) 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) { private fun goToFileViewer(fileModel: FileModel) {
@ -181,12 +218,6 @@ class ConversationDocumentsListFragment : SlidingPaneChildFragment() {
} }
} }
model.cancelEvent.observe(viewLifecycleOwner) {
it.consume {
dialog.dismiss()
}
}
model.confirmEvent.observe(viewLifecycleOwner) { model.confirmEvent.observe(viewLifecycleOwner) {
it.consume { it.consume {
sharedViewModel.displayFileEvent.value = Event(bundle) sharedViewModel.displayFileEvent.value = Event(bundle)

View file

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

View file

@ -149,7 +149,7 @@ class ConversationForwardMessageFragment : SlidingPaneChildFragment() {
} }
} }
viewModel.hideNumberOrAddressPickerDialogEvent.observe(viewLifecycleOwner) { viewModel.dismissNumberOrAddressPickerDialogEvent.observe(viewLifecycleOwner) {
it.consume { it.consume {
numberOrAddressPickerDialog?.dismiss() numberOrAddressPickerDialog?.dismiss()
numberOrAddressPickerDialog = null 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 numberOrAddressModel = NumberOrAddressPickerDialogModel(list)
val dialog = val dialog =
DialogUtils.getNumberOrAddressPickerDialog( DialogUtils.getNumberOrAddressPickerDialog(

View file

@ -69,7 +69,7 @@ import org.linphone.core.tools.Log
import org.linphone.databinding.ChatConversationFragmentBinding import org.linphone.databinding.ChatConversationFragmentBinding
import org.linphone.databinding.ChatConversationPopupMenuBinding import org.linphone.databinding.ChatConversationPopupMenuBinding
import org.linphone.ui.GenericActivity 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.ConversationEventAdapter
import org.linphone.ui.main.chat.adapter.MessageBottomSheetAdapter import org.linphone.ui.main.chat.adapter.MessageBottomSheetAdapter
import org.linphone.ui.main.chat.model.FileModel 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.setKeyboardInsetListener
import org.linphone.utils.showKeyboard import org.linphone.utils.showKeyboard
import androidx.core.net.toUri 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 @UiThread
open class ConversationFragment : SlidingPaneChildFragment() { open class ConversationFragment : SlidingPaneChildFragment() {
@ -113,6 +117,8 @@ open class ConversationFragment : SlidingPaneChildFragment() {
private lateinit var adapter: ConversationEventAdapter private lateinit var adapter: ConversationEventAdapter
private lateinit var participantsAdapter: ConversationParticipantsAdapter
private lateinit var bottomSheetAdapter: MessageBottomSheetAdapter private lateinit var bottomSheetAdapter: MessageBottomSheetAdapter
private val args: ConversationFragmentArgs by navArgs() private val args: ConversationFragmentArgs by navArgs()
@ -127,22 +133,20 @@ open class ConversationFragment : SlidingPaneChildFragment() {
) )
) { list -> ) { list ->
sendMessageViewModel.closeFilePickerBottomSheet() sendMessageViewModel.closeFilePickerBottomSheet()
if (list.isNotEmpty()) { val filesToAttach = arrayListOf<String>()
lifecycleScope.launch {
for (uri in list) { for (uri in list) {
lifecycleScope.launch { withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { val path = FileUtils.getFilePath(requireContext(), uri, false)
val path = FileUtils.getFilePath(requireContext(), uri, false) Log.i("$TAG Picked file [$uri] matching path is [$path]")
Log.i("$TAG Picked file [$uri] matching path is [$path]") if (path != null) {
if (path != null) { filesToAttach.add(path)
withContext(Dispatchers.Main) {
sendMessageViewModel.addAttachment(path)
}
}
} }
} }
} }
} else { withContext(Dispatchers.Main) {
Log.w("$TAG No file picked") sendMessageViewModel.addAttachments(filesToAttach)
}
} }
} }
@ -152,16 +156,20 @@ open class ConversationFragment : SlidingPaneChildFragment() {
ActivityResultContracts.OpenMultipleDocuments() ActivityResultContracts.OpenMultipleDocuments()
) { files -> ) { files ->
sendMessageViewModel.closeFilePickerBottomSheet() sendMessageViewModel.closeFilePickerBottomSheet()
for (fileUri in files) { val filesToAttach = arrayListOf<String>()
lifecycleScope.launch { lifecycleScope.launch {
for (fileUri in files) {
val path = FileUtils.getFilePath(requireContext(), fileUri, false).orEmpty() val path = FileUtils.getFilePath(requireContext(), fileUri, false).orEmpty()
if (path.isNotEmpty()) { if (path.isNotEmpty()) {
Log.i("$TAG Picked file [$path]") Log.i("$TAG Picked file [$path]")
sendMessageViewModel.addAttachment(path) filesToAttach.add(path)
} else { } else {
Log.e("$TAG Failed to pick file [$fileUri]") 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 (path != null) {
if (captured) { if (captured) {
Log.i("$TAG Image was captured and saved in [$path]") Log.i("$TAG Image was captured and saved in [$path]")
sendMessageViewModel.addAttachment(path) sendMessageViewModel.addAttachments(arrayListOf(path))
} else { } else {
Log.w("$TAG Image capture was aborted") Log.w("$TAG Image capture was aborted")
lifecycleScope.launch { lifecycleScope.launch {
@ -278,26 +286,33 @@ open class ConversationFragment : SlidingPaneChildFragment() {
override fun onTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) { 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) { 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(" ") if (sendMessageViewModel.isParticipantsListOpen.value == false) {
for (part in split) { Log.i("$TAG '@' found, opening participants list")
if (part == "@") { sendMessageViewModel.openParticipantsList()
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.notifyComposing(editable.toString().isNotEmpty())
sendMessageViewModel.notifyChatMessageIsBeingComposed()
}
} }
} }
private lateinit var scrollListener: ConversationScrollListener private lateinit var scrollListener: RecyclerViewScrollListener
private lateinit var headerItemDecoration: RecyclerViewHeaderDecoration private lateinit var headerItemDecoration: RecyclerViewHeaderDecoration
@ -313,6 +328,8 @@ open class ConversationFragment : SlidingPaneChildFragment() {
if (e.y >= 0 && e.y <= headerItemDecoration.getDecorationHeight(0)) { if (e.y >= 0 && e.y <= headerItemDecoration.getDecorationHeight(0)) {
if (viewModel.isEndToEndEncrypted.value == true) { if (viewModel.isEndToEndEncrypted.value == true) {
showEndToEndEncryptionDetailsBottomSheet() showEndToEndEncryptionDetailsBottomSheet()
} else {
showUnsafeConversationDisabledDetailsBottomSheet()
} }
return true return true
} }
@ -383,6 +400,7 @@ open class ConversationFragment : SlidingPaneChildFragment() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
adapter = ConversationEventAdapter() adapter = ConversationEventAdapter()
participantsAdapter = ConversationParticipantsAdapter()
headerItemDecoration = RecyclerViewHeaderDecoration( headerItemDecoration = RecyclerViewHeaderDecoration(
requireContext(), requireContext(),
adapter, adapter,
@ -454,6 +472,10 @@ open class ConversationFragment : SlidingPaneChildFragment() {
layoutManager.stackFromEnd = true layoutManager.stackFromEnd = true
binding.eventsList.layoutManager = layoutManager binding.eventsList.layoutManager = layoutManager
binding.sendArea.participants.participantsList.setHasFixedSize(true)
val participantsLayoutManager = LinearLayoutManager(requireContext())
binding.sendArea.participants.participantsList.layoutManager = participantsLayoutManager
val callbacks = RecyclerViewSwipeUtilsCallback( val callbacks = RecyclerViewSwipeUtilsCallback(
R.drawable.reply, R.drawable.reply,
ConversationEventAdapter.EventViewHolder::class.java ConversationEventAdapter.EventViewHolder::class.java
@ -471,9 +493,14 @@ open class ConversationFragment : SlidingPaneChildFragment() {
val chatMessageEventLog = adapter.currentList[index] val chatMessageEventLog = adapter.currentList[index]
val chatMessageModel = (chatMessageEventLog.model as? MessageModel) val chatMessageModel = (chatMessageEventLog.model as? MessageModel)
if (chatMessageModel != null) { if (chatMessageModel != null) {
sendMessageViewModel.replyToMessage(chatMessageModel) if (chatMessageModel.hasBeenRetracted.value == true) { // Don't allow to reply to retracted messages
// Open keyboard & focus edit text // TODO: notify user?
binding.sendArea.messageToSend.showKeyboard() } else {
viewModel.closeSearchBar()
sendMessageViewModel.replyToMessage(chatMessageModel)
// Open keyboard & focus edit text
binding.sendArea.messageToSend.showKeyboard()
}
} else { } else {
Log.e( Log.e(
"$TAG Can't reply, failed to get a ChatMessageModel from adapter item #[$index]" "$TAG Can't reply, failed to get a ChatMessageModel from adapter item #[$index]"
@ -502,6 +529,9 @@ open class ConversationFragment : SlidingPaneChildFragment() {
) )
} }
} else { } else {
sharedViewModel.displayedChatRoom = viewModel.chatRoom
ShortcutUtils.reportChatRoomShortcutHasBeenUsed(requireContext(), viewModel.conversationId)
sendMessageViewModel.configureChatRoom(viewModel.chatRoom) sendMessageViewModel.configureChatRoom(viewModel.chatRoom)
adapter.setIsConversationSecured(viewModel.isEndToEndEncrypted.value == true) 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" "$TAG Voice record playback finished, looking for voice record in next message"
) )
val list = viewModel.eventsList val list = viewModel.eventsList
val model = list.find { val model = list.find { eventLogModel ->
(it.model as? MessageModel)?.id == id (eventLogModel.model as? MessageModel)?.id == id
} }
if (model != null) { if (model != null) {
val index = list.indexOf(model) val index = list.indexOf(model)
@ -586,8 +616,7 @@ open class ConversationFragment : SlidingPaneChildFragment() {
viewModel.isEndToEndEncrypted.observe(viewLifecycleOwner) { encrypted -> viewModel.isEndToEndEncrypted.observe(viewLifecycleOwner) { encrypted ->
adapter.setIsConversationSecured(encrypted) adapter.setIsConversationSecured(encrypted)
if (encrypted || (!encrypted && viewModel.isEndToEndEncryptionAvailable.value == true)) {
if (encrypted) {
binding.eventsList.addItemDecoration(headerItemDecoration) binding.eventsList.addItemDecoration(headerItemDecoration)
binding.eventsList.addOnItemTouchListener(listItemTouchListener) binding.eventsList.addOnItemTouchListener(listItemTouchListener)
} }
@ -714,6 +743,12 @@ open class ConversationFragment : SlidingPaneChildFragment() {
false false
} }
sendMessageViewModel.messageSentEvent.observe(viewLifecycleOwner) {
it.consume { message ->
viewModel.addSentMessageToEventsList(message)
}
}
sendMessageViewModel.emojiToAddEvent.observe(viewLifecycleOwner) { sendMessageViewModel.emojiToAddEvent.observe(viewLifecycleOwner) {
it.consume { emoji -> it.consume { emoji ->
binding.sendArea.messageToSend.addCharacterAtPosition(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) { viewModel.focusSearchBarEvent.observe(viewLifecycleOwner) {
it.consume { show -> it.consume { show ->
if (show) { if (show) {
val bottomSheetBehavior = BottomSheetBehavior.from(binding.messageBottomSheet.root)
bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
// To automatically open keyboard // To automatically open keyboard
binding.search.showKeyboard() binding.search.showKeyboard()
} else { } 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) { viewModel.conferenceToJoinEvent.observe(viewLifecycleOwner) {
it.consume { conferenceUri -> it.consume { conferenceUri ->
if (messageLongPressViewModel.visible.value == true) return@consume if (messageLongPressViewModel.visible.value == true) return@consume
@ -781,6 +842,14 @@ open class ConversationFragment : SlidingPaneChildFragment() {
try { try {
val browserIntent = Intent(Intent.ACTION_VIEW, url.toUri()) val browserIntent = Intent(Intent.ACTION_VIEW, url.toUri())
startActivity(browserIntent) 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) { } catch (e: Exception) {
Log.e( Log.e(
"$TAG Can't start ACTION_VIEW intent for URL [$url]: $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 message = getString(R.string.conversation_message_deleted_toast)
val icon = R.drawable.trash_simple val icon = R.drawable.trash_simple
(requireActivity() as GenericActivity).showGreenToast(message, icon) (requireActivity() as GenericActivity).showGreenToast(message, icon)
sharedViewModel.forceRefreshConversations.value = Event(true) sharedViewModel.updateConversationLastMessageEvent.value = Event(viewModel.conversationId)
} }
} }
viewModel.itemToScrollTo.observe(viewLifecycleOwner) { position -> viewModel.itemToScrollTo.observe(viewLifecycleOwner) { position ->
if (position >= 0) { if (position >= 0) {
Log.i("$TAG Scrolling to message/event at position [$position]")
val recyclerView = binding.eventsList 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) { messageLongPressViewModel.replyToMessageEvent.observe(viewLifecycleOwner) {
it.consume { it.consume {
val model = messageLongPressViewModel.messageModel.value val model = messageLongPressViewModel.messageModel.value
if (model != null) { if (model != null) {
viewModel.closeSearchBar()
sendMessageViewModel.replyToMessage(model) sendMessageViewModel.replyToMessage(model)
// Open keyboard & focus edit text // Open keyboard & focus edit text
binding.sendArea.messageToSend.showKeyboard() binding.sendArea.messageToSend.showKeyboard()
@ -839,7 +938,13 @@ open class ConversationFragment : SlidingPaneChildFragment() {
it.consume { it.consume {
val model = messageLongPressViewModel.messageModel.value val model = messageLongPressViewModel.messageModel.value
if (model != null) { 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 { it.consume {
val model = messageLongPressViewModel.messageModel.value val model = messageLongPressViewModel.messageModel.value
if (model != null) { if (model != null) {
viewModel.closeSearchBar()
sendMessageViewModel.cancelReply()
// Remove observer before setting the message to forward // Remove observer before setting the message to forward
// as we don't want to forward it in this chat room // as we don't want to forward it in this chat room
sharedViewModel.messageToForwardEvent.removeObservers(viewLifecycleOwner) sharedViewModel.messageToForwardEvent.removeObservers(viewLifecycleOwner)
@ -878,7 +986,7 @@ open class ConversationFragment : SlidingPaneChildFragment() {
Log.i("$TAG Rich content URI [$uri] matching path is [$path]") Log.i("$TAG Rich content URI [$uri] matching path is [$path]")
if (path != null) { if (path != null) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
sendMessageViewModel.addAttachment(path) sendMessageViewModel.addAttachments(arrayListOf(path))
} }
} }
} }
@ -906,14 +1014,14 @@ open class ConversationFragment : SlidingPaneChildFragment() {
if (files.isNotEmpty()) { if (files.isNotEmpty()) {
Log.i("$TAG Found [${files.size}] files to share from intent") Log.i("$TAG Found [${files.size}] files to share from intent")
for (path in files) { for (path in files) {
sendMessageViewModel.addAttachment(path) sendMessageViewModel.addAttachments(arrayListOf(path))
} }
sharedViewModel.filesToShareFromIntent.value = arrayListOf() sharedViewModel.filesToShareFromIntent.value = arrayListOf()
} }
} }
sharedViewModel.forceRefreshConversationInfo.observe(viewLifecycleOwner) { sharedViewModel.forceRefreshConversationInfoEvent.observe(viewLifecycleOwner) {
it.consume { it.consume {
Log.i("$TAG Force refreshing conversation info") Log.i("$TAG Force refreshing conversation info")
viewModel.refresh() viewModel.refresh()
@ -927,7 +1035,7 @@ open class ConversationFragment : SlidingPaneChildFragment() {
} }
} }
sharedViewModel.newChatMessageEphemeralLifetimeToSet.observe(viewLifecycleOwner) { sharedViewModel.newChatMessageEphemeralLifetimeToSetEvent.observe(viewLifecycleOwner) {
it.consume { ephemeralLifetime -> it.consume { ephemeralLifetime ->
Log.i( Log.i(
"$TAG Setting [$ephemeralLifetime] as new ephemeral lifetime for messages" "$TAG Setting [$ephemeralLifetime] as new ephemeral lifetime for messages"
@ -946,7 +1054,7 @@ open class ConversationFragment : SlidingPaneChildFragment() {
binding.sendArea.messageToSend.addTextChangedListener(textObserver) binding.sendArea.messageToSend.addTextChangedListener(textObserver)
scrollListener = object : ConversationScrollListener(layoutManager) { scrollListener = object : RecyclerViewScrollListener(layoutManager, 5, false) {
@UiThread @UiThread
override fun onLoadMore(totalItemsCount: Int) { override fun onLoadMore(totalItemsCount: Int) {
if (viewModel.searchInProgress.value == false) { if (viewModel.searchInProgress.value == false) {
@ -1174,14 +1282,14 @@ open class ConversationFragment : SlidingPaneChildFragment() {
Log.i("$TAG Muting conversation") Log.i("$TAG Muting conversation")
viewModel.mute() viewModel.mute()
popupWindow.dismiss() popupWindow.dismiss()
sharedViewModel.forceRefreshDisplayedConversation.value = Event(true) sharedViewModel.forceRefreshDisplayedConversationEvent.value = Event(true)
} }
popupView.setUnmuteClickListener { popupView.setUnmuteClickListener {
Log.i("$TAG Un-muting conversation") Log.i("$TAG Un-muting conversation")
viewModel.unMute() viewModel.unMute()
popupWindow.dismiss() popupWindow.dismiss()
sharedViewModel.forceRefreshDisplayedConversation.value = Event(true) sharedViewModel.forceRefreshDisplayedConversationEvent.value = Event(true)
} }
popupView.setConfigureEphemeralMessagesClickListener { popupView.setConfigureEphemeralMessagesClickListener {
@ -1244,6 +1352,7 @@ open class ConversationFragment : SlidingPaneChildFragment() {
showDelivery: Boolean = false, showDelivery: Boolean = false,
showReactions: Boolean = false showReactions: Boolean = false
) { ) {
viewModel.closeSearchBar()
binding.sendArea.messageToSend.hideKeyboard() binding.sendArea.messageToSend.hideKeyboard()
backPressedCallback.isEnabled = true backPressedCallback.isEnabled = true
@ -1301,7 +1410,7 @@ open class ConversationFragment : SlidingPaneChildFragment() {
val model = MessageReactionsModel(chatMessageModel.chatMessage) { reactionsModel -> val model = MessageReactionsModel(chatMessageModel.chatMessage) { reactionsModel ->
coreContext.postOnMainThread { coreContext.postOnMainThread {
if (reactionsModel.allReactions.isEmpty) { if (reactionsModel.allReactions.value.orEmpty().isEmpty()) {
Log.i("$TAG No reaction to display, closing bottom sheet") Log.i("$TAG No reaction to display, closing bottom sheet")
val bottomSheetBehavior = BottomSheetBehavior.from( val bottomSheetBehavior = BottomSheetBehavior.from(
binding.messageBottomSheet.root binding.messageBottomSheet.root
@ -1320,54 +1429,64 @@ open class ConversationFragment : SlidingPaneChildFragment() {
private fun displayDeliveryStatuses(model: MessageDeliveryModel) { private fun displayDeliveryStatuses(model: MessageDeliveryModel) {
val tabs = binding.messageBottomSheet.tabs val tabs = binding.messageBottomSheet.tabs
tabs.removeAllTabs() tabs.removeAllTabs()
tabs.addTab(
tabs.newTab().setText(model.readLabel.value).setId( val displayedTab = tabs.newTab().setText(model.readLabel.value).setId(
ChatMessage.State.Displayed.toInt() ChatMessage.State.Displayed.toInt()
)
) )
tabs.addTab( val deliveredTab = tabs.newTab().setText(model.receivedLabel.value).setId(
tabs.newTab().setText( ChatMessage.State.DeliveredToUser.toInt()
model.receivedLabel.value
).setId(
ChatMessage.State.DeliveredToUser.toInt()
)
) )
tabs.addTab( val sentTab = tabs.newTab().setText(model.sentLabel.value).setId(
tabs.newTab().setText(model.sentLabel.value).setId( ChatMessage.State.Delivered.toInt()
ChatMessage.State.Delivered.toInt()
)
) )
tabs.addTab( val errorTab = tabs.newTab().setText(model.errorLabel.value).setId(
tabs.newTab().setText( ChatMessage.State.NotDelivered.toInt()
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 { tabs.setOnTabSelectedListener(object : OnTabSelectedListener {
override fun onTabSelected(tab: TabLayout.Tab?) { override fun onTabSelected(tab: TabLayout.Tab?) {
val state = tab?.id ?: ChatMessage.State.Displayed.toInt() val state = tab?.id ?: ChatMessage.State.Displayed.toInt()
bottomSheetAdapter.submitList( 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 @UiThread
private fun displayReactions(model: MessageReactionsModel) { 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 label = getString(R.string.message_reactions_info_all_title, totalCount.toString())
val tabs = binding.messageBottomSheet.tabs val tabs = binding.messageBottomSheet.tabs
@ -1377,7 +1496,7 @@ open class ConversationFragment : SlidingPaneChildFragment() {
) )
var index = 1 var index = 1
for (reaction in model.differentReactions.value.orEmpty()) { for (reaction in model.differentReactions) {
val count = model.reactionsMap[reaction] val count = model.reactionsMap[reaction]
val tabLabel = getString( val tabLabel = getString(
R.string.message_reactions_info_emoji_title, R.string.message_reactions_info_emoji_title,
@ -1394,7 +1513,7 @@ open class ConversationFragment : SlidingPaneChildFragment() {
override fun onTabSelected(tab: TabLayout.Tab?) { override fun onTabSelected(tab: TabLayout.Tab?) {
val filter = tab?.tag.toString() val filter = tab?.tag.toString()
if (filter.isEmpty()) { if (filter.isEmpty()) {
bottomSheetAdapter.submitList(model.allReactions) bottomSheetAdapter.submitList(model.allReactions.value.orEmpty())
} else { } else {
bottomSheetAdapter.submitList(model.filterReactions(filter)) 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) bottomSheetAdapter.submitList(initialList)
Log.i("$TAG Submitted [${initialList.size}] items for default reactions list") 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 { it.consume {
openFileInAnotherApp(path, mime, bundle) openFileInAnotherApp(path, mime, bundle)
dialog.dismiss() dialog.dismiss()
@ -1511,12 +1630,6 @@ open class ConversationFragment : SlidingPaneChildFragment() {
} }
} }
model.cancelEvent.observe(viewLifecycleOwner) {
it.consume {
dialog.dismiss()
}
}
model.confirmEvent.observe(viewLifecycleOwner) { model.confirmEvent.observe(viewLifecycleOwner) {
it.consume { it.consume {
sharedViewModel.displayFileEvent.value = Event(bundle) sharedViewModel.displayFileEvent.value = Event(bundle)
@ -1537,6 +1650,50 @@ open class ConversationFragment : SlidingPaneChildFragment() {
type = mime type = mime
putExtra(Intent.EXTRA_TITLE, name) 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 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.os.Bundle
import android.view.Gravity import android.view.Gravity
import android.view.LayoutInflater import android.view.LayoutInflater
@ -34,6 +31,7 @@ import androidx.lifecycle.ViewModelProvider
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import org.linphone.LinphoneApplication.Companion.corePreferences
import org.linphone.R import org.linphone.R
import org.linphone.core.tools.Log import org.linphone.core.tools.Log
import org.linphone.databinding.ChatInfoFragmentBinding 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.ui.main.fragment.SlidingPaneChildFragment
import org.linphone.utils.ConfirmationDialogModel import org.linphone.utils.ConfirmationDialogModel
import org.linphone.ui.main.model.GroupSetOrEditSubjectDialogModel import org.linphone.ui.main.model.GroupSetOrEditSubjectDialogModel
import org.linphone.utils.AppUtils
import org.linphone.utils.DialogUtils import org.linphone.utils.DialogUtils
import org.linphone.utils.Event import org.linphone.utils.Event
@ -136,7 +135,7 @@ class ConversationInfoFragment : SlidingPaneChildFragment() {
viewModel.groupLeftEvent.observe(viewLifecycleOwner) { viewModel.groupLeftEvent.observe(viewLifecycleOwner) {
it.consume { it.consume {
Log.i("$TAG Group has been left, leaving conversation info...") Log.i("$TAG Group has been left, leaving conversation info...")
sharedViewModel.forceRefreshConversationInfo.value = Event(true) sharedViewModel.forceRefreshConversationInfoEvent.value = Event(true)
goBack() goBack()
val message = getString(R.string.conversation_group_left_toast) val message = getString(R.string.conversation_group_left_toast)
(requireActivity() as GenericActivity).showGreenToast( (requireActivity() as GenericActivity).showGreenToast(
@ -149,6 +148,7 @@ class ConversationInfoFragment : SlidingPaneChildFragment() {
viewModel.historyDeletedEvent.observe(viewLifecycleOwner) { viewModel.historyDeletedEvent.observe(viewLifecycleOwner) {
it.consume { it.consume {
Log.i("$TAG History has been deleted, leaving conversation info...") Log.i("$TAG History has been deleted, leaving conversation info...")
sharedViewModel.updateConversationLastMessageEvent.value = Event(viewModel.conversationId)
sharedViewModel.forceRefreshConversationEvents.value = Event(true) sharedViewModel.forceRefreshConversationEvents.value = Event(true)
goBack() goBack()
val message = getString(R.string.conversation_info_history_deleted_toast) val message = getString(R.string.conversation_info_history_deleted_toast)
@ -179,7 +179,7 @@ class ConversationInfoFragment : SlidingPaneChildFragment() {
viewModel.infoChangedEvent.observe(viewLifecycleOwner) { viewModel.infoChangedEvent.observe(viewLifecycleOwner) {
it.consume { 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 -> it.consume { ephemeralLifetime ->
Log.i( Log.i(
"$TAG Setting [$ephemeralLifetime] as new ephemeral lifetime for messages" "$TAG Setting [$ephemeralLifetime] as new ephemeral lifetime for messages"
@ -366,6 +366,7 @@ class ConversationInfoFragment : SlidingPaneChildFragment() {
popupView.isMeAdmin = participantModel.isMyselfAdmin popupView.isMeAdmin = participantModel.isMyselfAdmin
val friendRefKey = participantModel.refKey val friendRefKey = participantModel.refKey
popupView.isParticipantContact = participantModel.friendAvailable popupView.isParticipantContact = participantModel.friendAvailable
popupView.disableAddContact = corePreferences.disableAddContact
popupView.setRemoveParticipantClickListener { popupView.setRemoveParticipantClickListener {
Log.i("$TAG Trying to remove participant [$address]") Log.i("$TAG Trying to remove participant [$address]")
@ -425,14 +426,13 @@ class ConversationInfoFragment : SlidingPaneChildFragment() {
popupView.setCopySipUriClickListener { popupView.setCopySipUriClickListener {
val sipUri = participantModel.sipUri val sipUri = participantModel.sipUri
val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager if (AppUtils.copyToClipboard(requireContext(), "SIP address", sipUri)) {
clipboard.setPrimaryClip(ClipData.newPlainText("SIP address", sipUri)) val message = getString(R.string.sip_address_copied_to_clipboard_toast)
(requireActivity() as GenericActivity).showGreenToast(
val message = getString(R.string.sip_address_copied_to_clipboard_toast) message,
(requireActivity() as GenericActivity).showGreenToast( R.drawable.check
message, )
R.drawable.check }
)
} }
// Elevation is for showing a shadow around the popup // Elevation is for showing a shadow around the popup
@ -487,12 +487,9 @@ class ConversationInfoFragment : SlidingPaneChildFragment() {
} }
private fun copyAddressToClipboard(value: String) { private fun copyAddressToClipboard(value: String) {
val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager if (AppUtils.copyToClipboard(requireContext(), "SIP address", value)) {
clipboard.setPrimaryClip(ClipData.newPlainText("SIP address", value)) val message = getString(R.string.sip_address_copied_to_clipboard_toast)
val message = getString(R.string.sip_address_copied_to_clipboard_toast) (requireActivity() as GenericActivity).showGreenToast(message, R.drawable.check)
(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.core.tools.Log
import org.linphone.databinding.ChatMediaFragmentBinding import org.linphone.databinding.ChatMediaFragmentBinding
import org.linphone.ui.GenericActivity 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.adapter.ConversationsFilesAdapter
import org.linphone.ui.main.chat.model.FileModel import org.linphone.ui.main.chat.model.FileModel
import org.linphone.ui.main.chat.viewmodel.ConversationMediaListViewModel import org.linphone.ui.main.chat.viewmodel.ConversationMediaListViewModel
@ -58,6 +59,8 @@ class ConversationMediaListFragment : SlidingPaneChildFragment() {
private val args: ConversationMediaListFragmentArgs by navArgs() private val args: ConversationMediaListFragmentArgs by navArgs()
private lateinit var scrollListener: RecyclerViewScrollListener
override fun goBack(): Boolean { override fun goBack(): Boolean {
try { try {
return findNavController().popBackStack() return findNavController().popBackStack()
@ -103,7 +106,7 @@ class ConversationMediaListFragment : SlidingPaneChildFragment() {
binding.mediaList.addItemDecoration(headerItemDecoration) binding.mediaList.addItemDecoration(headerItemDecoration)
binding.mediaList.setHasFixedSize(true) binding.mediaList.setHasFixedSize(true)
val spanCount = 4 val spanCount = requireContext().resources.getInteger(R.integer.media_columns)
val layoutManager = object : GridLayoutManager(requireContext(), spanCount) { val layoutManager = object : GridLayoutManager(requireContext(), spanCount) {
override fun checkLayoutParams(lp: RecyclerView.LayoutParams): Boolean { override fun checkLayoutParams(lp: RecyclerView.LayoutParams): Boolean {
lp.width = width / spanCount lp.width = width / spanCount
@ -159,6 +162,40 @@ class ConversationMediaListFragment : SlidingPaneChildFragment() {
goToFileViewer(model) 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) { 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.R
import org.linphone.core.tools.Log import org.linphone.core.tools.Log
import org.linphone.databinding.ChatListFragmentBinding import org.linphone.databinding.ChatListFragmentBinding
import org.linphone.ui.GenericActivity
import org.linphone.ui.fileviewer.FileViewerActivity import org.linphone.ui.fileviewer.FileViewerActivity
import org.linphone.ui.fileviewer.MediaViewerActivity import org.linphone.ui.fileviewer.MediaViewerActivity
import org.linphone.ui.main.MainActivity.Companion.ARGUMENTS_CONVERSATION_ID 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.chat.viewmodel.ConversationsListViewModel
import org.linphone.ui.main.fragment.AbstractMainFragment import org.linphone.ui.main.fragment.AbstractMainFragment
import org.linphone.ui.main.history.fragment.HistoryMenuDialogFragment import org.linphone.ui.main.history.fragment.HistoryMenuDialogFragment
import org.linphone.utils.AppUtils
import org.linphone.utils.Event import org.linphone.utils.Event
import org.linphone.utils.LinphoneUtils import org.linphone.utils.LinphoneUtils
@ -251,36 +249,16 @@ class ConversationsListFragment : AbstractMainFragment() {
} }
} }
sharedViewModel.filesToShareFromIntent.observe(viewLifecycleOwner) { filesToShare -> sharedViewModel.updateConversationLastMessageEvent.observe(viewLifecycleOwner) {
val count = filesToShare.size it.consume { conversationId ->
if (count > 0) { val model = listViewModel.conversations.value.orEmpty().find { conversationModel ->
val message = AppUtils.getStringWithPlural( conversationModel.id == conversationId
R.plurals.conversations_files_waiting_to_be_shared_toast, }
count, model?.updateLastMessageInfo()
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.textToShareFromIntent.observe(viewLifecycleOwner) { textToShare -> sharedViewModel.forceRefreshDisplayedConversationEvent.observe(viewLifecycleOwner) {
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) {
it.consume { it.consume {
val displayChatRoom = sharedViewModel.displayedChatRoom val displayChatRoom = sharedViewModel.displayedChatRoom
if (displayChatRoom != null) { if (displayChatRoom != null) {
@ -346,6 +324,11 @@ class ConversationsListFragment : AbstractMainFragment() {
} catch (e: IllegalStateException) { } catch (e: IllegalStateException) {
Log.e("$TAG Failed to unregister data observer to adapter: $e") 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() { override fun onPause() {

View file

@ -24,12 +24,9 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.annotation.UiThread import androidx.annotation.UiThread
import androidx.annotation.WorkerThread
import androidx.core.view.doOnPreDraw import androidx.core.view.doOnPreDraw
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import org.linphone.R import org.linphone.R
import org.linphone.core.Address
import org.linphone.core.Friend
import org.linphone.core.tools.Log import org.linphone.core.tools.Log
import org.linphone.databinding.StartChatFragmentBinding import org.linphone.databinding.StartChatFragmentBinding
import org.linphone.ui.GenericActivity 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) { viewModel.defaultAccountChangedEvent.observe(viewLifecycleOwner) {
it.consume { it.consume {
viewModel.updateGroupChatButtonVisibility() viewModel.updateGroupChatButtonVisibility()
@ -119,11 +106,6 @@ class StartConversationFragment : GenericAddressPickerFragment() {
} }
} }
@WorkerThread
override fun onSingleAddressSelected(address: Address, friend: Friend) {
viewModel.createOneToOneChatRoomWith(address)
}
private fun showGroupConversationSubjectDialog() { private fun showGroupConversationSubjectDialog() {
val model = GroupSetOrEditSubjectDialogModel("", isGroupConversation = true) val model = GroupSetOrEditSubjectDialogModel("", isGroupConversation = true)

View file

@ -20,8 +20,10 @@
package org.linphone.ui.main.chat.model package org.linphone.ui.main.chat.model
import android.text.Spannable import android.text.Spannable
import android.text.SpannableStringBuilder
import androidx.annotation.UiThread import androidx.annotation.UiThread
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.core.text.toSpannable
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.R import org.linphone.R
@ -80,6 +82,8 @@ class ConversationModel
val lastMessageContentIcon = MutableLiveData<Int>() val lastMessageContentIcon = MutableLiveData<Int>()
val composingIcon = MutableLiveData<Int>()
val isLastMessageOutgoing = MutableLiveData<Boolean>() val isLastMessageOutgoing = MutableLiveData<Boolean>()
val dateTime = MutableLiveData<String>() val dateTime = MutableLiveData<String>()
@ -105,6 +109,7 @@ class ConversationModel
} }
} }
@WorkerThread
override fun onConferenceJoined(chatRoom: ChatRoom, eventLog: EventLog) { override fun onConferenceJoined(chatRoom: ChatRoom, eventLog: EventLog) {
// This is required as a Created chat room may not have the participants list yet // This is required as a Created chat room may not have the participants list yet
Log.i("$TAG Conversation has been joined") Log.i("$TAG Conversation has been joined")
@ -114,8 +119,8 @@ class ConversationModel
@WorkerThread @WorkerThread
override fun onConferenceLeft(chatRoom: ChatRoom, eventLog: EventLog) { override fun onConferenceLeft(chatRoom: ChatRoom, eventLog: EventLog) {
Log.w("TAG Conversation has been left") Log.w("$TAG Conversation has been left")
isReadOnly.postValue(true) isReadOnly.postValue(chatRoom.isReadOnly)
} }
@WorkerThread @WorkerThread
@ -127,10 +132,12 @@ class ConversationModel
computeComposingLabel() computeComposingLabel()
} }
@WorkerThread
override fun onNewEvent(chatRoom: ChatRoom, eventLog: EventLog) { override fun onNewEvent(chatRoom: ChatRoom, eventLog: EventLog) {
updateLastUpdatedTime() updateLastUpdatedTime()
} }
@WorkerThread
override fun onNewEvents(chatRoom: ChatRoom, eventLogs: Array<out EventLog>) { override fun onNewEvents(chatRoom: ChatRoom, eventLogs: Array<out EventLog>) {
updateLastMessage() updateLastMessage()
updateLastUpdatedTime() updateLastUpdatedTime()
@ -151,6 +158,7 @@ class ConversationModel
override fun onSubjectChanged(chatRoom: ChatRoom, eventLog: EventLog) { override fun onSubjectChanged(chatRoom: ChatRoom, eventLog: EventLog) {
Log.i("$TAG Conversation subject changed [${chatRoom.subject}]") Log.i("$TAG Conversation subject changed [${chatRoom.subject}]")
subject.postValue(chatRoom.subject) subject.postValue(chatRoom.subject)
computeParticipants()
} }
@WorkerThread @WorkerThread
@ -163,6 +171,23 @@ class ConversationModel
Log.i("$TAG An ephemeral message lifetime has expired, updating last displayed message") Log.i("$TAG An ephemeral message lifetime has expired, updating last displayed message")
updateLastMessage() 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() { private val chatMessageListener = object : ChatMessageListenerStub() {
@ -272,6 +297,13 @@ class ConversationModel
} }
} }
@UiThread
fun updateLastMessageInfo() {
coreContext.postOnCoreThread {
updateLastMessage()
}
}
@WorkerThread @WorkerThread
private fun updateLastMessageStatus(message: ChatMessage) { private fun updateLastMessageStatus(message: ChatMessage) {
val isOutgoing = message.isOutgoing val isOutgoing = message.isOutgoing
@ -295,7 +327,9 @@ class ConversationModel
lastMessageDeliveryIcon.postValue(LinphoneUtils.getChatIconResId(message.state)) 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) lastMessageContentIcon.postValue(R.drawable.forward)
} else { } else {
val firstContent = message.contents.firstOrNull() val firstContent = message.contents.firstOrNull()
@ -331,16 +365,35 @@ class ConversationModel
val message = chatRoom.lastMessageInHistory val message = chatRoom.lastMessageInHistory
if (message != null) { if (message != null) {
lastMessage = message
updateLastMessageStatus(message) updateLastMessageStatus(message)
if (message.isOutgoing && message.state != ChatMessage.State.Displayed) { if (message.isOutgoing && message.state != ChatMessage.State.Displayed) {
message.addListener(chatMessageListener) message.addListener(chatMessageListener)
lastMessage = message } else if (message.contents.find { it.isFileTransfer } != null) {
} else if (message.contents.find { it.isFileTransfer == true } != null) {
message.addListener(chatMessageListener) 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 { } 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]") Log.w("$TAG No last message to display for conversation [$id]")
} }
} }
@ -348,18 +401,6 @@ class ConversationModel
@WorkerThread @WorkerThread
private fun updateLastUpdatedTime() { private fun updateLastUpdatedTime() {
val timestamp = chatRoom.lastUpdateTime 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) lastUpdateTime.postValue(timestamp)
} }
@ -392,16 +433,20 @@ class ConversationModel
} }
if (isGroup) { if (isGroup) {
val fakeFriend = coreContext.core.createFriend() if (avatarModel.value == null || avatarModel.value?.contactName != chatRoom.subject) {
fakeFriend.name = chatRoom.subject val fakeFriend = coreContext.core.createFriend()
val model = ContactAvatarModel(fakeFriend) fakeFriend.name = chatRoom.subject
model.defaultToConversationIcon.postValue(true) val model = ContactAvatarModel(fakeFriend)
model.updateSecurityLevelUsingConversation(chatRoom) model.defaultToConversationIcon.postValue(true)
avatarModel.postValue(model) model.updateSecurityLevelUsingConversation(chatRoom)
avatarModel.postValue(model)
}
} else { } else {
avatarModel.postValue( val model = coreContext.contactsManager.getContactAvatarModelForAddress(address)
coreContext.contactsManager.getContactAvatarModelForAddress(address) val oldModel = avatarModel.value
) if (!model.compare(oldModel)) {
avatarModel.postValue(model)
}
} }
} }
@ -409,30 +454,10 @@ class ConversationModel
private fun computeComposingLabel() { private fun computeComposingLabel() {
val composing = chatRoom.isRemoteComposing val composing = chatRoom.isRemoteComposing
isComposing.postValue(composing) isComposing.postValue(composing)
if (!composing) { val pair = LinphoneUtils.getComposingIconAndText(chatRoom)
composingLabel.postValue("") val icon = pair.first
return composingIcon.postValue(icon)
} val label = pair.second
composingLabel.postValue(label)
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("")
}
} }
} }

View file

@ -20,10 +20,7 @@
package org.linphone.ui.main.chat.model package org.linphone.ui.main.chat.model
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.core.EventLog import org.linphone.core.EventLog
import org.linphone.core.tools.Log
import org.linphone.utils.LinphoneUtils
class EventLogModel class EventLogModel
@WorkerThread @WorkerThread
@ -34,6 +31,7 @@ class EventLogModel
isGroupedWithNextOne: Boolean = false, isGroupedWithNextOne: Boolean = false,
currentFilter: String = "", currentFilter: String = "",
onContentClicked: ((fileModel: FileModel) -> Unit)? = null, onContentClicked: ((fileModel: FileModel) -> Unit)? = null,
onSipUriClicked: ((uri: String) -> Unit)? = null,
onJoinConferenceClicked: ((uri: String) -> Unit)? = null, onJoinConferenceClicked: ((uri: String) -> Unit)? = null,
onWebUrlClicked: ((url: String) -> Unit)? = null, onWebUrlClicked: ((url: String) -> Unit)? = null,
onContactClicked: ((friendRefKey: String) -> Unit)? = null, onContactClicked: ((friendRefKey: String) -> Unit)? = null,
@ -53,39 +51,14 @@ class EventLogModel
EventModel(eventLog) EventModel(eventLog)
} else { } else {
val chatMessage = eventLog.chatMessage!! 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( MessageModel(
chatMessage, chatMessage,
isFromGroup, isFromGroup,
isReply,
replyTo,
replyText,
chatMessage.replyMessageId,
chatMessage.isForward,
isGroupedWithPreviousOne, isGroupedWithPreviousOne,
isGroupedWithNextOne, isGroupedWithNextOne,
currentFilter, currentFilter,
onContentClicked, onContentClicked,
onSipUriClicked,
onJoinConferenceClicked, onJoinConferenceClicked,
onWebUrlClicked, onWebUrlClicked,
onContactClicked, onContactClicked,

View file

@ -116,23 +116,32 @@ class EventModel
EventLog.Type.ConferenceEphemeralMessageLifetimeChanged -> { EventLog.Type.ConferenceEphemeralMessageLifetimeChanged -> {
R.drawable.clock_countdown R.drawable.clock_countdown
} }
EventLog.Type.ConferenceTerminated,
EventLog.Type.ConferenceSecurityEvent -> { EventLog.Type.ConferenceSecurityEvent -> {
R.drawable.warning_circle R.drawable.warning_circle
} }
EventLog.Type.ConferenceSubjectChanged -> { EventLog.Type.ConferenceSubjectChanged -> {
R.drawable.pencil_simple R.drawable.pencil_simple
} }
EventLog.Type.ConferenceCreated,
EventLog.Type.ConferenceParticipantAdded, EventLog.Type.ConferenceParticipantAdded,
EventLog.Type.ConferenceCreated -> {
R.drawable.door_open
}
EventLog.Type.ConferenceParticipantRemoved, EventLog.Type.ConferenceParticipantRemoved,
EventLog.Type.ConferenceParticipantDeviceAdded, EventLog.Type.ConferenceTerminated -> {
EventLog.Type.ConferenceParticipantDeviceRemoved -> {
R.drawable.door R.drawable.door
} }
EventLog.Type.ConferenceParticipantDeviceAdded -> {
R.drawable.user_circle_plus
}
EventLog.Type.ConferenceParticipantDeviceRemoved -> {
R.drawable.user_circle_minus
}
EventLog.Type.ConferenceParticipantSetAdmin -> { EventLog.Type.ConferenceParticipantSetAdmin -> {
R.drawable.user_circle_check R.drawable.user_circle_check
} }
EventLog.Type.ConferenceParticipantUnsetAdmin -> {
R.drawable.user_circle_dashed
}
else -> R.drawable.user_circle else -> R.drawable.user_circle
}, },
coreContext.context.theme coreContext.context.theme

View file

@ -19,9 +19,11 @@
*/ */
package org.linphone.ui.main.chat.model package org.linphone.ui.main.chat.model
import android.graphics.pdf.PdfRenderer
import android.media.MediaMetadataRetriever import android.media.MediaMetadataRetriever
import android.media.MediaMetadataRetriever.METADATA_KEY_DURATION import android.media.MediaMetadataRetriever.METADATA_KEY_DURATION
import android.media.ThumbnailUtils import android.media.ThumbnailUtils
import android.os.ParcelFileDescriptor
import android.provider.MediaStore import android.provider.MediaStore
import androidx.annotation.AnyThread import androidx.annotation.AnyThread
import androidx.annotation.UiThread import androidx.annotation.UiThread
@ -35,6 +37,10 @@ import org.linphone.core.tools.Log
import org.linphone.utils.FileUtils import org.linphone.utils.FileUtils
import org.linphone.utils.TimestampUtils import org.linphone.utils.TimestampUtils
import androidx.core.net.toUri 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 class FileModel
@AnyThread @AnyThread
@ -91,13 +97,15 @@ class FileModel
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
init { init {
mediaPreviewAvailable.postValue(false)
updateTransferProgress(-1) updateTransferProgress(-1)
formattedFileSize.postValue(FileUtils.bytesToDisplayableSize(fileSize)) computeFileSize(fileSize)
if (!isWaitingToBeDownloaded) { if (!isWaitingToBeDownloaded) {
val extension = FileUtils.getExtensionFromFileName(path) val extension = FileUtils.getExtensionFromFileName(path)
isPdf = extension == "pdf" isPdf = extension == "pdf"
if (isPdf) {
loadPdfPreview()
}
val mime = FileUtils.getMimeTypeFromExtension(extension) val mime = FileUtils.getMimeTypeFromExtension(extension)
mimeTypeString = mime mimeTypeString = mime
@ -142,6 +150,11 @@ class FileModel
} }
} }
@AnyThread
fun computeFileSize(fileSize: Long) {
formattedFileSize.postValue(FileUtils.bytesToDisplayableSize(fileSize))
}
@AnyThread @AnyThread
fun updateTransferProgress(percent: Int) { fun updateTransferProgress(percent: Int) {
transferProgress.postValue(percent) transferProgress.postValue(percent)
@ -164,21 +177,67 @@ class FileModel
} }
@AnyThread @AnyThread
private fun loadVideoPreview() { private fun loadPdfPreview() {
try { scope.launch {
Log.i("$TAG Try to create an image preview of video file [$path]") withContext(Dispatchers.IO) {
val previewBitmap = ThumbnailUtils.createVideoThumbnail( try {
path, val pdfFileDescriptor = ParcelFileDescriptor.open(
MediaStore.Images.Thumbnails.MINI_KIND File(path),
) ParcelFileDescriptor.MODE_READ_ONLY
if (previewBitmap != null) { )
val previewPath = FileUtils.storeBitmap(previewBitmap, fileName) if (pdfFileDescriptor == null) {
Log.i("$TAG Preview of video file [$path] available at [$previewPath]") Log.e("$TAG Failed to get a file descriptor for PDF at [$path]")
mediaPreview.postValue(previewPath) return@withContext
mediaPreviewAvailable.postValue(true) }
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 @UiThread
fun toggleShowSipUri() { fun clicked() {
if (!isOurOwnReaction && !corePreferences.onlyDisplaySipUriUsername) { if (!isOurOwnReaction && !corePreferences.onlyDisplaySipUriUsername && !corePreferences.hideSipAddresses) {
showSipUri.postValue(showSipUri.value == false) showSipUri.postValue(showSipUri.value == false)
} else { } 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>() 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() { private val chatMessageListener = object : ChatMessageListenerStub() {
@WorkerThread @WorkerThread
@ -63,7 +63,7 @@ class MessageDeliveryModel
message: ChatMessage, message: ChatMessage,
state: ParticipantImdnState 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() computeDeliveryStatus()
} }
} }
@ -79,7 +79,7 @@ class MessageDeliveryModel
} }
@UiThread @UiThread
fun computeListForState(state: State): ArrayList<MessageBottomSheetParticipantModel> { fun getListForState(state: State): ArrayList<MessageBottomSheetParticipantModel> {
return when (state) { return when (state) {
State.DeliveredToUser -> { State.DeliveredToUser -> {
deliveredModels deliveredModels
@ -98,6 +98,8 @@ class MessageDeliveryModel
@WorkerThread @WorkerThread
private fun computeDeliveryStatus() { private fun computeDeliveryStatus() {
Log.i("$TAG Message ID [${chatMessage.messageId}] is in state [${chatMessage.state}]")
displayedModels.clear() displayedModels.clear()
deliveredModels.clear() deliveredModels.clear()
sentModels.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 } displayedModels.sortBy { it.timestamp }
deliveredModels.sortBy { it.timestamp } deliveredModels.sortBy { it.timestamp }
sentModels.sortBy { it.timestamp } sentModels.sortBy { it.timestamp }
errorModels.sortBy { it.timestamp } errorModels.sortBy { it.timestamp }
Log.i("$TAG Message ID [${chatMessage.messageId}] is in state [${chatMessage.state}]")
Log.i( 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" "$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.Spannable
import android.text.SpannableStringBuilder import android.text.SpannableStringBuilder
import android.text.Spanned import android.text.Spanned
import android.text.style.ForegroundColorSpan
import android.text.style.StyleSpan import android.text.style.StyleSpan
import androidx.annotation.AnyThread
import androidx.annotation.UiThread import androidx.annotation.UiThread
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.lifecycle.MediatorLiveData import androidx.lifecycle.MediatorLiveData
@ -70,15 +72,11 @@ class MessageModel
constructor( constructor(
val chatMessage: ChatMessage, val chatMessage: ChatMessage,
val isFromGroup: Boolean, val isFromGroup: Boolean,
val isReply: Boolean,
val replyTo: String,
val replyText: String,
val replyToMessageId: String?,
val isForward: Boolean,
isGroupedWithPreviousOne: Boolean, isGroupedWithPreviousOne: Boolean,
isGroupedWithNextOne: Boolean, isGroupedWithNextOne: Boolean,
private val currentFilter: String = "", private val currentFilter: String = "",
private val onContentClicked: ((fileModel: FileModel) -> Unit)? = null, private val onContentClicked: ((fileModel: FileModel) -> Unit)? = null,
private val onSipUriClicked: ((uri: String) -> Unit)? = null,
private val onJoinConferenceClicked: ((uri: String) -> Unit)? = null, private val onJoinConferenceClicked: ((uri: String) -> Unit)? = null,
private val onWebUrlClicked: ((url: String) -> Unit)? = null, private val onWebUrlClicked: ((url: String) -> Unit)? = null,
private val onContactClicked: ((friendRefKey: String) -> Unit)? = null, private val onContactClicked: ((friendRefKey: String) -> Unit)? = null,
@ -115,6 +113,16 @@ class MessageModel
)?.params?.instantMessagingEncryptionMandatory == true )?.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 avatarModel = MutableLiveData<ContactAvatarModel>()
val groupedWithNextMessage = MutableLiveData<Boolean>() val groupedWithNextMessage = MutableLiveData<Boolean>()
@ -129,6 +137,8 @@ class MessageModel
val text = MutableLiveData<Spannable>() val text = MutableLiveData<Spannable>()
val isTextEmoji = MutableLiveData<Boolean>()
val reactions = MutableLiveData<String>() val reactions = MutableLiveData<String>()
val ourReactionIndex = MutableLiveData<Int>() val ourReactionIndex = MutableLiveData<Int>()
@ -137,8 +147,14 @@ class MessageModel
val firstFileModel = MediatorLiveData<FileModel>() val firstFileModel = MediatorLiveData<FileModel>()
val hasBeenEdited = MutableLiveData<Boolean>()
val hasBeenRetracted = MutableLiveData<Boolean>()
val isSelected = MutableLiveData<Boolean>() val isSelected = MutableLiveData<Boolean>()
private var rawTextContent: String = ""
// Below are for conferences info // Below are for conferences info
val meetingFound = MutableLiveData<Boolean>() val meetingFound = MutableLiveData<Boolean>()
@ -204,27 +220,13 @@ class MessageModel
private val chatMessageListener = object : ChatMessageListenerStub() { private val chatMessageListener = object : ChatMessageListenerStub() {
@WorkerThread @WorkerThread
override fun onMsgStateChanged(message: ChatMessage, messageState: ChatMessage.State?) { 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) { if (messageState != ChatMessage.State.FileTransferDone && messageState != ChatMessage.State.FileTransferInProgress) {
statusIcon.postValue(LinphoneUtils.getChatIconResId(chatMessage.state)) statusIcon.postValue(LinphoneUtils.getChatIconResId(chatMessage.state))
if (messageState == ChatMessage.State.Displayed) { if (messageState == ChatMessage.State.Displayed) {
isRead = chatMessage.isRead 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) isInError.postValue(messageState == ChatMessage.State.NotDelivered)
} }
@ -232,22 +234,7 @@ class MessageModel
@WorkerThread @WorkerThread
override fun onFileTransferTerminated(message: ChatMessage, content: Content) { override fun onFileTransferTerminated(message: ChatMessage, content: Content) {
Log.i("$TAG File [${content.name}] from message [${message.messageId}] transfer terminated") Log.i("$TAG File [${content.name}] from message [${message.messageId}] transfer terminated")
fileTransferTerminated(message, 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 -> {}
}
}
} }
@WorkerThread @WorkerThread
@ -295,6 +282,21 @@ class MessageModel
Log.d("$TAG Ephemeral timer started") Log.d("$TAG Ephemeral timer started")
updateEphemeralTimer() 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 { init {
@ -312,7 +314,15 @@ class MessageModel
statusIcon.postValue(LinphoneUtils.getChatIconResId(chatMessage.state)) statusIcon.postValue(LinphoneUtils.getChatIconResId(chatMessage.state))
updateReactionsList() updateReactionsList()
hasBeenEdited.postValue(chatMessage.isEdited && !chatMessage.isRetracted)
hasBeenRetracted.postValue(chatMessage.isRetracted)
computeContentsList() computeContentsList()
if (chatMessage.isReply) {
// Wait to see if original message is found before setting isReply to true
computeReplyInfo()
} else {
isReply.postValue(false)
}
coreContext.postOnMainThread { coreContext.postOnMainThread {
firstFileModel.addSource(filesList) { firstFileModel.addSource(filesList) {
@ -401,23 +411,29 @@ class MessageModel
avatarModel.postValue(avatar) avatarModel.postValue(avatar)
} }
@AnyThread
fun getRawTextContent(): String {
return rawTextContent
}
@WorkerThread @WorkerThread
private fun computeContentsList() { private fun computeContentsList() {
Log.d("$TAG Computing message contents list") Log.d("$TAG Computing message contents list")
text.postValue(Spannable.Factory.getInstance().newSpannable("")) 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 displayableContentFound = false
var filesContentCount = 0 var contentIndex = 0
val filesPath = arrayListOf<FileModel>() val filesPath = arrayListOf<FileModel>()
val contents = chatMessage.contents val contents = chatMessage.contents
allFilesDownloaded = true 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 val exactly4Contents = contents.size == 4
for (content in contents) { for (content in contents) {
@ -440,9 +456,14 @@ class MessageModel
displayableContentFound = true displayableContentFound = true
} else { } 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) { if (content.isFile) {
Log.d("$TAG Found file content with type [${content.type}/${content.subtype}]") Log.d("$TAG Found file content with type [${content.type}/${content.subtype}]")
filesContentCount += 1 contentIndex += 1
checkAndRepairFilePathIfNeeded(content) checkAndRepairFilePathIfNeeded(content)
@ -460,9 +481,11 @@ class MessageModel
Log.d( Log.d(
"$TAG Found file ready to be displayed [$path] with MIME [${content.type}/${content.subtype}] for message [${chatMessage.messageId}]" "$TAG Found file ready to be displayed [$path] with MIME [${content.type}/${content.subtype}] for message [${chatMessage.messageId}]"
) )
val fileSize = if (content.fileSize.toLong() > 0) {
val wrapBefore = allContentsAreMedia && exactly4Contents && filesContentCount == 3 content.fileSize.toLong()
val fileSize = content.fileSize.toLong() } else {
FileUtils.getFileSize(path)
}
val timestamp = content.creationTimestamp val timestamp = content.creationTimestamp
val fileModel = FileModel( val fileModel = FileModel(
path, path,
@ -487,20 +510,26 @@ class MessageModel
"$TAG Found file content (not downloaded yet) with type [${content.type}/${content.subtype}] and name [${content.name}]" "$TAG Found file content (not downloaded yet) with type [${content.type}/${content.subtype}] and name [${content.name}]"
) )
allFilesDownloaded = false allFilesDownloaded = false
filesContentCount += 1 contentIndex += 1
val name = content.name ?: "" val name = content.name ?: ""
val timestamp = content.creationTimestamp val timestamp = content.creationTimestamp
if (name.isNotEmpty()) { if (name.isNotEmpty()) {
val fileModel = if (isOutgoing && chatMessage.isFileTransferInProgress) { val fileModel = if (isOutgoing && chatMessage.isFileTransferInProgress) {
val path = content.filePath.orEmpty() val path = content.filePath.orEmpty()
val fileSize = if (content.fileSize.toLong() > 0) {
content.fileSize.toLong()
} else {
FileUtils.getFileSize(path)
}
FileModel( FileModel(
path, path,
name, name,
content.fileSize.toLong(), fileSize,
timestamp, timestamp,
isFileEncrypted, isFileEncrypted,
path, path,
chatMessage.isEphemeral chatMessage.isEphemeral,
flexboxLayoutWrapBefore = wrapBefore
) { model -> ) { model ->
onContentClicked?.invoke(model) onContentClicked?.invoke(model)
} }
@ -513,7 +542,8 @@ class MessageModel
isFileEncrypted, isFileEncrypted,
name, name,
chatMessage.isEphemeral, chatMessage.isEphemeral,
isWaitingToBeDownloaded = true isWaitingToBeDownloaded = true,
flexboxLayoutWrapBefore = wrapBefore
) { model -> ) { model ->
downloadContent(model, content) downloadContent(model, content)
} }
@ -543,7 +573,7 @@ class MessageModel
@WorkerThread @WorkerThread
private fun downloadContent(model: FileModel, content: Content) { 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()) { if (content.filePath.orEmpty().isEmpty()) {
val contentName = content.name val contentName = content.name
@ -617,35 +647,44 @@ class MessageModel
if (textContent != null) { if (textContent != null) {
computeTextContent(textContent, highlight) 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 @WorkerThread
private fun computeTextContent(content: Content, highlight: String) { private fun computeTextContent(content: Content, highlight: String) {
val textContent = content.utf8Text.orEmpty().trim() rawTextContent = content.utf8Text.orEmpty().trim()
val spannableBuilder = SpannableStringBuilder(textContent) val spannableBuilder = SpannableStringBuilder(rawTextContent)
// Check for search val emojiOnly = AppUtils.isTextOnlyContainsEmoji(rawTextContent)
if (highlight.isNotEmpty()) { isTextEmoji.postValue(emojiOnly)
val indexStart = textContent.indexOf(highlight, 0, ignoreCase = true) if (emojiOnly) {
if (indexStart >= 0) { text.postValue(spannableBuilder)
isTextHighlighted = true return
val indexEnd = indexStart + highlight.length
spannableBuilder.setSpan(
StyleSpan(Typeface.BOLD),
indexStart,
indexEnd,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
} }
// Check for mentions // Check for mentions
val chatRoom = chatMessage.chatRoom 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()) { while (matcher.find()) {
val start = matcher.start() val start = matcher.start()
val end = matcher.end() 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]") Log.d("$TAG Found mention [$source]")
// Find address matching username // Find address matching username
@ -667,14 +706,14 @@ class MessageModel
) )
val friend = avatarModel.friend val friend = avatarModel.friend
val displayName = friend.name ?: LinphoneUtils.getDisplayName(address) val displayName = friend.name ?: LinphoneUtils.getDisplayName(address)
Log.d( Log.i(
"$TAG Using display name [$displayName] instead of username [$source]" "$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( val span = PatternClickableSpan.StyledClickableSpan(
object : object : SpannableClickedListener {
SpannableClickedListener { @UiThread
override fun onSpanClicked(text: String) { override fun onSpanClicked(text: String) {
val friendRefKey = friend.refKey ?: "" val friendRefKey = friend.refKey ?: ""
Log.i( Log.i(
@ -688,10 +727,18 @@ class MessageModel
) )
spannableBuilder.setSpan( spannableBuilder.setSpan(
span, span,
start, start + offset,
start + displayName.length + 1, start + offset + displayName.length + 1,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE 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) { override fun onSpanClicked(text: String) {
coreContext.postOnCoreThread { coreContext.postOnCoreThread {
Log.i("$TAG Clicked on SIP URI: $text") Log.i("$TAG Clicked on SIP URI: $text")
val address = coreContext.core.interpretUrl(text, false) onSipUriClicked?.invoke(text)
if (address != null) {
coreContext.startAudioCall(address)
} else {
Log.w("$TAG Failed to parse [$text] as SIP URI")
}
} }
} }
} }
@ -722,6 +764,7 @@ class MessageModel
HTTP_LINK_REGEXP HTTP_LINK_REGEXP
), ),
object : SpannableClickedListener { object : SpannableClickedListener {
@UiThread
override fun onSpanClicked(text: String) { override fun onSpanClicked(text: String) {
Log.i("$TAG Clicked on web URL: $text") Log.i("$TAG Clicked on web URL: $text")
onWebUrlClicked?.invoke(text) onWebUrlClicked?.invoke(text)
@ -730,6 +773,21 @@ class MessageModel
) )
.build(spannableBuilder) .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 @WorkerThread
@ -989,4 +1047,37 @@ class MessageModel
"$TAG Found voice record with path [$voiceRecordPath] and duration [$formattedDuration]" "$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]" 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>() val reactionsMap = HashMap<String, Int>()
@ -71,7 +71,7 @@ class MessageReactionsModel
fun filterReactions(emoji: String): ArrayList<MessageBottomSheetParticipantModel> { fun filterReactions(emoji: String): ArrayList<MessageBottomSheetParticipantModel> {
val filteredList = arrayListOf<MessageBottomSheetParticipantModel>() val filteredList = arrayListOf<MessageBottomSheetParticipantModel>()
for (reaction in allReactions) { for (reaction in allReactions.value.orEmpty()) {
if (reaction.value == emoji) { if (reaction.value == emoji) {
filteredList.add(reaction) filteredList.add(reaction)
} }
@ -83,16 +83,17 @@ class MessageReactionsModel
@WorkerThread @WorkerThread
private fun computeReactions() { private fun computeReactions() {
reactionsMap.clear() reactionsMap.clear()
allReactions.clear() differentReactions.clear()
val differentReactionsList = arrayListOf<String>() val allReactionsList = arrayListOf<MessageBottomSheetParticipantModel>()
for (reaction in chatMessage.reactions) { for (reaction in chatMessage.reactions) {
val body = reaction.body val body = reaction.body
val count = reactionsMap.getOrDefault(body, 0) val count = reactionsMap.getOrDefault(body, 0)
reactionsMap[body] = count + 1 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) val isOurOwn = reaction.fromAddress.weakEqual(chatMessage.chatRoom.localAddress)
allReactions.add( allReactionsList.add(
MessageBottomSheetParticipantModel( MessageBottomSheetParticipantModel(
reaction.fromAddress, reaction.fromAddress,
reaction.body, reaction.body,
@ -111,15 +112,15 @@ class MessageReactionsModel
} }
) )
if (!differentReactionsList.contains(body)) { if (!differentReactions.contains(body)) {
differentReactionsList.add(body) differentReactions.add(body)
} }
} }
Log.i( 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) onReactionsUpdated?.invoke(this)
} }
} }

View file

@ -58,19 +58,14 @@ class ParticipantModel
} }
@UiThread @UiThread
fun toggleShowSipUri() { fun onClicked() {
if (!corePreferences.onlyDisplaySipUriUsername) { if (onClicked == null && !corePreferences.onlyDisplaySipUriUsername && !corePreferences.hideSipAddresses) {
showSipUri.postValue(showSipUri.value == false) showSipUri.postValue(showSipUri.value == false)
} else { } else {
onClicked() onClicked?.invoke(this)
} }
} }
@UiThread
fun onClicked() {
onClicked?.invoke(this)
}
@UiThread @UiThread
fun openMenu(view: View) { fun openMenu(view: View) {
onMenuClicked?.invoke(view, this) onMenuClicked?.invoke(view, this)

View file

@ -28,6 +28,8 @@ import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.R import org.linphone.R
import org.linphone.core.Address import org.linphone.core.Address
import org.linphone.core.ChatRoom import org.linphone.core.ChatRoom
import org.linphone.core.Conference
import org.linphone.core.ConferenceListenerStub
import org.linphone.core.MediaDirection import org.linphone.core.MediaDirection
import org.linphone.core.tools.Log import org.linphone.core.tools.Log
import org.linphone.ui.GenericViewModel import org.linphone.ui.GenericViewModel
@ -51,6 +53,23 @@ abstract class AbstractConversationViewModel : GenericViewModel() {
lateinit var conversationId: String 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 { fun isChatRoomInitialized(): Boolean {
return ::chatRoom.isInitialized return ::chatRoom.isInitialized
} }
@ -173,6 +192,8 @@ abstract class AbstractConversationViewModel : GenericViewModel() {
if (conference.inviteParticipants(participants, callParams) != 0) { if (conference.inviteParticipants(participants, callParams) != 0) {
Log.e("$TAG Failed to invite participants into group call!") Log.e("$TAG Failed to invite participants into group call!")
showRedToast(R.string.conference_failed_to_create_group_call_toast, R.drawable.warning_circle) 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 package org.linphone.ui.main.chat.viewmodel
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.view.View import android.view.View
import androidx.annotation.UiThread import androidx.annotation.UiThread
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
@ -31,6 +28,7 @@ import org.linphone.core.tools.Log
import org.linphone.databinding.ChatBubbleEmojiPickerBottomSheetBinding import org.linphone.databinding.ChatBubbleEmojiPickerBottomSheetBinding
import org.linphone.ui.GenericViewModel import org.linphone.ui.GenericViewModel
import org.linphone.ui.main.chat.model.MessageModel import org.linphone.ui.main.chat.model.MessageModel
import org.linphone.utils.AppUtils
import org.linphone.utils.Event import org.linphone.utils.Event
class ChatMessageLongPressViewModel : GenericViewModel() { class ChatMessageLongPressViewModel : GenericViewModel() {
@ -48,16 +46,26 @@ class ChatMessageLongPressViewModel : GenericViewModel() {
val isChatRoomReadOnly = MutableLiveData<Boolean>() val isChatRoomReadOnly = MutableLiveData<Boolean>()
val canBeEdited = MutableLiveData<Boolean>()
val canBeRemotelyDeleted = MutableLiveData<Boolean>()
val messageModel = MutableLiveData<MessageModel>() val messageModel = MutableLiveData<MessageModel>()
val isMessageOutgoing = MutableLiveData<Boolean>() val isMessageOutgoing = MutableLiveData<Boolean>()
val isMessageInError = MutableLiveData<Boolean>() val isMessageInError = MutableLiveData<Boolean>()
val hasBeenRetracted = MutableLiveData<Boolean>()
val showImdnInfoEvent: MutableLiveData<Event<Boolean>> by lazy { val showImdnInfoEvent: MutableLiveData<Event<Boolean>> by lazy {
MutableLiveData<Event<Boolean>>() MutableLiveData<Event<Boolean>>()
} }
val editMessageEvent: MutableLiveData<Event<Boolean>> by lazy {
MutableLiveData<Event<Boolean>>()
}
val replyToMessageEvent: MutableLiveData<Event<Boolean>> by lazy { val replyToMessageEvent: MutableLiveData<Event<Boolean>> by lazy {
MutableLiveData<Event<Boolean>>() MutableLiveData<Event<Boolean>>()
} }
@ -76,6 +84,8 @@ class ChatMessageLongPressViewModel : GenericViewModel() {
init { init {
visible.value = false visible.value = false
canBeEdited.value = false
canBeRemotelyDeleted.value = false
} }
@UiThread @UiThread
@ -92,6 +102,9 @@ class ChatMessageLongPressViewModel : GenericViewModel() {
isMessageOutgoing.value = model.isOutgoing isMessageOutgoing.value = model.isOutgoing
isMessageInError.value = model.isInError.value == true isMessageInError.value = model.isInError.value == true
horizontalBias.value = if (model.isOutgoing) 1f else 0f 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 messageModel.value = model
emojiBottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN emojiBottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN
@ -125,13 +138,20 @@ class ChatMessageLongPressViewModel : GenericViewModel() {
} }
@UiThread @UiThread
fun copyClickListener() { fun edit() {
Log.i("$TAG Copying message text into clipboard") Log.i("$TAG Editing message")
editMessageEvent.value = Event(true)
dismiss()
}
val text = messageModel.value?.text?.value?.toString() @UiThread
val clipboard = coreContext.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager fun copyClickListener() {
val label = "Message" val text = messageModel.value?.getRawTextContent().orEmpty()
clipboard.setPrimaryClip(ClipData.newPlainText(label, text)) if (text.isNotEmpty()) {
Log.i("$TAG Copying message text into clipboard")
val label = "Message"
AppUtils.copyToClipboard(coreContext.context, label, text)
}
dismiss() dismiss()
} }

View file

@ -22,16 +22,21 @@ package org.linphone.ui.main.chat.viewmodel
import androidx.annotation.UiThread import androidx.annotation.UiThread
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.core.Content
import org.linphone.core.tools.Log import org.linphone.core.tools.Log
import org.linphone.ui.main.chat.model.FileModel import org.linphone.ui.main.chat.model.FileModel
import org.linphone.utils.Event import org.linphone.utils.Event
import org.linphone.utils.LinphoneUtils import org.linphone.utils.LinphoneUtils
import kotlin.math.min
class ConversationDocumentsListViewModel class ConversationDocumentsListViewModel
@UiThread @UiThread
constructor() : AbstractConversationViewModel() { constructor() : AbstractConversationViewModel() {
companion object { companion object {
private const val TAG = "[Conversation Documents List ViewModel]" private const val TAG = "[Conversation Documents List ViewModel]"
private const val CONTENTS_PER_PAGE = 20
} }
val documentsList = MutableLiveData<List<FileModel>>() val documentsList = MutableLiveData<List<FileModel>>()
@ -42,6 +47,8 @@ class ConversationDocumentsListViewModel
MutableLiveData<Event<FileModel>>() MutableLiveData<Event<FileModel>>()
} }
private var totalDocumentsCount: Int = -1
@WorkerThread @WorkerThread
override fun afterNotifyingChatRoomFound(sameOne: Boolean) { override fun afterNotifyingChatRoomFound(sameOne: Boolean) {
loadDocumentsList() loadDocumentsList()
@ -56,16 +63,48 @@ class ConversationDocumentsListViewModel
@WorkerThread @WorkerThread
private fun loadDocumentsList() { private fun loadDocumentsList() {
operationInProgress.postValue(true) operationInProgress.postValue(true)
val list = arrayListOf<FileModel>()
Log.i( Log.i(
"$TAG Loading document contents for conversation [${LinphoneUtils.getConversationId( "$TAG Loading document contents for conversation [${LinphoneUtils.getConversationId(
chatRoom chatRoom
)}]" )}]"
) )
val documents = chatRoom.documentContents
Log.i("$TAG [${documents.size}] documents have been fetched") totalDocumentsCount = chatRoom.documentContentsSize
for (documentContent in documents) { 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 isEncrypted = documentContent.isFileEncrypted
val originalPath = documentContent.filePath.orEmpty() val originalPath = documentContent.filePath.orEmpty()
val path = if (isEncrypted) { val path = if (isEncrypted) {
@ -94,14 +133,11 @@ class ConversationDocumentsListViewModel
val model = val model =
FileModel(path, name, size, timestamp, isEncrypted, originalPath, ephemeral) { FileModel(path, name, size, timestamp, isEncrypted, originalPath, ephemeral) {
openDocumentEvent.postValue(Event(it)) openDocumentEvent.postValue(Event(it))
} }
list.add(model) list.add(model)
} }
} }
return list
Log.i("$TAG [${documents.size}] documents have been processed")
documentsList.postValue(list)
operationInProgress.postValue(false)
} }
} }

View file

@ -30,9 +30,8 @@ import org.linphone.core.Address
import org.linphone.core.ChatRoom import org.linphone.core.ChatRoom
import org.linphone.core.ChatRoomListenerStub import org.linphone.core.ChatRoomListenerStub
import org.linphone.core.Conference import org.linphone.core.Conference
import org.linphone.core.Friend
import org.linphone.core.tools.Log 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.model.ConversationContactOrSuggestionModel
import org.linphone.ui.main.viewmodel.AddressSelectionViewModel import org.linphone.ui.main.viewmodel.AddressSelectionViewModel
import org.linphone.utils.AppUtils import org.linphone.utils.AppUtils
@ -52,31 +51,6 @@ class ConversationForwardMessageViewModel
MutableLiveData<Event<String>>() 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() { private val chatRoomListener = object : ChatRoomListenerStub() {
@WorkerThread @WorkerThread
override fun onStateChanged(chatRoom: ChatRoom, newState: ChatRoom.State?) { override fun onStateChanged(chatRoom: ChatRoom, newState: ChatRoom.State?) {
@ -105,8 +79,8 @@ class ConversationForwardMessageViewModel
} }
@WorkerThread @WorkerThread
private fun onAddressSelected(address: Address) { override fun onSingleAddressSelected(address: Address, friend: Friend?) {
hideNumberOrAddressPickerDialogEvent.postValue(Event(true)) dismissNumberOrAddressPickerDialogEvent.postValue(Event(true))
createOneToOneChatRoomWith(address) createOneToOneChatRoomWith(address)
@ -136,7 +110,7 @@ class ConversationForwardMessageViewModel
val friend = model.friend val friend = model.friend
if (friend == null) { if (friend == null) {
Log.i("$TAG Friend is null, using address [${model.address.asStringUriOnly()}]") Log.i("$TAG Friend is null, using address [${model.address.asStringUriOnly()}]")
onAddressSelected(model.address) onSingleAddressSelected(model.address, null)
return@postOnCoreThread return@postOnCoreThread
} }
@ -145,9 +119,9 @@ class ConversationForwardMessageViewModel
Log.i( Log.i(
"$TAG Only 1 SIP address or phone number found for contact [${friend.name}], using it" "$TAG Only 1 SIP address or phone number found for contact [${friend.name}], using it"
) )
onAddressSelected(singleAvailableAddress) onSingleAddressSelected(singleAvailableAddress, friend)
} else { } else {
val list = friend.getListOfSipAddressesAndPhoneNumbers(listener) val list = friend.getListOfSipAddressesAndPhoneNumbers(numberOrAddressClickListener)
Log.i( Log.i(
"$TAG [${list.size}] numbers or addresses found for contact [${friend.name}], showing selection dialog" "$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 isGroup = MutableLiveData<Boolean>()
val hideSipAddresses = MutableLiveData<Boolean>()
val isEndToEndEncrypted = MutableLiveData<Boolean>() val isEndToEndEncrypted = MutableLiveData<Boolean>()
val subject = MutableLiveData<String>() val subject = MutableLiveData<String>()
@ -80,6 +82,8 @@ class ConversationInfoViewModel
val friendAvailable = MutableLiveData<Boolean>() val friendAvailable = MutableLiveData<Boolean>()
val disableAddContact = MutableLiveData<Boolean>()
val groupLeftEvent: MutableLiveData<Event<Boolean>> by lazy { val groupLeftEvent: MutableLiveData<Event<Boolean>> by lazy {
MutableLiveData<Event<Boolean>>() MutableLiveData<Event<Boolean>>()
} }
@ -108,7 +112,7 @@ class ConversationInfoViewModel
R.string.conversation_info_participant_added_to_conversation_toast, R.string.conversation_info_participant_added_to_conversation_toast,
getParticipant(eventLog) getParticipant(eventLog)
) )
showFormattedGreenToast(message, R.drawable.user_circle) showFormattedGreenToast(message, R.drawable.user_circle_plus)
computeParticipantsList() computeParticipantsList()
infoChangedEvent.postValue(Event(true)) infoChangedEvent.postValue(Event(true))
@ -121,7 +125,7 @@ class ConversationInfoViewModel
R.string.conversation_info_participant_removed_from_conversation_toast, R.string.conversation_info_participant_removed_from_conversation_toast,
getParticipant(eventLog) getParticipant(eventLog)
) )
showFormattedGreenToast(message, R.drawable.user_circle) showFormattedGreenToast(message, R.drawable.user_circle_minus)
computeParticipantsList() computeParticipantsList()
infoChangedEvent.postValue(Event(true)) infoChangedEvent.postValue(Event(true))
@ -132,18 +136,19 @@ class ConversationInfoViewModel
Log.i( Log.i(
"$TAG A participant has been given/removed administration rights for group [${chatRoom.subject}]" "$TAG A participant has been given/removed administration rights for group [${chatRoom.subject}]"
) )
val message = if (eventLog.type == EventLog.Type.ConferenceParticipantSetAdmin) { if (eventLog.type == EventLog.Type.ConferenceParticipantSetAdmin) {
AppUtils.getFormattedString( val message = AppUtils.getFormattedString(
R.string.conversation_info_participant_has_been_granted_admin_rights_toast, R.string.conversation_info_participant_has_been_granted_admin_rights_toast,
getParticipant(eventLog) getParticipant(eventLog)
) )
showFormattedGreenToast(message, R.drawable.user_circle_check)
} else { } else {
AppUtils.getFormattedString( val message = AppUtils.getFormattedString(
R.string.conversation_info_participant_no_longer_has_admin_rights_toast, R.string.conversation_info_participant_no_longer_has_admin_rights_toast,
getParticipant(eventLog) getParticipant(eventLog)
) )
showFormattedGreenToast(message, R.drawable.user_circle_dashed)
} }
showFormattedGreenToast(message, R.drawable.user_circle)
computeParticipantsList() computeParticipantsList()
} }
@ -156,6 +161,7 @@ class ConversationInfoViewModel
showGreenToast(R.string.conversation_subject_changed_toast, R.drawable.check) showGreenToast(R.string.conversation_subject_changed_toast, R.drawable.check)
subject.postValue(chatRoom.subject) subject.postValue(chatRoom.subject)
computeParticipantsList()
infoChangedEvent.postValue(Event(true)) infoChangedEvent.postValue(Event(true))
} }
@ -190,8 +196,10 @@ class ConversationInfoViewModel
init { init {
expandParticipants.value = true expandParticipants.value = true
showPeerSipUri.value = false showPeerSipUri.value = false
disableAddContact.value = corePreferences.disableAddContact
coreContext.postOnCoreThread { coreContext.postOnCoreThread {
hideSipAddresses.postValue(corePreferences.hideSipAddresses)
coreContext.contactsManager.addListener(contactsListener) coreContext.contactsManager.addListener(contactsListener)
} }
} }
@ -558,7 +566,9 @@ class ConversationInfoViewModel
} else { } else {
participantsList.first().avatarModel participantsList.first().avatarModel
} }
avatarModel.postValue(avatar) if (!avatar.compare(avatarModel.value)) {
avatarModel.postValue(avatar)
}
participants.postValue(participantsList) participants.postValue(participantsList)
participantsLabel.postValue( participantsLabel.postValue(

View file

@ -22,16 +22,21 @@ package org.linphone.ui.main.chat.viewmodel
import androidx.annotation.UiThread import androidx.annotation.UiThread
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.core.Content
import org.linphone.core.tools.Log import org.linphone.core.tools.Log
import org.linphone.ui.main.chat.model.FileModel import org.linphone.ui.main.chat.model.FileModel
import org.linphone.utils.Event import org.linphone.utils.Event
import org.linphone.utils.LinphoneUtils import org.linphone.utils.LinphoneUtils
import kotlin.math.min
class ConversationMediaListViewModel class ConversationMediaListViewModel
@UiThread @UiThread
constructor() : AbstractConversationViewModel() { constructor() : AbstractConversationViewModel() {
companion object { companion object {
private const val TAG = "[Conversation Media List ViewModel]" private const val TAG = "[Conversation Media List ViewModel]"
private const val CONTENTS_PER_PAGE = 50
} }
val mediaList = MutableLiveData<List<FileModel>>() val mediaList = MutableLiveData<List<FileModel>>()
@ -42,6 +47,8 @@ class ConversationMediaListViewModel
MutableLiveData<Event<FileModel>>() MutableLiveData<Event<FileModel>>()
} }
private var totalMediaCount: Int = -1
@WorkerThread @WorkerThread
override fun afterNotifyingChatRoomFound(sameOne: Boolean) { override fun afterNotifyingChatRoomFound(sameOne: Boolean) {
loadMediaList() loadMediaList()
@ -56,16 +63,48 @@ class ConversationMediaListViewModel
@WorkerThread @WorkerThread
private fun loadMediaList() { private fun loadMediaList() {
operationInProgress.postValue(true) operationInProgress.postValue(true)
val list = arrayListOf<FileModel>()
Log.i( Log.i(
"$TAG Loading media contents for conversation [${LinphoneUtils.getConversationId( "$TAG Loading media contents for conversation [${LinphoneUtils.getConversationId(
chatRoom chatRoom
)}]" )}]"
) )
val media = chatRoom.mediaContents
Log.i("$TAG [${media.size}] media have been fetched") totalMediaCount = chatRoom.mediaContentsSize
for (mediaContent in media) { 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 // Do not display voice recordings here, even if they are media file
if (mediaContent.isVoiceRecording) continue if (mediaContent.isVoiceRecording) continue
@ -85,14 +124,11 @@ class ConversationMediaListViewModel
if (path.isNotEmpty() && name.isNotEmpty()) { if (path.isNotEmpty() && name.isNotEmpty()) {
val model = val model =
FileModel(path, name, size, timestamp, isEncrypted, originalPath, chatRoom.isEphemeralEnabled) { FileModel(path, name, size, timestamp, isEncrypted, originalPath, chatRoom.isEphemeralEnabled) {
openMediaEvent.postValue(Event(it)) openMediaEvent.postValue(Event(it))
} }
list.add(model) list.add(model)
} }
} }
return list
Log.i("$TAG [${media.size}] media have been processed")
mediaList.postValue(list)
operationInProgress.postValue(false)
} }
} }

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.FileModel
import org.linphone.ui.main.chat.model.MessageModel import org.linphone.ui.main.chat.model.MessageModel
import org.linphone.ui.main.contacts.model.ContactAvatarModel import org.linphone.ui.main.contacts.model.ContactAvatarModel
import org.linphone.utils.AppUtils
import org.linphone.utils.Event import org.linphone.utils.Event
import org.linphone.utils.FileUtils import org.linphone.utils.FileUtils
import org.linphone.utils.LinphoneUtils import org.linphone.utils.LinphoneUtils
@ -89,6 +88,8 @@ class ConversationViewModel
val composingLabel = MutableLiveData<String>() val composingLabel = MutableLiveData<String>()
val composingIcon = MutableLiveData<Int>()
val searchBarVisible = MutableLiveData<Boolean>() val searchBarVisible = MutableLiveData<Boolean>()
val searchFilter = MutableLiveData<String>() val searchFilter = MutableLiveData<String>()
@ -97,6 +98,8 @@ class ConversationViewModel
val canSearchDown = MutableLiveData<Boolean>() val canSearchDown = MutableLiveData<Boolean>()
val canSearchUp = MutableLiveData<Boolean>()
val itemToScrollTo = MutableLiveData<Int>() val itemToScrollTo = MutableLiveData<Int>()
val isUserScrollingUp = MutableLiveData<Boolean>() val isUserScrollingUp = MutableLiveData<Boolean>()
@ -111,6 +114,10 @@ class ConversationViewModel
MutableLiveData<Event<FileModel>>() MutableLiveData<Event<FileModel>>()
} }
val sipUriToCallEvent: MutableLiveData<Event<String>> by lazy {
MutableLiveData<Event<String>>()
}
val conferenceToJoinEvent: MutableLiveData<Event<String>> by lazy { val conferenceToJoinEvent: MutableLiveData<Event<String>> by lazy {
MutableLiveData<Event<String>>() MutableLiveData<Event<String>>()
} }
@ -145,11 +152,15 @@ class ConversationViewModel
private var latestMatch: EventLog? = null private var latestMatch: EventLog? = null
private var latestMatchModel: MessageModel? = null
private val chatRoomListener = object : ChatRoomListenerStub() { private val chatRoomListener = object : ChatRoomListenerStub() {
@WorkerThread @WorkerThread
override fun onConferenceJoined(chatRoom: ChatRoom, eventLog: EventLog) { override fun onConferenceJoined(chatRoom: ChatRoom, eventLog: EventLog) {
Log.i("$TAG Conversation was joined") Log.i("$TAG Conversation was joined")
addEvents(arrayOf(eventLog)) if (LinphoneUtils.isChatRoomAGroup(chatRoom)) {
addEvents(arrayOf(eventLog))
}
computeConversationInfo() computeConversationInfo()
val messageToForward = pendingForwardMessage val messageToForward = pendingForwardMessage
@ -163,8 +174,10 @@ class ConversationViewModel
@WorkerThread @WorkerThread
override fun onConferenceLeft(chatRoom: ChatRoom, eventLog: EventLog) { override fun onConferenceLeft(chatRoom: ChatRoom, eventLog: EventLog) {
Log.w("$TAG Conversation was left") Log.w("$TAG Conversation was left")
addEvents(arrayOf(eventLog)) if (LinphoneUtils.isChatRoomAGroup(chatRoom)) {
isReadOnly.postValue(true) addEvents(arrayOf(eventLog))
}
isReadOnly.postValue(chatRoom.isReadOnly)
} }
@WorkerThread @WorkerThread
@ -185,17 +198,6 @@ class ConversationViewModel
Log.i("$TAG Conversation was marked as read") 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 @WorkerThread
override fun onChatMessagesReceived(chatRoom: ChatRoom, eventLogs: Array<EventLog>) { override fun onChatMessagesReceived(chatRoom: ChatRoom, eventLogs: Array<EventLog>) {
Log.i("$TAG Received [${eventLogs.size}] new message(s)") 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") 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 { private val contactsListener = object : ContactsManager.ContactsListener {
@ -326,6 +344,7 @@ class ConversationViewModel
isDisabledBecauseNotSecured.value = false isDisabledBecauseNotSecured.value = false
searchInProgress.value = false searchInProgress.value = false
canSearchDown.value = false canSearchDown.value = false
canSearchUp.value = false
itemToScrollTo.value = -1 itemToScrollTo.value = -1
} }
@ -357,38 +376,41 @@ class ConversationViewModel
@UiThread @UiThread
fun openSearchBar() { fun openSearchBar() {
canSearchUp.value = true
searchBarVisible.value = true searchBarVisible.value = true
focusSearchBarEvent.value = Event(true) focusSearchBarEvent.value = Event(true)
} }
@UiThread @UiThread
fun closeSearchBar() { fun closeSearchBar() {
coreContext.postOnCoreThread {
latestMatchModel?.highlightText("")
latestMatchModel = null
}
searchFilter.value = "" searchFilter.value = ""
searchBarVisible.value = false searchBarVisible.value = false
focusSearchBarEvent.value = Event(false) focusSearchBarEvent.value = Event(false)
latestMatch = null latestMatch = null
canSearchDown.value = false canSearchDown.value = false
canSearchUp.value = false
}
coreContext.postOnCoreThread { @UiThread
for (eventLog in eventsList) { fun searchUp() {
if ((eventLog.model as? MessageModel)?.isTextHighlighted == true) { if (canSearchUp.value == true) {
eventLog.model.highlightText("") coreContext.postOnCoreThread {
} searchChatMessage(SearchDirection.Up)
} }
} }
} }
@UiThread
fun searchUp() {
coreContext.postOnCoreThread {
searchChatMessage(SearchDirection.Up)
}
}
@UiThread @UiThread
fun searchDown() { fun searchDown() {
coreContext.postOnCoreThread { if (canSearchDown.value == true) {
searchChatMessage(SearchDirection.Down) coreContext.postOnCoreThread {
searchChatMessage(SearchDirection.Down)
}
} }
} }
@ -402,6 +424,7 @@ class ConversationViewModel
@UiThread @UiThread
fun updateUnreadMessageCount() { fun updateUnreadMessageCount() {
if (!isChatRoomInitialized()) return
coreContext.postOnCoreThread { coreContext.postOnCoreThread {
unreadMessagesCount.postValue(chatRoom.unreadMessagesCount) unreadMessagesCount.postValue(chatRoom.unreadMessagesCount)
} }
@ -430,7 +453,9 @@ class ConversationViewModel
Log.i("$TAG Removing chat message id [${chatMessageModel.id}] from events list") Log.i("$TAG Removing chat message id [${chatMessageModel.id}] from events list")
list.remove(found) list.remove(found)
eventsList = list eventsList = list
updateEvents.postValue(Event(true)) updateEvents.postValue(Event(true))
isEmpty.postValue(eventsList.isEmpty()) isEmpty.postValue(eventsList.isEmpty())
} else { } else {
@ -442,11 +467,23 @@ class ConversationViewModel
Log.i("$TAG Deleting message id [${chatMessageModel.id}] from database") Log.i("$TAG Deleting message id [${chatMessageModel.id}] from database")
chatRoom.deleteMessage(chatMessageModel.chatMessage) chatRoom.deleteMessage(chatMessageModel.chatMessage)
messageDeletedEvent.postValue(Event(true)) 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 @UiThread
fun markAsRead() { fun markAsRead() {
if (!isChatRoomInitialized()) return
coreContext.postOnCoreThread { coreContext.postOnCoreThread {
if (chatRoom.unreadMessagesCount == 0) return@postOnCoreThread if (chatRoom.unreadMessagesCount == 0) return@postOnCoreThread
Log.i("$TAG Marking chat room as read") Log.i("$TAG Marking chat room as read")
@ -456,6 +493,7 @@ class ConversationViewModel
@UiThread @UiThread
fun mute() { fun mute() {
if (!isChatRoomInitialized()) return
coreContext.postOnCoreThread { coreContext.postOnCoreThread {
chatRoom.muted = true chatRoom.muted = true
isMuted.postValue(chatRoom.muted) isMuted.postValue(chatRoom.muted)
@ -464,6 +502,7 @@ class ConversationViewModel
@UiThread @UiThread
fun unMute() { fun unMute() {
if (!isChatRoomInitialized()) return
coreContext.postOnCoreThread { coreContext.postOnCoreThread {
chatRoom.muted = false chatRoom.muted = false
isMuted.postValue(chatRoom.muted) isMuted.postValue(chatRoom.muted)
@ -487,6 +526,7 @@ class ConversationViewModel
@UiThread @UiThread
fun updateEphemeralLifetime(lifetime: Long) { fun updateEphemeralLifetime(lifetime: Long) {
if (!isChatRoomInitialized()) return
coreContext.postOnCoreThread { coreContext.postOnCoreThread {
LinphoneUtils.chatRoomConfigureEphemeralMessagesLifetime(chatRoom, lifetime) LinphoneUtils.chatRoomConfigureEphemeralMessagesLifetime(chatRoom, lifetime)
ephemeralLifetime.postValue( ephemeralLifetime.postValue(
@ -500,6 +540,7 @@ class ConversationViewModel
@UiThread @UiThread
fun loadMoreData(totalItemsCount: Int) { fun loadMoreData(totalItemsCount: Int) {
if (!isChatRoomInitialized()) return
coreContext.postOnCoreThread { coreContext.postOnCoreThread {
val maxSize: Int = chatRoom.historyEventsSize val maxSize: Int = chatRoom.historyEventsSize
Log.i("$TAG Loading more data, current total is $totalItemsCount, max size is $maxSize") Log.i("$TAG Loading more data, current total is $totalItemsCount, max size is $maxSize")
@ -535,6 +576,7 @@ class ConversationViewModel
@WorkerThread @WorkerThread
fun checkIfConversationShouldBeDisabledForSecurityReasons() { fun checkIfConversationShouldBeDisabledForSecurityReasons() {
if (!isChatRoomInitialized()) return
if (!chatRoom.hasCapability(ChatRoom.Capabilities.Encrypted.toInt())) { if (!chatRoom.hasCapability(ChatRoom.Capabilities.Encrypted.toInt())) {
if (LinphoneUtils.getAccountForAddress(chatRoom.localAddress)?.params?.instantMessagingEncryptionMandatory == true) { if (LinphoneUtils.getAccountForAddress(chatRoom.localAddress)?.params?.instantMessagingEncryptionMandatory == true) {
Log.w( 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 @WorkerThread
private fun configureChatRoom() { private fun configureChatRoom() {
if (!isChatRoomInitialized()) return
computeComposingLabel() computeComposingLabel()
isEndToEndEncrypted.postValue( isEndToEndEncrypted.postValue(
@ -585,6 +642,8 @@ class ConversationViewModel
@WorkerThread @WorkerThread
private fun computeConversationInfo() { private fun computeConversationInfo() {
if (!isChatRoomInitialized()) return
val group = LinphoneUtils.isChatRoomAGroup(chatRoom) val group = LinphoneUtils.isChatRoomAGroup(chatRoom)
isGroup.postValue(group) isGroup.postValue(group)
@ -615,6 +674,8 @@ class ConversationViewModel
@WorkerThread @WorkerThread
private fun computeParticipantsInfo() { private fun computeParticipantsInfo() {
if (!isChatRoomInitialized()) return
val friends = arrayListOf<Friend>() val friends = arrayListOf<Friend>()
val address = if (chatRoom.hasCapability(ChatRoom.Capabilities.Basic.toInt())) { val address = if (chatRoom.hasCapability(ChatRoom.Capabilities.Basic.toInt())) {
chatRoom.peerAddress chatRoom.peerAddress
@ -644,6 +705,8 @@ class ConversationViewModel
@WorkerThread @WorkerThread
private fun computeEvents() { private fun computeEvents() {
if (!isChatRoomInitialized()) return
eventsList.forEach(EventLogModel::destroy) eventsList.forEach(EventLogModel::destroy)
val history = chatRoom.getHistoryEvents(MESSAGES_PER_PAGE) val history = chatRoom.getHistoryEvents(MESSAGES_PER_PAGE)
@ -656,7 +719,7 @@ class ConversationViewModel
@WorkerThread @WorkerThread
private fun addEvents(eventLogs: Array<EventLog>) { 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... // Need to use a new list, otherwise ConversationFragment's dataObserver isn't triggered...
val list = arrayListOf<EventLogModel>() val list = arrayListOf<EventLogModel>()
list.addAll(eventsList) list.addAll(eventsList)
@ -748,25 +811,28 @@ class ConversationViewModel
index > 0, index > 0,
index != groupedEventLogs.size - 1, index != groupedEventLogs.size - 1,
searchFilter.value.orEmpty(), searchFilter.value.orEmpty(),
{ fileModel -> { fileModel -> // onContentClicked
fileToDisplayEvent.postValue(Event(fileModel)) fileToDisplayEvent.postValue(Event(fileModel))
}, },
{ conferenceUri -> { sipUri -> // onSipUriClicked
sipUriToCallEvent.postValue(Event(sipUri))
},
{ conferenceUri -> // onJoinConferenceClicked
conferenceToJoinEvent.postValue(Event(conferenceUri)) conferenceToJoinEvent.postValue(Event(conferenceUri))
}, },
{ url -> { url -> // onWebUrlClicked
openWebBrowserEvent.postValue(Event(url)) openWebBrowserEvent.postValue(Event(url))
}, },
{ friendRefKey -> { friendRefKey -> // onContactClicked
contactToDisplayEvent.postValue(Event(friendRefKey)) contactToDisplayEvent.postValue(Event(friendRefKey))
}, },
{ redToast -> { redToast -> // onRedToastToShow
showRedToastEvent.postValue(Event(redToast)) showRedToastEvent.postValue(Event(redToast))
}, },
{ id -> { id -> // onVoiceRecordingPlaybackEnded
voiceRecordPlaybackEndedEvent.postValue(Event(id)) voiceRecordPlaybackEndedEvent.postValue(Event(id))
}, },
{ filePath -> { filePath -> // onFileToExportToNativeGallery
viewModelScope.launch { viewModelScope.launch {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
Log.i("$TAG Export file [$filePath] to Android's MediaStore") Log.i("$TAG Export file [$filePath] to Android's MediaStore")
@ -878,29 +944,31 @@ class ConversationViewModel
} }
@WorkerThread @WorkerThread
private fun computeComposingLabel() { private fun updateRepliesUpTo(chatMessage: ChatMessage) {
val composingFriends = arrayListOf<String>() for (model in eventsList.reversed()) {
var label = "" if (model.model is MessageModel) {
for (address in chatRoom.composingAddresses) { if (model.model.replyToMessageId == chatMessage.messageId) {
val avatar = coreContext.contactsManager.getContactAvatarModelForAddress(address) model.model.computeReplyInfo()
val name = avatar.name.value ?: LinphoneUtils.getDisplayName(address) }
composingFriends.add(name)
label += "$name, "
}
if (composingFriends.isNotEmpty()) {
label = label.dropLast(2)
val format = AppUtils.getStringWithPlural( if (model.model.timestamp < chatMessage.time) {
R.plurals.conversation_composing_label, break
composingFriends.size, }
label }
)
composingLabel.postValue(format)
} else {
composingLabel.postValue("")
} }
} }
@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 @WorkerThread
private fun loadMessagesUpTo(targetEvent: EventLog) { private fun loadMessagesUpTo(targetEvent: EventLog) {
val mask = HistoryFilter.ChatMessage.toInt() or HistoryFilter.InfoNoDevice.toInt() val mask = HistoryFilter.ChatMessage.toInt() or HistoryFilter.InfoNoDevice.toInt()
@ -928,6 +996,7 @@ class ConversationViewModel
@WorkerThread @WorkerThread
private fun searchChatMessage(direction: SearchDirection) { private fun searchChatMessage(direction: SearchDirection) {
if (!isChatRoomInitialized()) return
searchInProgress.postValue(true) searchInProgress.postValue(true)
val textToSearch = searchFilter.value.orEmpty().trim() val textToSearch = searchFilter.value.orEmpty().trim()
@ -940,15 +1009,34 @@ class ConversationViewModel
val message = if (latestMatch == null) { val message = if (latestMatch == null) {
R.string.conversation_search_no_match_found R.string.conversation_search_no_match_found
} else { } 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 R.string.conversation_search_no_more_match
} }
showRedToast(message, R.drawable.magnifying_glass) showRedToast(message, R.drawable.magnifying_glass)
} else { } else {
canSearchDown.postValue(true)
canSearchUp.postValue(true)
// Clear highlight from previous match
latestMatchModel?.highlightText("")
Log.i( 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}]" "$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 latestMatch = match
val found = eventsList.find { val found = eventsList.find {
it.eventLog == match it.eventLog == match
} }
@ -957,19 +1045,12 @@ class ConversationViewModel
loadMessagesUpTo(match) loadMessagesUpTo(match)
} else { } else {
Log.i("$TAG Found result is already in history, no need to load more history") 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) val index = eventsList.indexOf(found)
if (direction == SearchDirection.Down && index < eventsList.size - 1) { itemToScrollTo.postValue(index)
// 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)
}
searchInProgress.postValue(false) searchInProgress.postValue(false)
} }
canSearchDown.postValue(true)
} }
} }

View file

@ -174,16 +174,12 @@ class ConversationsListViewModel
@WorkerThread @WorkerThread
private fun addChatRoom(chatRoom: ChatRoom) { private fun addChatRoom(chatRoom: ChatRoom) {
val localAddress = chatRoom.localAddress val identifier = chatRoom.identifier
val peerAddress = chatRoom.peerAddress val chatRoomAccount = chatRoom.account
val defaultAccount = LinphoneUtils.getDefaultAccount() val defaultAccount = LinphoneUtils.getDefaultAccount()
if (defaultAccount == null || if (defaultAccount == null || chatRoomAccount == null || chatRoomAccount != defaultAccount) {
defaultAccount.params.identityAddress?.weakEqual(localAddress) == false
)
{
Log.w( 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 return
} }
@ -191,16 +187,16 @@ class ConversationsListViewModel
val hideEmptyChatRooms = coreContext.core.config.getBool("misc", "hide_empty_chat_rooms", true) val hideEmptyChatRooms = coreContext.core.config.getBool("misc", "hide_empty_chat_rooms", true)
// Hide empty chat rooms only applies to 1-1 conversations // Hide empty chat rooms only applies to 1-1 conversations
if (hideEmptyChatRooms && !LinphoneUtils.isChatRoomAGroup(chatRoom) && chatRoom.lastMessageInHistory == null) { 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 return
} }
val currentList = conversations.value.orEmpty() val currentList = conversations.value.orEmpty()
val found = currentList.find { val found = currentList.find {
it.chatRoom.peerAddress.weakEqual(peerAddress) it.chatRoom.identifier == identifier
} }
if (found != null) { 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 return
} }
@ -216,27 +212,27 @@ class ConversationsListViewModel
val model = ConversationModel(chatRoom) val model = ConversationModel(chatRoom)
newList.add(model) newList.add(model)
newList.addAll(currentList) 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) conversations.postValue(newList)
} }
@WorkerThread @WorkerThread
private fun removeChatRoom(chatRoom: ChatRoom) { private fun removeChatRoom(chatRoom: ChatRoom) {
val currentList = conversations.value.orEmpty() val currentList = conversations.value.orEmpty()
val peerAddress = chatRoom.peerAddress val identifier = chatRoom.identifier
val found = currentList.find { val found = currentList.find {
it.chatRoom.peerAddress.weakEqual(peerAddress) it.chatRoom.identifier == identifier
} }
if (found != null) { if (found != null) {
val newList = arrayListOf<ConversationModel>() val newList = arrayListOf<ConversationModel>()
newList.addAll(currentList) newList.addAll(currentList)
newList.remove(found) newList.remove(found)
found.destroy() 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) conversations.postValue(newList)
} else { } else {
Log.w( 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 attachments = MutableLiveData<ArrayList<FileModel>>()
val isEditing = MutableLiveData<Boolean>()
val isEditingMessage = MutableLiveData<Spannable>()
val isReplying = MutableLiveData<Boolean>() val isReplying = MutableLiveData<Boolean>()
val isReplyingTo = MutableLiveData<String>() val isReplyingTo = MutableLiveData<String>()
@ -106,6 +110,8 @@ class SendMessageInConversationViewModel
val voiceRecordPlayerPosition = MutableLiveData<Int>() val voiceRecordPlayerPosition = MutableLiveData<Int>()
val isComputingParticipantsList = MutableLiveData<Boolean>()
private lateinit var voiceRecordPlayer: Player private lateinit var voiceRecordPlayer: Player
private val playerListener = PlayerListener { private val playerListener = PlayerListener {
@ -129,14 +135,22 @@ class SendMessageInConversationViewModel
MutableLiveData<Event<Boolean>>() MutableLiveData<Event<Boolean>>()
} }
val messageSentEvent: MutableLiveData<Event<ChatMessage>> by lazy {
MutableLiveData<Event<ChatMessage>>()
}
lateinit var chatRoom: ChatRoom lateinit var chatRoom: ChatRoom
private var chatMessageToReplyTo: ChatMessage? = null private var chatMessageToReplyTo: ChatMessage? = null
private var chatMessageToEdit: ChatMessage? = null
private lateinit var voiceMessageRecorder: Recorder private lateinit var voiceMessageRecorder: Recorder
private var voiceRecordAudioFocusRequest: AudioFocusRequestCompat? = null private var voiceRecordAudioFocusRequest: AudioFocusRequestCompat? = null
private var participantsListFilter = ""
private val chatRoomListener = object : ChatRoomListenerStub() { private val chatRoomListener = object : ChatRoomListenerStub() {
@WorkerThread @WorkerThread
override fun onParticipantAdded(chatRoom: ChatRoom, eventLog: EventLog) { override fun onParticipantAdded(chatRoom: ChatRoom, eventLog: EventLog) {
@ -157,6 +171,7 @@ class SendMessageInConversationViewModel
isKeyboardOpen.value = false isKeyboardOpen.value = false
isEmojiPickerOpen.value = false isEmojiPickerOpen.value = false
areFilePickersOpen.value = false areFilePickersOpen.value = false
isParticipantsListOpen.value = false
isVoiceRecording.value = false isVoiceRecording.value = false
isPlayingVoiceRecord.value = false isPlayingVoiceRecord.value = false
isCallConversation.value = false isCallConversation.value = false
@ -228,8 +243,41 @@ class SendMessageInConversationViewModel
areFilePickersOpen.value = false 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 @UiThread
fun replyToMessage(model: MessageModel) { fun replyToMessage(model: MessageModel) {
if (isEditing.value == true) {
cancelEdit()
}
coreContext.postOnCoreThread { coreContext.postOnCoreThread {
val message = model.chatMessage val message = model.chatMessage
Log.i("$TAG Pending reply to message [${message.messageId}]") 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 isBasicChatRoom: Boolean = chatRoom.hasCapability(ChatRoom.Capabilities.Basic.toInt())
val messageToReplyTo = chatMessageToReplyTo val messageToReplyTo = chatMessageToReplyTo
val messageToEdit = chatMessageToEdit
val message = if (messageToReplyTo != null) { val message = if (messageToReplyTo != null) {
Log.i("$TAG Sending message as reply to [${messageToReplyTo.messageId}]") Log.i("$TAG Sending message as reply to [${messageToReplyTo.messageId}]")
chatRoom.createReplyMessage(messageToReplyTo) chatRoom.createReplyMessage(messageToReplyTo)
} else if (messageToEdit != null) {
chatRoom.createReplacesMessage(messageToEdit)
} else { } else {
chatRoom.createEmptyMessage() chatRoom.createEmptyMessage()
} }
@ -278,9 +329,9 @@ class SendMessageInConversationViewModel
val voiceMessage = chatRoom.createEmptyMessage() val voiceMessage = chatRoom.createEmptyMessage()
voiceMessage.addContent(content) voiceMessage.addContent(content)
voiceMessage.send() voiceMessage.send()
messageSentEvent.postValue(Event(voiceMessage))
} else { } else {
message.addContent(content) message.addContent(content)
contentAdded = true
} }
} else { } else {
Log.e("$TAG Voice recording content couldn't be created!") Log.e("$TAG Voice recording content couldn't be created!")
@ -310,6 +361,7 @@ class SendMessageInConversationViewModel
val fileMessage = chatRoom.createEmptyMessage() val fileMessage = chatRoom.createEmptyMessage()
fileMessage.addFileContent(content) fileMessage.addFileContent(content)
fileMessage.send() fileMessage.send()
messageSentEvent.postValue(Event(fileMessage))
} else { } else {
message.addFileContent(content) message.addFileContent(content)
contentAdded = true contentAdded = true
@ -320,11 +372,13 @@ class SendMessageInConversationViewModel
if (message.contents.isNotEmpty()) { if (message.contents.isNotEmpty()) {
Log.i("$TAG Sending message") Log.i("$TAG Sending message")
message.send() message.send()
messageSentEvent.postValue(Event(message))
} }
Log.i("$TAG Message sent, re-setting defaults") Log.i("$TAG Message sent, re-setting defaults")
textToSend.postValue("") textToSend.postValue("")
isReplying.postValue(false) isReplying.postValue(false)
isEditing.postValue(false)
isFileAttachmentsListOpen.postValue(false) isFileAttachmentsListOpen.postValue(false)
isParticipantsListOpen.postValue(false) isParticipantsListOpen.postValue(false)
isEmojiPickerOpen.postValue(false) isEmojiPickerOpen.postValue(false)
@ -339,15 +393,20 @@ class SendMessageInConversationViewModel
attachments.postValue(attachmentsList) attachments.postValue(attachmentsList)
chatMessageToReplyTo = null chatMessageToReplyTo = null
chatMessageToEdit = null
maxNumberOfAttachmentsReached.postValue(false) maxNumberOfAttachmentsReached.postValue(false)
} }
} }
@UiThread @UiThread
fun notifyChatMessageIsBeingComposed() { fun notifyComposing(composing: Boolean) {
coreContext.postOnCoreThread { coreContext.postOnCoreThread {
if (::chatRoom.isInitialized) { if (::chatRoom.isInitialized) {
chatRoom.compose() if (composing) {
chatRoom.composeTextMessage()
} else {
chatRoom.stopComposing()
}
} }
} }
} }
@ -362,6 +421,10 @@ class SendMessageInConversationViewModel
@UiThread @UiThread
fun closeParticipantsList() { fun closeParticipantsList() {
isParticipantsListOpen.value = false isParticipantsListOpen.value = false
coreContext.postOnCoreThread {
participantsListFilter = ""
computeParticipantsList()
}
} }
@UiThread @UiThread
@ -379,35 +442,41 @@ class SendMessageInConversationViewModel
} }
@UiThread @UiThread
fun addAttachment(file: String) { fun addAttachments(files: ArrayList<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
}
val list = arrayListOf<FileModel>() val list = arrayListOf<FileModel>()
list.addAll(attachments.value.orEmpty()) list.addAll(attachments.value.orEmpty())
val fileName = FileUtils.getNameFromFilePath(file) for (file in files) {
val timestamp = System.currentTimeMillis() / 1000 if (list.size >= MAX_FILES_TO_ATTACH) {
val model = FileModel(file, fileName, 0, timestamp, false, file, false) { model -> Log.w(
removeAttachment(model.path) "$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 attachments.value = list
maxNumberOfAttachmentsReached.value = list.size >= MAX_FILES_TO_ATTACH maxNumberOfAttachmentsReached.value = list.size >= MAX_FILES_TO_ATTACH
if (list.isNotEmpty()) { if (list.isNotEmpty()) {
isFileAttachmentsListOpen.value = true 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 { } else {
Log.w("$TAG No attachment to display!") Log.w("$TAG No attachment to display!")
} }
@ -483,6 +552,7 @@ class SendMessageInConversationViewModel
@UiThread @UiThread
fun stopVoiceMessageRecording() { fun stopVoiceMessageRecording() {
coreContext.postOnCoreThread { coreContext.postOnCoreThread {
chatRoom.stopComposing()
stopVoiceRecorder() stopVoiceRecorder()
} }
} }
@ -490,6 +560,7 @@ class SendMessageInConversationViewModel
@UiThread @UiThread
fun cancelVoiceMessageRecording() { fun cancelVoiceMessageRecording() {
coreContext.postOnCoreThread { coreContext.postOnCoreThread {
chatRoom.stopComposing()
stopVoiceRecorder() stopVoiceRecorder()
val path = voiceMessageRecorder.file val path = voiceMessageRecorder.file
@ -516,7 +587,32 @@ class SendMessageInConversationViewModel
} }
@WorkerThread @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>() val participantsList = arrayListOf<ParticipantModel>()
for (participant in chatRoom.participants) { for (participant in chatRoom.participants) {
@ -525,14 +621,18 @@ class SendMessageInConversationViewModel
coreContext.postOnCoreThread { coreContext.postOnCoreThread {
val username = clicked.address.username val username = clicked.address.username
if (!username.isNullOrEmpty()) { 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) participants.postValue(participantsList)
isComputingParticipantsList.postValue(false)
} }
@WorkerThread @WorkerThread
@ -583,6 +683,7 @@ class SendMessageInConversationViewModel
} }
else -> {} else -> {}
} }
chatRoom.composeVoiceMessage()
val duration = voiceMessageRecorder.duration val duration = voiceMessageRecorder.duration
val formattedDuration = SimpleDateFormat("mm:ss", Locale.getDefault()).format(duration) // duration is in ms 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.ChatRoom
import org.linphone.core.ChatRoomListenerStub import org.linphone.core.ChatRoomListenerStub
import org.linphone.core.Conference import org.linphone.core.Conference
import org.linphone.core.Friend
import org.linphone.core.tools.Log import org.linphone.core.tools.Log
import org.linphone.ui.main.viewmodel.AddressSelectionViewModel import org.linphone.ui.main.viewmodel.AddressSelectionViewModel
import org.linphone.utils.AppUtils import org.linphone.utils.AppUtils
@ -51,10 +52,6 @@ class StartConversationViewModel
val operationInProgress = MutableLiveData<Boolean>() val operationInProgress = MutableLiveData<Boolean>()
val chatRoomCreationErrorEvent: MutableLiveData<Event<Int>> by lazy {
MutableLiveData<Event<Int>>()
}
val chatRoomCreatedEvent: MutableLiveData<Event<String>> by lazy { val chatRoomCreatedEvent: MutableLiveData<Event<String>> by lazy {
MutableLiveData<Event<String>>() MutableLiveData<Event<String>>()
} }
@ -77,9 +74,7 @@ class StartConversationViewModel
Log.e("$TAG Conversation [$id] creation has failed!") Log.e("$TAG Conversation [$id] creation has failed!")
chatRoom.removeListener(this) chatRoom.removeListener(this)
operationInProgress.postValue(false) operationInProgress.postValue(false)
chatRoomCreationErrorEvent.postValue( showRedToast(R.string.conversation_failed_to_create_toast, R.drawable.warning_circle)
Event(R.string.conversation_failed_to_create_toast)
)
} }
} }
} }
@ -93,6 +88,11 @@ class StartConversationViewModel
updateGroupChatButtonVisibility() updateGroupChatButtonVisibility()
} }
@WorkerThread
override fun onSingleAddressSelected(address: Address, friend: Friend?) {
createOneToOneChatRoomWith(address)
}
@UiThread @UiThread
fun createGroupChatRoom() { fun createGroupChatRoom() {
coreContext.postOnCoreThread { core -> coreContext.postOnCoreThread { core ->
@ -111,7 +111,11 @@ class StartConversationViewModel
params.isChatEnabled = true params.isChatEnabled = true
params.isGroupEnabled = true params.isGroupEnabled = true
params.subject = groupChatRoomSubject 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 params.account = account
val chatParams = params.chatParams ?: return@postOnCoreThread val chatParams = params.chatParams ?: return@postOnCoreThread
@ -149,9 +153,7 @@ class StartConversationViewModel
} else { } else {
Log.e("$TAG Failed to create group conversation [$groupChatRoomSubject]!") Log.e("$TAG Failed to create group conversation [$groupChatRoomSubject]!")
operationInProgress.postValue(false) operationInProgress.postValue(false)
chatRoomCreationErrorEvent.postValue( showRedToast(R.string.conversation_failed_to_create_toast, R.drawable.warning_circle)
Event(R.string.conversation_failed_to_create_toast)
)
} }
} }
} }
@ -202,9 +204,7 @@ class StartConversationViewModel
"$TAG Account is in secure mode, can't chat with SIP address of different domain [${remote.asStringUriOnly()}]" "$TAG Account is in secure mode, can't chat with SIP address of different domain [${remote.asStringUriOnly()}]"
) )
operationInProgress.postValue(false) operationInProgress.postValue(false)
chatRoomCreationErrorEvent.postValue( showRedToast(R.string.conversation_invalid_participant_due_to_security_mode_toast, R.drawable.warning_circle)
Event(R.string.conversation_invalid_participant_due_to_security_mode_toast)
)
return return
} }
@ -237,9 +237,7 @@ class StartConversationViewModel
} else { } else {
Log.e("$TAG Failed to create 1-1 conversation with [${remote.asStringUriOnly()}]!") Log.e("$TAG Failed to create 1-1 conversation with [${remote.asStringUriOnly()}]!")
operationInProgress.postValue(false) operationInProgress.postValue(false)
chatRoomCreationErrorEvent.postValue( showRedToast(R.string.conversation_failed_to_create_toast, R.drawable.warning_circle)
Event(R.string.conversation_failed_to_create_toast)
)
} }
} else { } else {
Log.w( Log.w(

View file

@ -125,12 +125,12 @@ class ContactsListAdapter(
val previousItem = bindingAdapterPosition - 1 val previousItem = bindingAdapterPosition - 1
val previousLetter = if (previousItem >= 0) { val previousLetter = if (previousItem >= 0) {
getItem(previousItem).contactName?.get(0).toString() getItem(previousItem).sortingName?.get(0).toString()
} else { } else {
"" ""
} }
val currentLetter = contactModel.contactName?.get(0).toString() val currentLetter = contactModel.sortingName?.get(0).toString()
val displayLetter = previousLetter.isEmpty() || currentLetter != previousLetter val displayLetter = previousLetter.isEmpty() || currentLetter != previousLetter
firstContactStartingByThatLetter = displayLetter firstContactStartingByThatLetter = displayLetter
@ -160,7 +160,7 @@ class ContactsListAdapter(
} }
override fun areContentsTheSame(oldItem: ContactAvatarModel, newItem: ContactAvatarModel): Boolean { 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 package org.linphone.ui.main.contacts.fragment
import android.app.Dialog import android.app.Dialog
import android.content.ClipData import android.content.ActivityNotFoundException
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.provider.ContactsContract import android.provider.ContactsContract
@ -170,20 +168,27 @@ class ContactFragment : SlidingPaneChildFragment() {
viewModel.openNativeContactEditor.observe(viewLifecycleOwner) { viewModel.openNativeContactEditor.observe(viewLifecycleOwner) {
it.consume { uri -> it.consume { uri ->
val editIntent = Intent(Intent.ACTION_EDIT).apply { try {
setDataAndType(uri.toUri(), ContactsContract.Contacts.CONTENT_ITEM_TYPE) val editIntent = Intent(Intent.ACTION_EDIT).apply {
putExtra("finishActivityOnSaveCompleted", true) 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) { viewModel.openLinphoneContactEditor.observe(viewLifecycleOwner) {
it.consume { refKey -> it.consume { refKey ->
val action = ContactFragmentDirections.actionContactFragmentToEditContactFragment( if (findNavController().currentDestination?.id == R.id.contactFragment) {
refKey val action =
) ContactFragmentDirections.actionContactFragmentToEditContactFragment(
findNavController().navigate(action) refKey
)
findNavController().navigate(action)
}
} }
} }
@ -213,8 +218,8 @@ class ContactFragment : SlidingPaneChildFragment() {
} }
viewModel.startCallToDeviceToIncreaseTrustEvent.observe(viewLifecycleOwner) { viewModel.startCallToDeviceToIncreaseTrustEvent.observe(viewLifecycleOwner) {
it.consume { pair -> it.consume { triple ->
callDirectlyOrShowConfirmTrustCallDialog(pair.first, pair.second) callDirectlyOrShowConfirmTrustCallDialog(triple.first, triple.second, triple.third)
} }
} }
@ -244,19 +249,15 @@ class ContactFragment : SlidingPaneChildFragment() {
} }
private fun copyNumberOrAddressToClipboard(value: String, isSip: Boolean) { 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" val label = if (isSip) "SIP address" else "Phone number"
clipboard.setPrimaryClip(ClipData.newPlainText(label, value)) if (AppUtils.copyToClipboard(requireContext(), label, value)) {
val message = if (isSip) {
val message = if (isSip) { getString(R.string.sip_address_copied_to_clipboard_toast)
getString(R.string.sip_address_copied_to_clipboard_toast) } else {
} else { getString(R.string.contact_details_phone_number_copied_to_clipboard_toast)
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) { private fun shareContact(name: String, file: File) {
@ -275,7 +276,11 @@ class ContactFragment : SlidingPaneChildFragment() {
} }
val shareIntent = Intent.createChooser(sendIntent, null) 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) { private fun inviteContactBySms(number: String) {
@ -291,22 +296,26 @@ class ContactFragment : SlidingPaneChildFragment() {
putExtra("address", number) putExtra("address", number)
putExtra("sms_body", smsBody) putExtra("sms_body", smsBody)
} }
startActivity(smsIntent) try {
startActivity(smsIntent)
} catch (anfe: ActivityNotFoundException) {
Log.e("$TAG Failed to start SMS intent: $anfe")
}
} }
private fun showTrustProcessDialog() { 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 picture = viewModel.contact.value?.picturePath?.value.orEmpty()
val model = ContactTrustDialogModel(initials, picture) val model = ContactTrustDialogModel(initials, picture)
val dialog = DialogUtils.getContactTrustProcessExplanationDialog(requireActivity(), model) val dialog = DialogUtils.getContactTrustProcessExplanationDialog(requireActivity(), model)
dialog.show() dialog.show()
} }
private fun callDirectlyOrShowConfirmTrustCallDialog(contactName: String, deviceSipUri: String) { private fun callDirectlyOrShowConfirmTrustCallDialog(contactName: String, deviceName: String, deviceSipUri: String) {
coreContext.postOnCoreThread { coreContext.postOnCoreThread {
if (corePreferences.showDialogWhenCallingDeviceUuidDirectly) { if (corePreferences.showDialogWhenCallingDeviceUuidDirectly) {
coreContext.postOnMainThread { coreContext.postOnMainThread {
showConfirmTrustCallDialog(contactName, deviceSipUri) showConfirmTrustCallDialog(contactName, deviceName, deviceSipUri)
} }
} else { } else {
val address = Factory.instance().createAddress(deviceSipUri) 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( val label = AppUtils.getFormattedString(
R.string.contact_dialog_increase_trust_level_message, R.string.contact_dialog_increase_trust_level_message,
contactName, contactName,
deviceSipUri deviceName
) )
val model = ConfirmationDialogModel(label) val model = ConfirmationDialogModel(label)
val dialog = DialogUtils.getContactTrustCallConfirmationDialog(requireActivity(), model) val dialog = DialogUtils.getContactTrustCallConfirmationDialog(requireActivity(), model)

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