Compare commits

..

190 commits
master ... c3

Author SHA1 Message Date
Daniel Gultsch 69866e591c
include mediaType and size in message content 2023-04-19 08:11:39 +02:00
Daniel Gultsch 506e4e1d0c
add index on disco.feature coloum 2023-04-02 09:53:35 +02:00
Daniel Gultsch c858b5346f
clean up references in ChatFragment 2023-04-01 13:59:48 +02:00
Daniel Gultsch e6bf595388
do not restrain embedded message size to outer message size 2023-04-01 11:39:30 +02:00
Daniel Gultsch 9127d68197
render reactions 2023-04-01 11:01:10 +02:00
Daniel Gultsch 340bf45095
do not show fallback in embedded message 2023-03-31 15:36:57 +02:00
Daniel Gultsch acfcde8416
flash background after scrolling to message 2023-03-31 14:14:20 +02:00
Daniel Gultsch 4f654044b4
remove fallback body when rendering 2023-03-30 18:49:04 +02:00
Daniel Gultsch 1b3c7b6a42
render inReplyTo message 2023-03-30 17:47:00 +02:00
Daniel Gultsch a4fe60dece
add image preview to message bubbles 2023-03-30 16:36:16 +02:00
Daniel Gultsch 03cf48f4c1
set avatar invisible when message is null 2023-03-28 23:14:05 +02:00
Daniel Gultsch 4d5445d123
jump to message id 2023-03-28 17:15:35 +02:00
Daniel Gultsch 4bfcf209d7
add date separators 2023-03-27 16:48:35 +02:00
Daniel Gultsch 5b777ef657
display outgoing messages 2023-03-27 14:41:23 +02:00
Daniel Gultsch d52cbb8e8c
fix message comparator 2023-03-27 11:18:29 +02:00
Daniel Gultsch cc07f86bf4
use occupantResource for sender name 2023-03-27 10:41:46 +02:00
Daniel Gultsch f13f15cc91
include occupant resource 2023-03-24 17:51:29 +01:00
Daniel Gultsch 405eeadd95
show sender and display correct encryption icon 2023-03-24 16:11:55 +01:00
Daniel Gultsch 75a4008aee
use resource for consistent color gen (as per modernxmpp) 2023-03-24 15:07:24 +01:00
Daniel Gultsch 4fae8d4e11
show avatars in chat 2023-03-24 12:21:19 +01:00
Daniel Gultsch 805d0db486
show time underneath bubble 2023-03-23 19:08:09 +01:00
Daniel Gultsch 779e6fa61e
rudimentary MessageAdapter 2023-03-23 12:36:59 +01:00
Daniel Gultsch da528776db
include membersOnlyNonAnonymous in chat info 2023-03-23 12:36:33 +01:00
Daniel Gultsch 4fd96e740f
do not start inner transaction for transformation
throwing in an inner transaction will fail the entire transaction even if the
exception is caught in the outer transaction
2023-03-21 22:01:39 +01:00
Daniel Gultsch 4139c11771
add test for multi page result 2023-03-21 18:21:56 +01:00
Daniel Gultsch 1e884ec435
display title in chat fragment 2023-03-21 16:08:05 +01:00
Daniel Gultsch 86d9264ee5
create stub chat fragment 2023-03-21 11:03:50 +01:00
Daniel Gultsch 0f6f9b0001
do not reload chat filter when correct one is already set 2023-03-12 21:18:13 +01:00
Daniel Gultsch e22fcab844
set up back press action for search view 2023-03-12 21:15:13 +01:00
Daniel Gultsch e3f5f6404b
add unit test for archive dao 2023-03-11 23:16:55 +01:00
Daniel Gultsch 7c820f7b32
create stub message contents for encryption failures 2023-03-11 15:56:17 +01:00
Daniel Gultsch ee1c938f2a
look up sender in group chats 2023-03-11 12:10:26 +01:00
Daniel Gultsch d9e8918727
add TODO on how to use RangeAfter 2023-03-11 09:57:51 +01:00
Daniel Gultsch 97f54b6673
bump annotation processors to java 17 2023-03-10 20:03:32 +01:00
Daniel Gultsch 58c5bd0f1b
fetch MAM messages 2023-03-10 20:03:02 +01:00
Daniel Gultsch bb2d077b7c
warn user when lacking internet connnection 2023-03-09 08:14:30 +01:00
Daniel Gultsch b2c348a1df
delete unused disco info on bind 2023-03-08 16:04:18 +01:00
Daniel Gultsch 9a0c2226c1
fix hashCode and equals in account 2023-03-08 12:57:24 +01:00
Daniel Gultsch e971b77539
fix sentAt not always being properly end aligned 2023-03-08 12:17:25 +01:00
Daniel Gultsch c1ef2ac628
submit empty page when changing filter 2023-03-08 09:52:57 +01:00
Daniel Gultsch eb15dc1260
make avatar shape configurable (in code) 2023-03-08 09:52:01 +01:00
Daniel Gultsch 26d856e91f
support chat filters 2023-03-07 20:43:16 +01:00
Daniel Gultsch 9819ef7d05
fetch vcard avatars 2023-03-07 20:05:20 +01:00
Daniel Gultsch 417e801811
unarchive chat when receiving message 2023-03-07 16:13:25 +01:00
Daniel Gultsch 0d134a919e
add avatar image to chat overview item 2023-03-07 16:04:32 +01:00
Daniel Gultsch 260654f171
rudimentary ChatOverviewAdapter 2023-03-06 18:55:02 +01:00
Daniel Gultsch cfaf6162e6
use Java 17 2023-03-05 15:23:46 +01:00
Daniel Gultsch e4fb793769
use transaction for complex model 2023-03-05 15:15:57 +01:00
Daniel Gultsch f1fbf15fea
add HttpUploadManager slot request 2023-03-05 12:09:56 +01:00
Daniel Gultsch f9b3d42a8a
bump agp 2023-03-05 08:38:49 +01:00
Daniel Gultsch a67979adf8
join MultiUserChats on bind 2023-03-05 08:38:12 +01:00
Daniel Gultsch 8be8d7df8f
parse more presence metadata 2023-03-03 12:05:20 +01:00
Daniel Gultsch 2e5e2ff6fe
cache last used service record in DB 2023-03-03 10:14:02 +01:00
Daniel Gultsch 807078b24f
remove unused 'service' from resolver 2023-03-02 19:45:45 +01:00
Daniel Gultsch 4addeaa356
use futures in DNS resolver 2023-03-02 18:44:27 +01:00
Daniel Gultsch 100c735636
use guavas utiltiy equals+hash in resolver result 2023-03-02 16:12:07 +01:00
Daniel Gultsch b2414434dc
fix connection pool not handling removes 2023-03-02 15:43:56 +01:00
Daniel Gultsch 0c4771e2a8
persist certificate trust to disk 2023-03-02 13:44:29 +01:00
Daniel Gultsch 177320d8fe
use scopes for trust 2023-03-02 10:10:12 +01:00
Daniel Gultsch 9c64f9c24c
add UI for certificate trust 2023-03-01 22:05:46 +01:00
Daniel Gultsch 786a6c4c2a
put trust manager framework in place 2023-03-01 22:05:46 +01:00
Daniel Gultsch be6f4300da
include sender id in tests 2023-03-01 22:05:46 +01:00
Daniel Gultsch c2bf9d0413
store senderIdentity in message 2023-03-01 22:05:46 +01:00
Daniel Gultsch 303f14200f
take btbv setting into account when deciding default trust 2023-03-01 22:05:46 +01:00
Daniel Gultsch 1a924d3efd
introduce AppSettings for easier access to preferences 2023-03-01 22:05:45 +01:00
Daniel Gultsch 86ef179c42
use empty message (not key transport) to finish sessions 2023-03-01 22:05:45 +01:00
Daniel Gultsch 5e79dd8b68
left join trust into MessageWithContentReactions 2023-03-01 22:05:45 +01:00
Daniel Gultsch 3c207c28b4
fix drawer layout reacting to back press after rotation 2023-03-01 22:05:45 +01:00
Daniel Gultsch 9c95554782
add trust to identity table 2023-03-01 22:05:45 +01:00
Daniel Gultsch ac2866a682
add automatic session completion 2023-03-01 22:05:45 +01:00
Daniel Gultsch cf17a2ac6d
request device list when encountering unknown device 2023-03-01 22:05:45 +01:00
Daniel Gultsch c3f5273813
close drawer on back press 2023-03-01 22:05:45 +01:00
Daniel Gultsch 6ef2997b5e
add some menu items to setup screen 2023-03-01 22:05:45 +01:00
Daniel Gultsch b8f3472af0
remember chat filter selection across rotations 2023-03-01 22:05:45 +01:00
Daniel Gultsch d54978f593
store connection settings after pressing submit in hostname fragment 2023-03-01 22:05:45 +01:00
Daniel Gultsch 99c11fba17
add stub hostname fragment 2023-03-01 22:05:45 +01:00
Daniel Gultsch cf5910e96e
add 'encryption' and 'identityKey' to message version entity 2023-03-01 22:05:44 +01:00
Daniel Gultsch 677cfcd34c
generate prekeys on cpu executor 2023-03-01 22:05:44 +01:00
Daniel Gultsch 2abcb1b4e4
decrypt omemo messages 2023-03-01 22:05:44 +01:00
Daniel Gultsch 49b4f54285
run RtpSessionService during phone calls 2023-03-01 22:05:44 +01:00
Daniel Gultsch 1be1334794
fix memory leak in local video track 2023-03-01 22:05:44 +01:00
Daniel Gultsch 63df518c19
include PartType in CallLog 2023-03-01 22:05:44 +01:00
Daniel Gultsch 63bfbfb40a
create transformation for call log 2023-03-01 22:05:44 +01:00
Daniel Gultsch 44ac7190a9
add notifications and attachments settings screens 2023-03-01 22:05:44 +01:00
Daniel Gultsch bfafad6c65
use icons in security preferences 2023-03-01 22:05:44 +01:00
Daniel Gultsch f5203b082b
calculate switch to video cap on jingle connection startup 2023-03-01 22:05:44 +01:00
Daniel Gultsch eafa93d132
port jingle rtp connection 2023-03-01 22:05:44 +01:00
Daniel Gultsch d7ab5e1a4b
add http upload manager 2023-03-01 22:05:44 +01:00
Daniel Gultsch d136928322
redirect from main to setup on zero accounts 2023-03-01 22:05:43 +01:00
Daniel Gultsch 0727b0aba6
add 'security' settings 2023-03-01 22:05:43 +01:00
Daniel Gultsch 1f22c5f534
show dynamic colors setting only if available 2023-03-01 22:05:43 +01:00
Daniel Gultsch 7d42da8c34
Android 7+: do not repeat app name in notification 2023-03-01 22:05:43 +01:00
Daniel Gultsch 09b28358ab
add more sample settings 2023-03-01 22:05:43 +01:00
Daniel Gultsch 7567dcff5e
add settings for dynamic colors and dark theme 2023-03-01 22:05:43 +01:00
Daniel Gultsch b80fe9802a
try to fix SearchBar disappearing 2023-03-01 22:05:43 +01:00
Daniel Gultsch fe9b3b8ed9
introduce settings activity 2023-03-01 22:05:43 +01:00
Daniel Gultsch cdcd323c36
stick intent into menu items to know which one was clicked 2023-03-01 22:05:43 +01:00
Daniel Gultsch 867db9d54c
toggle between 'chats' and 'all chats' 2023-03-01 22:05:43 +01:00
Daniel Gultsch 87e33a779f
add stub MainActivity 2023-03-01 22:05:43 +01:00
Daniel Gultsch c105c3420e
store roster groups and bookmark groups in one table 2023-03-01 22:05:43 +01:00
Daniel Gultsch 2212c63810
add basic foreground service and event receiver 2023-03-01 22:05:42 +01:00
Daniel Gultsch d6edea8ddf
avoid accounts being connected multiple times
the pool should not be asked to connect a specific account
it should only be called to do a full reconfiguration
2023-03-01 22:05:42 +01:00
Daniel Gultsch bca253faa4
navigate from start to password to done in Setup 2023-03-01 22:05:42 +01:00
Daniel Gultsch 68e9f25da2
add leak canary 2023-03-01 22:05:42 +01:00
Daniel Gultsch a1e97461f9
do not parse presences from account 2023-03-01 22:05:42 +01:00
Daniel Gultsch bf9b0b18f9
restructure build setup 2023-03-01 22:05:42 +01:00
Daniel Gultsch a09cc126ea
use logging framework in more places 2023-03-01 22:05:42 +01:00
Daniel Gultsch b0010307c0
move Domain Verifier to im.conversations 2023-03-01 22:05:42 +01:00
Daniel Gultsch b5a47000c9
get rid of payment required account state 2023-03-01 22:05:42 +01:00
Daniel Gultsch 7d34c894d0
move SSLSockets helper library into im.conversations package 2023-03-01 22:05:41 +01:00
Daniel Gultsch 5866974eff
wire up SetupViewModel with account repo 2023-03-01 22:05:41 +01:00
Daniel Gultsch 3c42066a7c
get rid of legacy Jid wrapper around jxmpp 2023-03-01 22:05:41 +01:00
Daniel Gultsch 6845380be5
move Element, Tag etc into im.conversations package 2023-03-01 22:05:41 +01:00
Daniel Gultsch eeac779e25
introduce SetupActiviy 2023-03-01 22:05:41 +01:00
Daniel Gultsch 35360fde91
modify XmppConnection to change status to online for unbound cons 2023-03-01 22:05:41 +01:00
Daniel Gultsch a204bf9ec1
add support to retrieve registration 2023-03-01 22:05:41 +01:00
Daniel Gultsch 79eebe68e2
add registration manager (change password + delete account) 2023-03-01 22:05:41 +01:00
Daniel Gultsch 268bef4433
verify we set occupantId on modifcations in group chat 2023-03-01 22:05:41 +01:00
Daniel Gultsch 69d212141b
parse message retractions 2023-03-01 22:05:41 +01:00
Daniel Gultsch 94c8b9ed04
add models for retraction 2023-03-01 22:05:41 +01:00
Daniel Gultsch 2d10a561e4
rename EmbeddedMessage to MessageEmbedded 2023-03-01 22:05:41 +01:00
Daniel Gultsch acb297ac96
store roster groups in DB 2023-03-01 22:05:41 +01:00
Daniel Gultsch 405445afbe
store reference to inReplyTo in database 2023-03-01 22:05:40 +01:00
Daniel Gultsch 56a462833e
in group chats corrections and reactions use different ids. we need to merge stubs 2023-03-01 22:05:40 +01:00
Daniel Gultsch 2728a96ab9
add helper method to count reactions 2023-03-01 22:05:40 +01:00
Daniel Gultsch 7e2bff9d03
test message correction 2023-03-01 22:05:40 +01:00
Daniel Gultsch 4c09b20aa4
support reaction arriving before message 2023-03-01 22:05:40 +01:00
Daniel Gultsch fbb900d4ad
make transformer testable
note that the test will currently fail because the implemtation isnt complete
2023-03-01 22:05:40 +01:00
Daniel Gultsch 6c24cb12dd
store reactions in database 2023-03-01 22:05:40 +01:00
Daniel Gultsch a69b4b14a5
apply message corrections 2023-03-01 22:05:40 +01:00
Daniel Gultsch be3a8dc5e1
insert message states (displayed, received, error) into DB 2023-03-01 22:05:40 +01:00
Daniel Gultsch 9b62861a64
store messages in database 2023-03-01 22:05:40 +01:00
Daniel Gultsch dc371d7017
add models for Displayed and replace 2023-03-01 22:05:40 +01:00
Daniel Gultsch a43160b13d
setup stub transformer 2023-03-01 22:05:40 +01:00
Daniel Gultsch 458f0ef280
parse and validate stanza-id 2023-03-01 22:05:40 +01:00
Daniel Gultsch 3f59dd2688
add model for MAM result + MAM manager 2023-03-01 22:05:39 +01:00
Daniel Gultsch ca0a0c07fc
add models for Chat States + manager 2023-03-01 22:05:39 +01:00
Daniel Gultsch bed6b07bdd
add receipt manager and process receipt requests 2023-03-01 22:05:39 +01:00
Daniel Gultsch 870393df8e
introduce 'PepManager' to unify what pubsub service we talk to 2023-03-01 22:05:39 +01:00
Daniel Gultsch e2ea1f9437
fix device list publication. boomarks add + retract 2023-03-01 22:05:39 +01:00
Daniel Gultsch 3be56b6775
reconfigure node when precondition is not met 2023-03-01 22:05:39 +01:00
Daniel Gultsch 58b1e26367
include publish-options. prepare code for reconfiguration 2023-03-01 22:05:39 +01:00
Daniel Gultsch c077e4e8da
add PubSubManager, AvatarManager and AxolotlManager 2023-03-01 22:05:39 +01:00
Daniel Gultsch f1e1cf9653
respond to software version requests 2023-03-01 22:05:39 +01:00
Daniel Gultsch e073f22ec0
respond to disco#info queries 2023-03-01 22:05:39 +01:00
Daniel Gultsch 57d264d72e
include caps in outgoing presence 2023-03-01 22:05:39 +01:00
Daniel Gultsch 9a855a57ac
add models for Error conditions 2023-03-01 22:05:39 +01:00
Daniel Gultsch ddcab5fb58
add message carbon processing 2023-03-01 22:05:38 +01:00
Daniel Gultsch fe32526de8
parse blocking command pushes 2023-03-01 22:05:38 +01:00
Daniel Gultsch 164ac450d4
introduce CarbonsManager to enable and maintain carbon state 2023-03-01 22:05:38 +01:00
Daniel Gultsch d2794ccf32
create new models for IQ, Message & Presence 2023-03-01 22:05:38 +01:00
Daniel Gultsch f16603742f
make authentications work with null password 2023-03-01 22:05:38 +01:00
Daniel Gultsch f982885d2e
fix regression in ping strategy 2023-03-01 22:05:38 +01:00
Daniel Gultsch 8df97067bb
discover commands on domain 2023-03-01 22:05:38 +01:00
Daniel Gultsch bd343eafa0
add async variant for createAccount 2023-03-01 22:05:38 +01:00
Daniel Gultsch c31fa7ed2b
include parentNode in disco items table 2023-03-01 22:05:38 +01:00
Daniel Gultsch d25cc059c5
add AccountRepository 2023-03-01 22:05:38 +01:00
Daniel Gultsch 359ef330df
get rid of upsert in favor of update and insert
upsert seems to only work with primary keys and not other
unique constraints.
2023-03-01 22:05:38 +01:00
Daniel Gultsch de06bfb8f0
retrieve Extensions not Elements from extension map 2023-03-01 22:05:38 +01:00
Daniel Gultsch 1e6aed759b
check caps hash after retrieving them 2023-03-01 22:05:37 +01:00
Daniel Gultsch 1a09b3ed05
use empty string instead of null for 'no node' and 'no resource' 2023-03-01 22:05:37 +01:00
Daniel Gultsch 90e613f94e
fix parsing error in Legacy caps 2023-03-01 22:05:37 +01:00
Daniel Gultsch 09db9e574b
do not return NodeHash if no valid hash mech is found 2023-03-01 22:05:37 +01:00
Daniel Gultsch f5faa8fc4d
try with resources in CredentialStore 2023-03-01 22:05:37 +01:00
Daniel Gultsch bfa61d56af
use annotations processor to create extensions 2023-03-01 22:05:37 +01:00
Daniel Gultsch da65960fd1
reconnect account after adding to ConnectionPool 2023-03-01 22:05:37 +01:00
Daniel Gultsch 6983aedddc
add IDs.seed() method for random account seed 2023-03-01 22:05:37 +01:00
Daniel Gultsch 27952c00ed
flush credential store file 2023-03-01 22:05:37 +01:00
Daniel Gultsch 944c48e00b
store presence in DB 2023-03-01 22:05:37 +01:00
Daniel Gultsch 26bff8028a
check disco feature on entity 2023-03-01 22:05:37 +01:00
Daniel Gultsch 873644f528
remove XmppConnection.Features helper class in favor of DiscoManager 2023-03-01 22:05:37 +01:00
Daniel Gultsch 199a1cdc64
add code to fetch items and their infos in one go 2023-03-01 22:05:37 +01:00
Daniel Gultsch 43a82e504b
parse caps from presence 2023-03-01 22:05:37 +01:00
Daniel Gultsch a2b21d97eb
use dedicated hash object instead of byte[] for caps
this way we can store the algo alongside the object
2023-03-01 22:05:36 +01:00
Daniel Gultsch 6458c6e9f9
store disco features after fetching them 2023-03-01 22:05:36 +01:00
Daniel Gultsch 1b438117a3
add Entity Caps 2 hash calculation 2023-03-01 22:05:36 +01:00
Daniel Gultsch 78af8cbd87
migrate entity caps 1 calculation to new code 2023-03-01 22:05:36 +01:00
Daniel Gultsch 482dc8cfe9
insert disco items into db 2023-03-01 22:05:36 +01:00
Daniel Gultsch 3e9029dc8f
bump targetSdk; bump room version 2023-03-01 22:05:36 +01:00
Daniel Gultsch 38c612d35d
make name+namespace assignment in xmpp less error prone 2023-03-01 22:05:36 +01:00
Daniel Gultsch 07c1669813
introduce Manager concept to bundle functionality like roster, blocking, … 2023-03-01 22:05:36 +01:00
Daniel Gultsch 20962554a4
retrieve blocklist on bind 2023-03-01 22:05:36 +01:00
Daniel Gultsch 6b232f7a5a
fetch roster. process result 2023-03-01 22:05:36 +01:00
Daniel Gultsch 9e7bbcc272
offer alternative access to elements and children
instead of Element.findChild(name, namespace) we can now use
Element.getExtension(Extension.class) for registered extensions
2023-03-01 22:05:36 +01:00
Daniel Gultsch 49bf92f7ca
wire up MessageAckProcessor with DB 2023-03-01 22:05:35 +01:00
Daniel Gultsch 2c32f9738c
homogenize ID generation 2023-03-01 22:05:35 +01:00
Daniel Gultsch 7ee3e07946
Introduce XmppConnection v3
The various layers of the app are too intertwined to refactor them in place.

The C3 refactor is going to create a parallel architecture for all classes that
have too strong of a connection to other parts of the app.

This commit introduces XmppConnection v3 that keeps a lot of the logic of the
privous XmppConnection but cuts ties to XmppConnectionService and the very
stateful `entites.Account`. The latter is replaced by a lightweight immutable
account model.

The reconnection logic has been kept but was moved from XmppConnectionService
to a singleton ConnectionPool.
2023-03-01 22:05:35 +01:00
Daniel Gultsch 94dde9f433
initial set of Room entities 2023-03-01 22:05:35 +01:00
Daniel Gultsch 5d79cfbf0d
add spotless plugin 2023-03-01 22:05:35 +01:00
Daniel Gultsch 80d97c3fcc
bump version to 3.0.0-alpha. modify appId 2023-03-01 22:05:35 +01:00
2951 changed files with 73280 additions and 138693 deletions

View file

@ -1,7 +0,0 @@
steps:
build:
image: codeberg.org/freeyourgadget/android-fdroid-tools:latest
commands:
- ./gradlew clean
- ./gradlew assembleConversationsFreeDebug
- ./gradlew assembleQuicksyFreeDebug

View file

@ -1,60 +1,5 @@
# Changelog # Changelog
### Version 2.13.0
* Easier access to 'Show QR code'
* Support PEP Native Bookmarks
* Add support for SDP Offer / Answer Model (Used by SIP gateways)
* Raise target API to Android 14
### Version 2.12.12
* Support Private DNS (DNS over TLS)
* Support themed launcher icon
* Fix rare permission issue when sharing files on Android 11+
### Version 2.12.11
* Bump libwebrtc dependency to M117 and bump libvpx
* Go back to AAC for voice messages
* Support per app language settings
### Version 2.12.10
* support per conversation notification settings
* use opus for voice messages on Android 10
### Version 2.12.9
* Introduce new backup file format
### Version 2.12.8
* Disable opening backup files (.ceb) from file manager
### Version 2.12.7
* Remove channel discovery feature from Google Play version
### Version 2.12.6
* Fix 'q' falsely being recognized as cyrillic
### Version 2.12.5
* Bump Target SDK to 33 again
* Fix issues on servers supporting SASL2 w/o inline Stream Management
### Version 2.12.4
* Revert Target SDK bump (back to 32) to fix various issues on Android 13
### Version 2.12.3
* Improve support for new emojis
* Add ability to remove account from server
* Show timestamp for calls
### Version 2.12.2 ### Version 2.12.2
* Increase corner radius on profile pictures * Increase corner radius on profile pictures

235
README.md
View file

@ -1,6 +1,17 @@
<h1 align="center">Conversations Classic</h1> <h1 align="center">Conversations</h1>
<p align="center">Conversations Classic: the very last word in instant messaging</p> <p align="center">Conversations: the very last word in instant messaging</p>
<p align="center">
<a href="https://play.google.com/store/apps/details?id=eu.siacs.conversations&amp;referrer=utm_source%3Dcodeberg">
<img src="https://conversations.im/images/get-it-on-play.png" alt="Get it on Google Play" height="80">
</a>
<a href="https://f-droid.org/packages/eu.siacs.conversations">
<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="80">
</a>
</p>
![screenshots](https://codeberg.org/iNPUTmice/Conversations/raw/branch/master/screenshots.png)
## Design principles ## Design principles
@ -11,9 +22,9 @@
## Features ## Features
* End-to-end encryption with [OMEMO](https://en.wikipedia.org/wiki/OMEMO) or [OpenPGP](https://openpgp.org/about/) * End-to-end encryption with [OMEMO](http://conversations.im/omemo/) or [OpenPGP](http://openpgp.org/about/)
* Send and receive images as well as other kind of files * Send and receive images as well as other kind of files
* Encrypted audio and video calls (DTLS-SRTP) with DTMF dialer support * [Encrypted audio and video calls (DTLS-SRTP)](https://help.conversations.im)
* Share your location * Share your location
* Send voice messages * Send voice messages
* Indication when your contact has read your message * Indication when your contact has read your message
@ -28,15 +39,14 @@
### XMPP Features ### XMPP Features
Conversations Classic works with every XMPP server out there. However XMPP is an Conversations works with every XMPP server out there. However XMPP is an
extensible protocol. These extensions are standardized as well in so called extensible protocol. These extensions are standardized as well in so called
XEP's. Conversations Classic supports a couple of these to make the overall user XEP's. Conversations supports a couple of these to make the overall user
experience better. There is a chance that your current XMPP server does not experience better. There is a chance that your current XMPP server does not
support these extensions; therefore to get the most out of Conversations Classic you support these extensions; therefore to get the most out of Conversations you
should consider either switching to an XMPP server that does or — even better — should consider either switching to an XMPP server that does or — even better —
run your own XMPP server for you and your friends. These XEP's are: run your own XMPP server for you and your friends. These XEP's are:
* [XEP-0050: Ad-Hoc Commands](http://xmpp.org/extensions/xep-0050.html) lets to interact with gateways
* [XEP-0065: SOCKS5 Bytestreams](http://xmpp.org/extensions/xep-0065.html) will be used to transfer * [XEP-0065: SOCKS5 Bytestreams](http://xmpp.org/extensions/xep-0065.html) will be used to transfer
files if both parties are behind a firewall (NAT). files if both parties are behind a firewall (NAT).
* [XEP-0163: Personal Eventing Protocol](http://xmpp.org/extensions/xep-0163.html) for avatars and OMEMO. * [XEP-0163: Personal Eventing Protocol](http://xmpp.org/extensions/xep-0163.html) for avatars and OMEMO.
@ -45,21 +55,18 @@ run your own XMPP server for you and your friends. These XEP's are:
* [XEP-0198: Stream Management](http://xmpp.org/extensions/xep-0198.html) allows XMPP to survive small network outages and * [XEP-0198: Stream Management](http://xmpp.org/extensions/xep-0198.html) allows XMPP to survive small network outages and
changes of the underlying TCP connection. changes of the underlying TCP connection.
* [XEP-0215: External Service Discovery](https://xmpp.org/extensions/xep-0215.html) will be used to discover STUN and TURN servers which facilitate P2P A/V calls. * [XEP-0215: External Service Discovery](https://xmpp.org/extensions/xep-0215.html) will be used to discover STUN and TURN servers which facilitate P2P A/V calls.
* [XEP-0237: Roster Versioning](http://xmpp.org/extensions/xep-0237.html) mainly to save bandwidth on poor mobile connections
* [XEP-0280: Message Carbons](http://xmpp.org/extensions/xep-0280.html) which automatically syncs the messages you send to * [XEP-0280: Message Carbons](http://xmpp.org/extensions/xep-0280.html) which automatically syncs the messages you send to
your desktop client and thus allows you to switch seamlessly from your mobile your desktop client and thus allows you to switch seamlessly from your mobile
client to your desktop client and back within one conversation. client to your desktop client and back within one conversation.
* [XEP-0308: Last Message Correction](https://xmpp.org/extensions/xep-0308.html) allows you to edit last message as well as retract it * [XEP-0237: Roster Versioning](http://xmpp.org/extensions/xep-0237.html) mainly to save bandwidth on poor mobile connections
* [XEP-0313: Message Archive Management](http://xmpp.org/extensions/xep-0313.html) synchronize message history with the * [XEP-0313: Message Archive Management](http://xmpp.org/extensions/xep-0313.html) synchronize message history with the
server. Catch up with messages that were sent while Conversations Classic was server. Catch up with messages that were sent while Conversations was
offline. offline.
* [XEP-0352: Client State Indication](http://xmpp.org/extensions/xep-0352.html) lets the server know whether or not * [XEP-0352: Client State Indication](http://xmpp.org/extensions/xep-0352.html) lets the server know whether or not
Conversations is in the background. Allows the server to save bandwidth by Conversations is in the background. Allows the server to save bandwidth by
withholding unimportant packages. withholding unimportant packages.
* [XEP-0363: HTTP File Upload](http://xmpp.org/extensions/xep-0363.html) allows you to share files in conferences * [XEP-0363: HTTP File Upload](http://xmpp.org/extensions/xep-0363.html) allows you to share files in conferences
and with offline contacts. and with offline contacts.
* [XEP-0461: Message Replies](https://xmpp.org/extensions/xep-0461.html) provides support of native replies, which also works in many transports (gateways) as well
## FAQ ## FAQ
@ -67,30 +74,44 @@ run your own XMPP server for you and your friends. These XEP's are:
#### How do I install Conversations? #### How do I install Conversations?
Conversations Classic is entirely open source and licensed under GPLv3. So if you are a Conversations is entirely open source and licensed under GPLv3. So if you are a
software developer you can check out the sources from GitHub and use Gradle to software developer you can check out the sources from GitHub and use Gradle to
build your apk file. build your apk file.
The more convenient way — which not only gives you automatic updates but also
supports the further development of Conversations — is to buy the App in the
Google [Play Store](https://play.google.com/store/apps/details?id=eu.siacs.conversations&referrer=utm_source%3Dcodeberg).
Buying the App from the Play Store will also give you access to our [beta test](#beta).
#### I don't have a Google Account but I would still like to make a donation
Im listing several options to support me financially on [my website](https://gultsch.de/donate.html). Among other things [Liberapay](https://liberapay.com/iNPUTmice/donate), [GitHub Sponsors](https://github.com/sponsors/inputmice) and bank transfer.
#### How do I create an account? #### How do I create an account?
XMPP, like email, is a federated protocol, which means that there is not one company you can create an *official XMPP account* with. Instead there are hundreds, or even thousands, of providers out there. One of those providers is [conversations.im](https://account.conversations.im). If you dont like to use *conversations.im* use a web search engine of your choice to find another provider. Or maybe your university has one. Or you can run your own. Or ask a friend to run one. Once you've found one, you can use Conversations to create an account. Just select *register new account* on server within the create account dialog. XMPP, like email, is a federated protocol, which means that there is not one company you can create an *official XMPP account* with. Instead there are hundreds, or even thousands, of providers out there. One of those providers is our very own [conversations.im](https://account.conversations.im). If you dont like to use *conversations.im* use a web search engine of your choice to find another provider. Or maybe your university has one. Or you can run your own. Or ask a friend to run one. Once you've found one, you can use Conversations to create an account. Just select *register new account* on server within the create account dialog.
##### Domain hosting ##### Domain hosting
Using your own domain not only gives you a more recognizable Jabber ID, it also gives you the flexibility to migrate your account between different XMPP providers. This is a good compromise between the responsibilities of having to operate your own server and the downsides of being dependent on a single provider. Using your own domain not only gives you a more recognizable Jabber ID, it also gives you the flexibility to migrate your account between different XMPP providers. This is a good compromise between the responsibilities of having to operate your own server and the downsides of being dependent on a single provider.
Learn more about [conversations.im Jabber/XMPP domain hosting](https://account.conversations.im/domain/).
##### Running your own ##### Running your own
If you already have a server somewhere and are willing and able to put the necessary work in you can run your own XMPP server. If you already have a server somewhere and are willing and able to put the necessary work in you can run your own XMPP server.
As of 2023 XMPP has reached a level of maturity where all major XMPP servers ([ejabberd](https://ejabberd.im), [Prosody](https://prosody.im), [Openfire](https://www.igniterealtime.org/projects/openfire/), [Tigase](https://tigase.net/xmpp-server/)) should work well with Conversations. As of 2019 we recommend you use [ejabberd](https://ejabberd.im). The default configuration file already enables everything you need to pass the [Conversations Compliance Suite](https://compliance.conversations.im). Make sure your Linux distribution ships a fairly recent version.
Interoperability with Prosody and ejabberd is tested fairly regularly just because of their market share but we occasionally test with other servers too and fix issues as soon as we are being made aware of them. With a little bit of effort [Prosody](https://prosody.im) can be configured to support all necessary extensions as well. However you will have to rely on so called [Community Modules](https://modules.prosody.im/) of varying quality. Prosody can be interesting to people who like to modify their server and create / prototype own modules.
Performance wise - for small deployments - both ejabberd and Prosody should be fine.
#### Where can I set up a custom hostname / port #### Where can I set up a custom hostname / port
Conversations Classic will automatically look up the SRV records for your domain name Conversations will automatically look up the SRV records for your domain name
which can point to any hostname port combination. If your server doesnt provide which can point to any hostname port combination. If your server doesnt provide
those please contact your admin and have them read those please contact your admin and have them read
[this](http://prosody.im/doc/dns#srv_records). If your server operator is unwilling [this](http://prosody.im/doc/dns#srv_records). If your server operator is unwilling
to fix this you can enable advanced server settings in the expert settings of to fix this you can enable advanced server settings in the expert settings of
Conversations Classic. Conversations.
#### I get 'Incompatible Server' #### I get 'Incompatible Server'
@ -101,15 +122,15 @@ If you are a server administrator you should make sure that your server provides
either STARTTLS or [XEP-0368: SRV records for XMPP over TLS](https://xmpp.org/extensions/xep-0368.html). either STARTTLS or [XEP-0368: SRV records for XMPP over TLS](https://xmpp.org/extensions/xep-0368.html).
On rare occasions this error message might also be caused by a server not providing On rare occasions this error message might also be caused by a server not providing
a login (SASL) mechanism that Conversations Classic is able to handle. Conversations Classic supports a login (SASL) mechanism that Conversations is able to handle. Conversations supports
SCRAM-SHA1, PLAIN, EXTERNAL (client certs) and DIGEST-MD5. SCRAM-SHA1, PLAIN, EXTERNAL (client certs) and DIGEST-MD5.
#### I get 'Bind failure'. What does that mean? #### I get 'Bind failure'. What does that mean?
Some Bind failures are transient and resolve themselves after a reconnect. Some Bind failures are transient and resolve themselves after a reconnect.
When trying to connect to OpenFire the bind failure can be a permanent problem when the domain part of the Jabber ID entered in Conversations Classic doesnt match the domain the OpenFire server feels responsible for. For example OpenFire is configured to use the domain `a.tld` but the Jabber ID entered is `user@b.tld` where `b.tld` also points to the same host. During bind OpenFire tries to reassign the Jabber to `user@a.tld`. Conversations Classic doesnt like that. When trying to connect to OpenFire the bind failure can be a permanent problem when the domain part of the Jabber ID entered in Conversations doesnt match the domain the OpenFire server feels responsible for. For example OpenFire is configured to use the domain `a.tld` but the Jabber ID entered is `user@b.tld` where `b.tld` also points to the same host. During bind OpenFire tries to reassign the Jabber to `user@a.tld`. Conversations doesnt like that.
This can be fixed by creating a new account in Conversations Classic that uses the Jabber ID `user@a.tld`. This can be fixed by creating a new account in Conversations that uses the Jabber ID `user@a.tld`.
Note: This is kind of a weird quirk in OpenFire. Most other servers would just throw a 'Server not responsible for domain' error instead of attempting to reassign the Jabber ID. Note: This is kind of a weird quirk in OpenFire. Most other servers would just throw a 'Server not responsible for domain' error instead of attempting to reassign the Jabber ID.
@ -119,10 +140,16 @@ Maybe you attempted to use the Jabber ID `test@b.tld` because `a.tld` doesnt
In most cases this error is caused by ejabberd advertising support for TLSv1.3 but not properly supporting it. This can happen if the OpenSSL version on the server already supports TLSv1.3 but the fast\_tls wrapper library used by ejabberd not (properly) support it. Upgrading fast\_tls and ejabberd or - theoretically - downgrading OpenSSL should fix the issue. A work around is to explicitly disable TLSv1.3 support in the ejabberd configuration. More information can be found on [this issue on the ejabberd issue tracker](https://github.com/processone/ejabberd/issues/2614). In most cases this error is caused by ejabberd advertising support for TLSv1.3 but not properly supporting it. This can happen if the OpenSSL version on the server already supports TLSv1.3 but the fast\_tls wrapper library used by ejabberd not (properly) support it. Upgrading fast\_tls and ejabberd or - theoretically - downgrading OpenSSL should fix the issue. A work around is to explicitly disable TLSv1.3 support in the ejabberd configuration. More information can be found on [this issue on the ejabberd issue tracker](https://github.com/processone/ejabberd/issues/2614).
**The battery consumption and the entire behavior of Conversations Classic will remain the same (as good or as bad as it was before). Why is Google doing this to you? We have no idea.**
##### Android &lt;= 7.1 or Conversations Classic from F-Droid (all Android versions) #### Im getting this annoying permanent notification
The foreground notification is still controlled over the expert settings within Conversations Classic as it always has been. Whether or not you need to enable it depends on how aggressive the non-standard 'power saving' features are that your phone vendor has built into the operating system. Starting with Conversations 2.3.6 Conversations releases distributed over the Google Play Store will display a permanent notification if you are running it on Android 8 and above. This is a rule that it is essentially enforced by the Google Play Store. (You wont have the problem of a *forced* foreground notification if you are getting your app from F-Droid.)
However you can disable the notification via settings of the operating system. (Not settings in Conversations.)
**The battery consumption and the entire behavior of Conversations will remain the same (as good or as bad as it was before). Why is Google doing this to you? We have no idea.**
##### Android &lt;= 7.1 or Conversations from F-Droid (all Android versions)
The foreground notification is still controlled over the expert settings within Conversations as it always has been. Whether or not you need to enable it depends on how aggressive the non-standard 'power saving' features are that your phone vendor has built into the operating system.
##### Android 8.x ##### Android 8.x
Long press the permanent notification and disable that particular type of notification by moving the slider to the left. This will make the notification disappear but create another notification (this time created by the operating system itself.) that will complain about Conversations (and other apps) using battery. Starting with Android 8.1 you can disable that notification again with the same method described above. Long press the permanent notification and disable that particular type of notification by moving the slider to the left. This will make the notification disappear but create another notification (this time created by the operating system itself.) that will complain about Conversations (and other apps) using battery. Starting with Android 8.1 you can disable that notification again with the same method described above.
@ -130,24 +157,43 @@ Long press the permanent notification and disable that particular type of notifi
##### Android 9.0+ ##### Android 9.0+
Long press the permanent notification and press the info `(i)` button to get into the App info screen. In that screen touch the 'Notification' entry. In the next screen remove the checkbox for the 'Foreground service' entry. Long press the permanent notification and press the info `(i)` button to get into the App info screen. In that screen touch the 'Notification' entry. In the next screen remove the checkbox for the 'Foreground service' entry.
#### How do XEP-0357: Push Notifications work?
You need to be running the Play Store version of Conversations and your server needs to support push notifications.¹ Because *Googles Firebase Cloud Messaging (FCM)* are tied with an API key to a specific app your server can not initiate the push message directly. Instead your server will send the push notification to the [Conversations App server](https://github.com/iNPUTmice/p2) (operated by us) which then acts as a proxy and initiates the push message for you. The push message sent from our App server through FCM doesnt contain any personal information. It is just an empty message which will wake up your device and tell Conversations to reconnect to your server. The information sent from your server to our App server depends on the configuration of your server but can be limited to your account name. (In any case the Conversations App server won't redirect any information through FCM even if your server sends this information.)
In summary Google will never get hold of any personal information besides that *something* happened. (Which doesnt even have to be a message but can be some automated event as well.) We - as the operator of the App server - will just get hold of your account name (without being able to tie this to your specific device).
If you dont want this simply pick a server which does not offer Push Notifications or build Conversations yourself without support for push notifications. (This is available via a gradle build flavor.) Non-play store source of Conversations like the Amazon App store will also offer a version without push notifications. Conversations will just work as before and maintain its own TCP connection in the background.
You can find a detailed description of how your server, the app server and FCM are interacting with each other in the [README](https://github.com/iNPUTmice/p2/blob/master/README.md) of the Conversations App Server.
¹ If you use the Play Store version you do **not** need to run your own app server. Your server only needs to support the server side of [XEP-0357: Push Notifications](http://xmpp.org/extensions/xep-0357.html) and [XEP-0198: Stream Management](https://xmpp.org/extensions/xep-0198.html). The prosody server modules are called *mod_cloud_notify* and *mod_smacks*. The ejabberd server modules are called *mod_push* and *mod_stream_mgmt*.
#### But why do I need a permanent notification if I use Google Push?
FCM (Google Push) allows an app to wake up from *Doze* which is (as the name suggests) a hibernation feature of the Android operating system that cuts the network connection and also reduces the number of times the app is allowed to wake up (to ping the server for example). The app can ask to be excluded from doze. Non push variants of the app (from F-Droid or if the server doesnt support it) will do this on first start up. So if you get exemption from *Doze*, or if you get regular push events sent to you, Doze should not pose a threat to Conversatons working properly. But even with *Doze* the app is still open in the background (kept in memory); it is just limited in the actions it can do. Conversations needs to stay in memory to hold certain session state (online status of contacts, join status of group chats, …). However with Android 8 Google changed all of this again and now an App that wants to stay in memory needs to have a foreground service which is visible to the user via the annoying notification. But why does Conversations need to hold that state? XMPP is a statefull protocol that has a lot of per-session information; packets need to be counted, presence information needs to be held, some features like Message Carbons get activated once per session, MAM catch-up happens once, service discovery happens only once; the list goes on. When Conversations was created in early 2014 none of this was a problem because apps were just allowed to stay in memory. Basically every XMPP client out there holds that information in memory because it would be a lot more complicated trying to persist it to disk. An entire rewrite of Conversations in the year 2019 would attempt to do that and would probably succeed however it would require exactly that; a complete rewrite which is not feasible right now. Thats by the way also the reason why it is difficult to write an XMPP client on iOS. Or more broadly put this is also the reason why other protocols are designed as or migrated to stateless protocols (often based on HTTP); take for example the migration of IMAP to [JMAP](https://jmap.io/).
#### Conversations doesnt work for me. Where can I get help? #### Conversations doesnt work for me. Where can I get help?
You can join our conference room on [`xmppclient-dev@conference.narayana.im`](xmpp:xmppclient-dev@conference.narayana.im). You can join our conference room on [`conversations@conference.siacs.eu`](https://conversations.im/j/conversations@conference.siacs.eu).
A lot of people in there are able to answer basic questions about the usage of A lot of people in there are able to answer basic questions about the usage of
Conversations Classic or can provide you with tips on running your own XMPP server. If Conversations or can provide you with tips on running your own XMPP server. If
you found a bug or your app crashes please read the Developer / Report Bugs you found a bug or your app crashes please read the Developer / Report Bugs
section of this document. section of this document.
#### I need professional support with Conversations or setting up my server
I'm available for hire. Contact information can be found on [my website](https://gultsch.de).
#### How does the address book integration work? #### How does the address book integration work?
The address book integration was designed to protect your privacy. Conversations Classic The address book integration was designed to protect your privacy. Conversations
neither uploads contacts from your address book to your server nor fills your neither uploads contacts from your address book to your server nor fills your
address book with unnecessary contacts from your online roster. If you manually address book with unnecessary contacts from your online roster. If you manually
add a Jabber ID to your phones address book Conversations Classic will use the name and add a Jabber ID to your phones address book Conversations will use the name and
the profile picture of this contact. To make the process of adding Jabber IDs to the profile picture of this contact. To make the process of adding Jabber IDs to
your address book easier you can click on the profile picture in the contact your address book easier you can click on the profile picture in the contact
details within Conversations Classic. This will start an "add to address book" intent details within Conversations. This will start an "add to address book" intent
with the JID as the payload. This doesn't require Conversations Classic to have write with the JID as the payload. This doesn't require Conversations to have write
permissions on your address book but also doesn't require you to copy/paste a permissions on your address book but also doesn't require you to copy/paste a
JID from one app to another. JID from one app to another.
@ -203,23 +249,45 @@ changeable on the fly. Metrics like last active client (the client which sent
the last message) are much better. the last message) are much better.
Unfortunately these modern replacements for legacy XMPP features are not widely Unfortunately these modern replacements for legacy XMPP features are not widely
adopted. However Conversations Classic should be an instant messenger for the future and adopted. However Conversations should be an instant messenger for the future and
instead of making Conversations Classic compatible with the past we should work on instead of making Conversations compatible with the past we should work on
implementing new, improved technologies and getting them into other XMPP clients implementing new, improved technologies and getting them into other XMPP clients
as well. as well.
Making these status and priority optional isn't a solution either because Making these status and priority optional isn't a solution either because
Conversations Classic is trying to get rid of old behaviours and set an example for Conversations is trying to get rid of old behaviours and set an example for
other clients. other clients.
#### Translations
Translations are managed on [Weblate](https://translate.codeberg.org/projects/conversations/).
#### How do I backup / move Conversations Classic to a new device? You can log in with your Codeberg account and start translating.
Use the Backup button in the Settings. #### How do I backup / move Conversations to a new device?
#### Conversations Classic is missing a certain feature See the dedicated guides for
- [backups](docs/user/backup.md)
- [migrations](docs/user/migrating_to_new_device.md)
Please report it to our XMPP conference [`xmppclient-dev@conference.narayana.im`](xmpp:xmppclient-dev@conference.narayana.im) #### Conversations is missing a certain feature
I'm open for new feature suggestions. You can use the [issue tracker][https://codeberg.org/iNPUTmice/Conversations/issues]
on Codeberg. Please take some time to browse through the issues to see if someone
else already suggested it. Be assured that I read each and every ticket. If I
like it I will leave it open until it's implemented. If I don't like it I will
close it (usually with a short comment). If I don't comment on an feature
request that's probably a good sign because this means I agree with you.
Commenting with +1 on either open or closed issues won't change my mind, nor
will it accelerate the development.
#### You closed my feature request but I want it really really badly
Just write it yourself and send me a pull request. If I like it I will happily
merge it if I don't at least you and like minded people get to enjoy it.
#### I need a feature and I need it now!
I am available for hire. Find contact information on [my website](https://gultsch.de).
### Security ### Security
@ -231,7 +299,7 @@ Please report it to our XMPP conference [`xmppclient-dev@conference.narayana.im`
#### How do I use OpenPGP #### How do I use OpenPGP
Before you continue reading you should note that the OpenPGP support in Before you continue reading you should note that the OpenPGP support in
Conversations Classic is experimental. This is not because it will make the app unstable Conversations is experimental. This is not because it will make the app unstable
but because the fundamental concepts of PGP aren't ready for widespread use. but because the fundamental concepts of PGP aren't ready for widespread use.
The way PGP works is that you trust Key IDs instead of JID's or email addresses. The way PGP works is that you trust Key IDs instead of JID's or email addresses.
So in theory your contact list should consist of Public-Key-IDs instead of So in theory your contact list should consist of Public-Key-IDs instead of
@ -269,12 +337,12 @@ details and hit the settings button (the one with the gears) and select both *pr
Every participant has to announce their OpenPGP key (see answer above). Every participant has to announce their OpenPGP key (see answer above).
If you would like to send encrypted messages to a conference you have to make If you would like to send encrypted messages to a conference you have to make
sure that you have every participant's public key in your OpenKeychain. sure that you have every participant's public key in your OpenKeychain.
Right now there is no check in Conversations Classic to ensure that. Right now there is no check in Conversations to ensure that.
You have to take care of that yourself. Go to the conference details and You have to take care of that yourself. Go to the conference details and
touch every key id (The hexadecimal number below a contact). This will send you touch every key id (The hexadecimal number below a contact). This will send you
to OpenKeychain which will assist you on adding the key. This works best in to OpenKeychain which will assist you on adding the key. This works best in
very small conferences with contacts you are already using OpenPGP with. This very small conferences with contacts you are already using OpenPGP with. This
feature is regarded experimental. Conversations Classic is the only client that uses feature is regarded experimental. Conversations is the only client that uses
XEP-0027 with conferences. (The XEP neither specifically allows nor disallows XEP-0027 with conferences. (The XEP neither specifically allows nor disallows
this.) this.)
@ -282,6 +350,89 @@ this.)
Read more about the concept on https://gultsch.de/trust.html Read more about the concept on https://gultsch.de/trust.html
#### What happened to OTR support?
OTR was removed because it was highly unreliable. It didnt work with multiple devices and was never really specified to work with XMPP. The codebase was a mess (There was an HTML parser in there for crying out loud to deal with the garbage some OTR clients would send.) Verification was implemented in a non-blocking way. It would tell you if the current session was using an unknown fingerprint but it didnt actively stopped you from sending messages until you have confirmed the new fingerprint. (Like Conversations would do now with BTBV after verification or when BTBV is turned off.) Considering the previous points there was little to no desire from my point to fix this potential security issue or clean up the code base. Another reason for the removal was that people would use it *accidentally* even to communicate between two Conversations clients because they read somewhere that OTR is good.
### What clients do I use on other platforms
There are XMPP Clients available for all major platforms.
#### Windows / Linux
For your desktop computer we recommend that you use [Gajim](https://gajim.org). You need to install the `OMEMO` plugin to get the best compatibility with Conversations. Plugins can be installed from within the app, from your distribution, or from flatpak if you installed it from there.
#### iOS
Unfortunately we dont have a recommendation for iPhones right now. There are three clients available [Siskin](https://siskin.im/), [ChatSecure](https://chatsecure.org/) and [Monal](https://monal.im/). Each with their own pros and cons.
### Development
<a name="beta"></a>
#### Beta testing
If you bought the App on [Google Play](https://play.google.com/store/apps/details?id=eu.siacs.conversations)
you can get access to the the latest beta version by signing up using [this link](https://play.google.com/apps/testing/eu.siacs.conversations).
#### How do I build Conversations
Make sure to have ANDROID_HOME point to your Android SDK. Use the Android SDK Manager to install missing dependencies.
Alternatively (and to avoid thinking about environment variables), create a file called local.properties, in the root of the Conversations build tree,
with the following contents:
```
## This file must *NOT* be checked into Version Control Systems,
# as it contains information specific to your local configuration.
#
# Location of the SDK. This is only used by Gradle.
# For customization when using a Version Control System, please read the
# header note.
#Wed May 20 16:21:35 CST 2020
ndk.dir=Path-To-Ndk
sdk.dir=Path-To-Sdk
```
Then issue the following commands in order to build the apk.
git clone https://codeberg.org/iNPUTmice/Conversations.git
cd Conversations
./gradlew assembleConversationsFreeDebug
There are two build flavors available. *free* and *playstore*. Unless you know what you are doing you only need *free*.
You will find the apks in the `./build/outputs/apk/conversationsFree/debug/` directory.
Be careful, the resulting apks will not install unless you delete your existing Conversations installation (which will delete all the messages from your phone, and if you have used OMEMO, you will not be able to restore them from the server).
Do it at your own risk.
You, though, can make your own build a "test build", that can be installed alongside the normal (F-Droid or Google Play) Conversations:
In the file `build.gradle`, find the line `applicationId "eu.siacs.conversations"` , and replace it with `applicationId "my.conversations.fork"`, also below replace "Conversations" appName with "MyCFork".
Then the resulting APK can be installed ALONGSIDE normal Conversations. And have a different name so it's not confusing
WARNING: DO NOT REPLACE ANYTHING ELSE ANYWHERE ELSE, DO NOT REPLACE THIS PROJECT WIDE. JUST 2 strings in THAT specific file!
#### How do I debug Conversations
If something goes wrong Conversations usually exposes very little information in
the UI (other than the fact that something didn't work). However with adb
(android debug bridge) you can squeeze some more information out of Conversations.
These information are especially useful if you are experiencing trouble with
your connection or with file transfer.
To use adb you have to connect your mobile phone to your computer with an USB cable
and install `adb`. Most Linux systems have prebuilt packages for that tool. On
Debian/Ubuntu for example it is called `android-tools-adb`.
Furthermore you might have to enable 'USB debugging' in the Developer options of your
phone. After that you can just execute the following on your computer:
adb -d logcat -v time -s conversations
If need be there are also some Apps on the PlayStore that can be used to show the logcat
directly on your rooted phone. (Search for logcat). However in regards to further processing
(for example to create an issue here on Codeberg) it is more convenient to just use your PC.
#### I found a bug #### I found a bug
Please report it to our XMPP conference [`xmppclient-dev@conference.narayana.im`](xmpp:xmppclient-dev@conference.narayana.im). Please report it to our [issue tracker](https://codeberg.org/iNPUTmice/Conversations/issues). If your app crashes please
provide a stack trace. If you are experiencing misbehavior please provide
detailed steps to reproduce. Always mention whether you are running the latest
Play Store version or the current HEAD. If you are having problems connecting to
your XMPP server your file transfer doesnt work as expected please always
include a logcat debug output with your issue (see above).

View file

@ -0,0 +1,15 @@
apply plugin: "java-library"
java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
dependencies {
implementation project(':annotation')
annotationProcessor 'com.google.auto.service:auto-service:1.0.1'
api 'com.google.auto.service:auto-service-annotations:1.0.1'
implementation 'com.google.guava:guava:31.1-jre'
}

View file

@ -0,0 +1,160 @@
package im.conversations.android.annotation.processor;
import com.google.auto.service.AutoService;
import com.google.common.base.CaseFormat;
import com.google.common.base.Objects;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableMap;
import im.conversations.android.annotation.XmlElement;
import im.conversations.android.annotation.XmlPackage;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.Map;
import java.util.Set;
import javax.annotation.processing.AbstractProcessor;
import javax.annotation.processing.Processor;
import javax.annotation.processing.RoundEnvironment;
import javax.annotation.processing.SupportedAnnotationTypes;
import javax.annotation.processing.SupportedSourceVersion;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.Element;
import javax.lang.model.element.PackageElement;
import javax.lang.model.element.TypeElement;
import javax.tools.JavaFileObject;
@AutoService(Processor.class)
@SupportedSourceVersion(SourceVersion.RELEASE_17)
@SupportedAnnotationTypes("im.conversations.android.annotation.XmlElement")
public class XmlElementProcessor extends AbstractProcessor {
@Override
public boolean process(Set<? extends TypeElement> set, RoundEnvironment roundEnvironment) {
final Set<? extends Element> elements =
roundEnvironment.getElementsAnnotatedWith(XmlElement.class);
final ImmutableMap.Builder<Id, String> builder = ImmutableMap.builder();
for (final Element element : elements) {
if (element instanceof final TypeElement typeElement) {
final Id id = of(typeElement);
builder.put(id, typeElement.getQualifiedName().toString());
}
}
final ImmutableMap<Id, String> maps = builder.build();
if (maps.isEmpty()) {
return false;
}
final JavaFileObject extensionFile;
try {
extensionFile =
processingEnv
.getFiler()
.createSourceFile("im.conversations.android.xmpp.Extensions");
} catch (final IOException e) {
throw new RuntimeException(e);
}
try (final PrintWriter out = new PrintWriter(extensionFile.openWriter())) {
out.println("package im.conversations.android.xmpp;");
out.println("import com.google.common.collect.BiMap;");
out.println("import com.google.common.collect.ImmutableBiMap;");
out.println("import im.conversations.android.xmpp.ExtensionFactory;");
out.println("import im.conversations.android.xmpp.model.Extension;");
out.print("\n");
out.println("public final class Extensions {");
out.println(
"public static final BiMap<ExtensionFactory.Id, Class<? extends Extension>>"
+ " EXTENSION_CLASS_MAP;");
out.println("static {");
out.println(
"final var builder = new ImmutableBiMap.Builder<ExtensionFactory.Id, Class<?"
+ " extends Extension>>();");
for (final Map.Entry<Id, String> entry : maps.entrySet()) {
Id id = entry.getKey();
String clazz = entry.getValue();
out.format(
"builder.put(new ExtensionFactory.Id(\"%s\",\"%s\"),%s.class);",
id.name, id.namespace, clazz);
out.print("\n");
}
out.println("EXTENSION_CLASS_MAP = builder.build();");
out.println("}");
out.println(" private Extensions() {}");
out.println("}");
// writing generated file to out
} catch (IOException e) {
throw new RuntimeException(e);
}
return true;
}
private static Id of(final TypeElement typeElement) {
final XmlElement xmlElement = typeElement.getAnnotation(XmlElement.class);
PackageElement packageElement = getPackageElement(typeElement);
XmlPackage xmlPackage =
packageElement == null ? null : packageElement.getAnnotation(XmlPackage.class);
if (xmlElement == null) {
throw new IllegalStateException(
String.format(
"%s is not annotated as @XmlElement",
typeElement.getQualifiedName().toString()));
}
final String packageNamespace = xmlPackage == null ? null : xmlPackage.namespace();
final String elementName = xmlElement.name();
final String elementNamespace = xmlElement.namespace();
final String namespace;
if (!Strings.isNullOrEmpty(elementNamespace)) {
namespace = elementNamespace;
} else if (!Strings.isNullOrEmpty(packageNamespace)) {
namespace = packageNamespace;
} else {
throw new IllegalStateException(
String.format(
"%s does not declare a namespace",
typeElement.getQualifiedName().toString()));
}
final String name;
if (Strings.isNullOrEmpty(elementName)) {
name =
CaseFormat.UPPER_CAMEL.to(
CaseFormat.LOWER_HYPHEN, typeElement.getSimpleName().toString());
} else {
name = elementName;
}
return new Id(name, namespace);
}
private static PackageElement getPackageElement(final TypeElement typeElement) {
final Element parent = typeElement.getEnclosingElement();
if (parent instanceof PackageElement) {
return (PackageElement) parent;
} else {
final Element nextParent = parent.getEnclosingElement();
if (nextParent instanceof PackageElement) {
return (PackageElement) nextParent;
} else {
return null;
}
}
}
public static class Id {
public final String name;
public final String namespace;
public Id(String name, String namespace) {
this.name = name;
this.namespace = namespace;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Id id = (Id) o;
return Objects.equal(name, id.name) && Objects.equal(namespace, id.namespace);
}
@Override
public int hashCode() {
return Objects.hashCode(name, namespace);
}
}
}

6
annotation/build.gradle Normal file
View file

@ -0,0 +1,6 @@
apply plugin: "java-library"
java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}

View file

@ -0,0 +1,15 @@
package im.conversations.android.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.SOURCE)
@Target({ElementType.TYPE})
public @interface XmlElement {
String name() default "";
String namespace() default "";
}

View file

@ -0,0 +1,12 @@
package im.conversations.android.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.SOURCE)
@Target(ElementType.PACKAGE)
public @interface XmlPackage {
String namespace();
}

147
app/build.gradle Normal file
View file

@ -0,0 +1,147 @@
apply plugin: "com.android.application"
apply plugin: "androidx.navigation.safeargs"
apply plugin: "com.diffplug.spotless"
android {
namespace 'im.conversations.android'
compileSdk 33
defaultConfig {
minSdk 23
targetSdk 33
versionCode 1
versionName "3.0.0-alpha"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
javaCompileOptions {
annotationProcessorOptions {
arguments += ["room.schemaLocation": "$projectDir/schemas".toString()]
}
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
coreLibraryDesugaringEnabled true
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
buildFeatures {
dataBinding true
}
flavorDimensions "product"
productFlavors {
quicksy {
dimension "product"
applicationId = "im.quicksy.client"
def appName = "Quicksy"
resValue "string", "applicationId", applicationId
resValue "string", "app_name", appName
buildConfigField "String", "APP_NAME", "\"$appName\""
}
conversations {
dimension "product"
applicationId "im.conversations.android"
def appName = "Conversations"
resValue "string", "applicationId", applicationId
resValue "string", "app_name", appName
buildConfigField "String", "APP_NAME", "\"$appName\""
}
}
}
spotless {
java {
target '**/*.java'
googleJavaFormat().aosp().reflowLongStrings()
}
}
dependencies {
implementation project(':annotation')
annotationProcessor project(':annotation-processor')
// make Java 8 API available
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.3'
// Jetpack / AndroidX libraries
implementation "androidx.appcompat:appcompat:$rootProject.ext.appcompatVersion"
implementation "androidx.lifecycle:lifecycle-extensions:$rootProject.ext.lifecycleVersion"
implementation "androidx.navigation:navigation-fragment:$rootProject.ext.navVersion"
implementation "androidx.navigation:navigation-ui:$rootProject.ext.navVersion"
implementation "androidx.room:room-runtime:$rootProject.ext.roomVersion"
implementation "androidx.room:room-guava:$rootProject.ext.roomVersion"
implementation "androidx.room:room-paging:$rootProject.ext.roomVersion"
annotationProcessor "androidx.room:room-compiler:$rootProject.ext.roomVersion"
implementation "androidx.paging:paging-runtime:$rootProject.ext.pagingVersion"
implementation "androidx.preference:preference:$rootProject.ext.preferenceVersion"
implementation "androidx.security:security-crypto:1.0.0"
// Google material design libraries
implementation "com.google.android.material:material:$rootProject.ext.material"
// LeakCanary to detect memory leaks in debug builds
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10'
// crypto libraries
implementation 'org.whispersystems:signal-protocol-java:2.6.2'
implementation 'org.conscrypt:conscrypt-android:2.5.2'
implementation 'org.bouncycastle:bcmail-jdk15on:1.64'
// XMPP Address library
implementation 'org.jxmpp:jxmpp-jid:1.0.3'
// WebRTC
implementation 'im.conversations.webrtc:webrtc-android:104.0.0'
// Consistent Color Generation
implementation 'org.hsluv:hsluv:0.2'
// DNS library (XMPP needs to resolve SRV records)
implementation 'de.measite.minidns:minidns-hla:0.2.4'
// Guava
implementation 'com.google.guava:guava:31.1-android'
// HTTP library
implementation "com.squareup.okhttp3:okhttp:4.10.0"
// JSON parser
implementation 'com.google.code.gson:gson:2.10.1'
// logging framework + logging api
implementation 'org.slf4j:slf4j-api:1.7.36'
implementation 'com.github.tony19:logback-android:2.0.1'
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.robolectric:robolectric:4.9.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation "androidx.test.espresso:espresso-core:$rootProject.ext.espressoVersion"
}

21
app/proguard-rules.pro vendored Normal file
View file

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

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,214 @@
package im.conversations.android.xmpp;
import static org.hamcrest.Matchers.*;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterables;
import im.conversations.android.database.model.Account;
import im.conversations.android.database.model.StanzaId;
import im.conversations.android.transformer.MessageTransformation;
import im.conversations.android.transformer.Transformer;
import im.conversations.android.xmpp.manager.ArchiveManager;
import im.conversations.android.xmpp.model.jabber.Body;
import im.conversations.android.xmpp.model.stanza.Message;
import java.time.Instant;
import java.util.Collections;
import java.util.concurrent.ExecutionException;
import org.hamcrest.MatcherAssert;
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
@RunWith(AndroidJUnit4.class)
public class ArchivePagingTest extends BaseTransformationTest {
@Test
public void initialQuery() throws ExecutionException, InterruptedException {
final var ranges = database.archiveDao().resetLivePage(account(), ACCOUNT);
final Range range = Iterables.getOnlyElement(ranges);
Assert.assertNull(range.id);
Assert.assertEquals(Range.Order.REVERSE, range.order);
}
@Test
public void queryAfterSingleLiveMessage() throws ExecutionException, InterruptedException {
final var stub = new StubMessage(2);
transformer.transform(stub.messageTransformation(), stub.stanzaId());
final var ranges = database.archiveDao().resetLivePage(account(), ACCOUNT);
Assert.assertEquals(2, ranges.size());
MatcherAssert.assertThat(
ranges,
contains(new Range(Range.Order.REVERSE, "2"), new Range(Range.Order.NORMAL, "2")));
}
@Test
public void twoLiveMessageQueryNoSubmitAndQuery()
throws ExecutionException, InterruptedException {
final var stub2 = new StubMessage(2);
transformer.transform(stub2.messageTransformation(), stub2.stanzaId());
final var stub3 = new StubMessage(3);
transformer.transform(stub3.messageTransformation(), stub3.stanzaId());
final var ranges = database.archiveDao().resetLivePage(account(), ACCOUNT);
Assert.assertEquals(2, ranges.size());
MatcherAssert.assertThat(
ranges,
contains(new Range(Range.Order.REVERSE, "2"), new Range(Range.Order.NORMAL, "3")));
final var stub4 = new StubMessage(4);
transformer.transform(stub4.messageTransformation(), stub4.stanzaId());
final var rangesSecondAttempt = database.archiveDao().resetLivePage(account(), ACCOUNT);
Assert.assertEquals(2, rangesSecondAttempt.size());
MatcherAssert.assertThat(
rangesSecondAttempt,
contains(new Range(Range.Order.REVERSE, "2"), new Range(Range.Order.NORMAL, "3")));
}
@Test
public void liveMessageQuerySubmitAndQuery() throws ExecutionException, InterruptedException {
final var stub2 = new StubMessage(2);
transformer.transform(stub2.messageTransformation(), stub2.stanzaId());
final var stub3 = new StubMessage(3);
transformer.transform(stub3.messageTransformation(), stub3.stanzaId());
final var ranges = database.archiveDao().resetLivePage(account(), ACCOUNT);
Assert.assertEquals(2, ranges.size());
MatcherAssert.assertThat(
ranges,
contains(new Range(Range.Order.REVERSE, "2"), new Range(Range.Order.NORMAL, "3")));
final var stub4 = new StubMessage(4);
transformer.transform(stub4.messageTransformation(), stub4.stanzaId());
for (final Range range : ranges) {
database.archiveDao()
.submitPage(
account(),
ACCOUNT,
range,
new ArchiveManager.QueryResult(
true, Page.emptyWithCount(range.id, null)),
false);
}
final var rangesSecondAttempt = database.archiveDao().resetLivePage(account(), ACCOUNT);
// we mark the reversing range as complete in the submit above; hence it is not included in
// the second ranges
Assert.assertEquals(1, rangesSecondAttempt.size());
MatcherAssert.assertThat(rangesSecondAttempt, contains(new Range(Range.Order.NORMAL, "4")));
}
@Test
public void liveMessageQuerySubmitTwice() throws ExecutionException, InterruptedException {
final var stub2 = new StubMessage(2);
transformer.transform(stub2.messageTransformation(), stub2.stanzaId());
final var ranges = database.archiveDao().resetLivePage(account(), ACCOUNT);
Assert.assertEquals(2, ranges.size());
MatcherAssert.assertThat(
ranges,
contains(new Range(Range.Order.REVERSE, "2"), new Range(Range.Order.NORMAL, "2")));
final var account = account();
final var transformer =
new Transformer(account, ApplicationProvider.getApplicationContext(), database);
transformer.transform(
Collections.emptyList(),
ACCOUNT,
new Range(Range.Order.REVERSE, "2"),
new ArchiveManager.QueryResult(true, Page.emptyWithCount("2", null)),
true);
transformer.transform(
Collections.emptyList(),
ACCOUNT,
new Range(Range.Order.NORMAL, "2"),
new ArchiveManager.QueryResult(false, new Page("3", "4", 2)),
false);
transformer.transform(
Collections.emptyList(),
ACCOUNT,
new Range(Range.Order.NORMAL, "4"),
new ArchiveManager.QueryResult(true, new Page("5", "6", 2)),
false);
final var rangesSecondAttempt = database.archiveDao().resetLivePage(account(), ACCOUNT);
// we mark the reversing range as complete in the submit above; hence it is not included in
// the second ranges
Assert.assertEquals(1, rangesSecondAttempt.size());
MatcherAssert.assertThat(rangesSecondAttempt, contains(new Range(Range.Order.NORMAL, "6")));
}
@Test
public void liveMessageQuerySubmitTwiceWithDuplicates()
throws ExecutionException, InterruptedException {
final var stub2 = new StubMessage(2);
transformer.transform(stub2.messageTransformation(), stub2.stanzaId());
final var ranges = database.archiveDao().resetLivePage(account(), ACCOUNT);
Assert.assertEquals(2, ranges.size());
MatcherAssert.assertThat(
ranges,
contains(new Range(Range.Order.REVERSE, "2"), new Range(Range.Order.NORMAL, "2")));
final var account = account();
final var transformer =
new Transformer(account, ApplicationProvider.getApplicationContext(), database);
transformer.transform(
Collections.emptyList(),
ACCOUNT,
new Range(Range.Order.REVERSE, "2"),
new ArchiveManager.QueryResult(true, Page.emptyWithCount("2", null)),
true);
transformer.transform(
ImmutableList.of(stub2.messageTransformation()),
ACCOUNT,
new Range(Range.Order.NORMAL, "2"),
new ArchiveManager.QueryResult(false, new Page("3", "4", 2)),
false);
transformer.transform(
Collections.emptyList(),
ACCOUNT,
new Range(Range.Order.NORMAL, "4"),
new ArchiveManager.QueryResult(true, new Page("5", "6", 2)),
false);
}
private Account account() throws ExecutionException, InterruptedException {
return this.database.accountDao().getEnabledAccount(ACCOUNT).get();
}
private static class StubMessage {
public final int id;
private StubMessage(int id) {
this.id = id;
}
public StanzaId stanzaId() {
return new StanzaId(String.valueOf(id), ACCOUNT);
}
public MessageTransformation messageTransformation() {
final var message = new Message();
message.setTo(ACCOUNT);
message.setFrom(REMOTE);
message.addExtension(new Body()).setContent(String.format("%s (%d)", GREETING, id));
return MessageTransformation.of(
message,
Instant.ofEpochSecond(id * 2000L),
REMOTE,
String.valueOf(id),
message.getFrom().asBareJid(),
null);
}
}
}

View file

@ -0,0 +1,42 @@
package im.conversations.android.xmpp;
import android.content.Context;
import androidx.room.Room;
import androidx.test.core.app.ApplicationProvider;
import im.conversations.android.IDs;
import im.conversations.android.database.ConversationsDatabase;
import im.conversations.android.database.entity.AccountEntity;
import im.conversations.android.transformer.Transformer;
import java.util.concurrent.ExecutionException;
import org.junit.Before;
import org.jxmpp.jid.BareJid;
import org.jxmpp.jid.impl.JidCreate;
public abstract class BaseTransformationTest {
protected static final BareJid ACCOUNT = JidCreate.bareFromOrThrowUnchecked("user@example.com");
protected static final BareJid REMOTE =
JidCreate.bareFromOrThrowUnchecked("juliet@example.com");
protected static final BareJid REMOTE_2 =
JidCreate.bareFromOrThrowUnchecked("romeo@example.com");
protected static final String GREETING = "Hi Juliet. How are you?";
protected ConversationsDatabase database;
protected Transformer transformer;
@Before
public void setupTransformer() throws ExecutionException, InterruptedException {
final Context context = ApplicationProvider.getApplicationContext();
this.database = Room.inMemoryDatabaseBuilder(context, ConversationsDatabase.class).build();
final var account = new AccountEntity();
account.address = ACCOUNT;
account.enabled = true;
account.randomSeed = IDs.seed();
final long id = database.accountDao().insert(account);
this.transformer =
new Transformer(
database.accountDao().getEnabledAccount(id).get(), context, database);
}
}

View file

@ -0,0 +1,555 @@
package im.conversations.android.xmpp;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.google.common.collect.Iterables;
import im.conversations.android.database.model.Encryption;
import im.conversations.android.database.model.MessageEmbedded;
import im.conversations.android.database.model.Modification;
import im.conversations.android.database.model.PartType;
import im.conversations.android.transformer.MessageTransformation;
import im.conversations.android.xmpp.model.correction.Replace;
import im.conversations.android.xmpp.model.jabber.Body;
import im.conversations.android.xmpp.model.reactions.Reaction;
import im.conversations.android.xmpp.model.reactions.Reactions;
import im.conversations.android.xmpp.model.receipts.Received;
import im.conversations.android.xmpp.model.reply.Reply;
import im.conversations.android.xmpp.model.retract.Retract;
import im.conversations.android.xmpp.model.stanza.Message;
import java.time.Instant;
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.jxmpp.jid.impl.JidCreate;
import org.jxmpp.jid.parts.Resourcepart;
import org.jxmpp.stringprep.XmppStringprepException;
@RunWith(AndroidJUnit4.class)
public class MessageTransformationTest extends BaseTransformationTest {
@Test
public void reactionBeforeOriginal() throws XmppStringprepException {
final var reactionMessage = new Message();
reactionMessage.setId("2");
reactionMessage.setTo(ACCOUNT);
reactionMessage.setFrom(JidCreate.fullFrom(REMOTE, Resourcepart.from("junit")));
final var reactions = reactionMessage.addExtension(new Reactions());
reactions.setId("1");
final var reaction = reactions.addExtension(new Reaction());
reaction.setContent("Y");
this.transformer.transform(
MessageTransformation.of(
reactionMessage,
Instant.now(),
REMOTE,
"stanza-b",
reactionMessage.getFrom().asBareJid(),
null));
final var originalMessage = new Message();
originalMessage.setId("1");
originalMessage.setTo(REMOTE);
originalMessage.setFrom(JidCreate.fullFrom(ACCOUNT, Resourcepart.from(("junit"))));
final var body = originalMessage.addExtension(new Body());
body.setContent(GREETING);
this.transformer.transform(
MessageTransformation.of(
originalMessage,
Instant.now(),
REMOTE,
"stanza-a",
originalMessage.getFrom().asBareJid(),
null));
final var messages = database.messageDao().getMessagesForTesting(1L);
Assert.assertEquals(1, messages.size());
final var message = Iterables.getOnlyElement(messages);
final var onlyContent = Iterables.getOnlyElement(message.contents);
Assert.assertEquals(GREETING, onlyContent.body);
Assert.assertEquals(Encryption.CLEARTEXT, message.encryption);
final var onlyReaction = Iterables.getOnlyElement(message.reactions);
Assert.assertEquals("Y", onlyReaction.reaction);
Assert.assertEquals(REMOTE, onlyReaction.reactionBy);
}
@Test
public void multipleReactions() throws XmppStringprepException {
final var group = JidCreate.bareFrom("a@group.example.com");
final var message = new Message(Message.Type.GROUPCHAT);
message.addExtension(new Body("Please give me a thumbs up"));
message.setFrom(JidCreate.fullFrom(group, Resourcepart.from("user-a")));
this.transformer.transform(
MessageTransformation.of(
message, Instant.now(), REMOTE, "stanza-a", null, "id-user-a"));
final var reactionA = new Message(Message.Type.GROUPCHAT);
reactionA.setFrom(JidCreate.fullFrom(group, Resourcepart.from("user-b")));
reactionA.addExtension(Reactions.to("stanza-a")).addExtension(new Reaction("Y"));
this.transformer.transform(
MessageTransformation.of(
reactionA, Instant.now(), REMOTE, "stanza-b", null, "id-user-b"));
final var reactionB = new Message(Message.Type.GROUPCHAT);
reactionB.setFrom(JidCreate.fullFrom(group, Resourcepart.from("user-c")));
reactionB.addExtension(Reactions.to("stanza-a")).addExtension(new Reaction("Y"));
this.transformer.transform(
MessageTransformation.of(
reactionB, Instant.now(), REMOTE, "stanza-c", null, "id-user-c"));
final var reactionC = new Message(Message.Type.GROUPCHAT);
reactionC.setFrom(JidCreate.fullFrom(group, Resourcepart.from("user-d")));
final var reactions = reactionC.addExtension(Reactions.to("stanza-a"));
reactions.addExtension(new Reaction("Y"));
reactions.addExtension(new Reaction("Z"));
this.transformer.transform(
MessageTransformation.of(
reactionC, Instant.now(), REMOTE, "stanza-d", null, "id-user-d"));
final var messages = database.messageDao().getMessagesForTesting(1L);
Assert.assertEquals(1, messages.size());
final var dbMessage = Iterables.getOnlyElement(messages);
Assert.assertEquals(4, dbMessage.reactions.size());
final var aggregated = dbMessage.getAggregatedReactions();
final var mostFrequentReaction = Iterables.get(aggregated, 0);
Assert.assertEquals("Y", mostFrequentReaction.getKey());
Assert.assertEquals(3L, (long) mostFrequentReaction.getValue());
final var secondReaction = Iterables.get(aggregated, 1);
Assert.assertEquals("Z", secondReaction.getKey());
Assert.assertEquals(1L, (long) secondReaction.getValue());
}
@Test
public void correctionBeforeOriginal() throws XmppStringprepException {
final var messageCorrection = new Message();
messageCorrection.setId("2");
messageCorrection.setTo(ACCOUNT);
messageCorrection.setFrom(JidCreate.fullFrom(REMOTE, Resourcepart.from("junit")));
messageCorrection.addExtension(new Body()).setContent("Hi example!");
messageCorrection.addExtension(new Replace()).setId("1");
this.transformer.transform(
MessageTransformation.of(
messageCorrection,
Instant.now(),
REMOTE,
"stanza-a",
messageCorrection.getFrom().asBareJid(),
null));
// the correction should not show up as a message
Assert.assertEquals(0, database.messageDao().getMessagesForTesting(1L).size());
final var messageWithTypo = new Message();
messageWithTypo.setId("1");
messageWithTypo.setTo(ACCOUNT);
messageWithTypo.setFrom(JidCreate.fullFrom(REMOTE, Resourcepart.from("junit")));
messageWithTypo.addExtension(new Body()).setContent("Hii example!");
this.transformer.transform(
MessageTransformation.of(
messageWithTypo,
Instant.now(),
REMOTE,
"stanza-b",
messageWithTypo.getFrom().asBareJid(),
null));
final var messages = database.messageDao().getMessagesForTesting(1L);
Assert.assertEquals(1, messages.size());
final var message = Iterables.getOnlyElement(messages);
final var onlyContent = Iterables.getOnlyElement(message.contents);
Assert.assertEquals(Modification.CORRECTION, message.modification);
Assert.assertEquals("Hi example!", onlyContent.body);
}
@Test
public void correctionAfterOriginal() throws XmppStringprepException {
final var messageWithTypo = new Message();
messageWithTypo.setId("1");
messageWithTypo.setTo(ACCOUNT);
messageWithTypo.setFrom(JidCreate.fullFrom(REMOTE, Resourcepart.from("junit")));
messageWithTypo.addExtension(new Body()).setContent("Hii example!");
this.transformer.transform(
MessageTransformation.of(
messageWithTypo,
Instant.now(),
REMOTE,
"stanza-a",
messageWithTypo.getFrom().asBareJid(),
null));
Assert.assertEquals(1, database.messageDao().getMessagesForTesting(1L).size());
final var messageCorrection = new Message();
messageCorrection.setId("2");
messageCorrection.setTo(ACCOUNT);
messageCorrection.setFrom(JidCreate.fullFrom(REMOTE, Resourcepart.from("junit")));
messageCorrection.addExtension(new Body()).setContent("Hi example!");
messageCorrection.addExtension(new Replace()).setId("1");
this.transformer.transform(
MessageTransformation.of(
messageCorrection,
Instant.now(),
REMOTE,
"stanza-b",
messageCorrection.getFrom().asBareJid(),
null));
final var messages = database.messageDao().getMessagesForTesting(1L);
Assert.assertEquals(1, messages.size());
final var message = Iterables.getOnlyElement(messages);
final var onlyContent = Iterables.getOnlyElement(message.contents);
Assert.assertEquals(Modification.CORRECTION, message.modification);
Assert.assertEquals("Hi example!", onlyContent.body);
}
@Test
public void replacingReactions() throws XmppStringprepException {
final var group = JidCreate.bareFrom("a@group.example.com");
final var message = new Message(Message.Type.GROUPCHAT);
message.addExtension(new Body("Please give me a thumbs up"));
message.setFrom(JidCreate.fullFrom(group, Resourcepart.from("user-a")));
this.transformer.transform(
MessageTransformation.of(
message, Instant.now(), REMOTE, "stanza-a", null, "id-user-a"));
final var reactionA = new Message(Message.Type.GROUPCHAT);
reactionA.setFrom(JidCreate.fullFrom(group, Resourcepart.from("user-b")));
reactionA.addExtension(Reactions.to("stanza-a")).addExtension(new Reaction("N"));
this.transformer.transform(
MessageTransformation.of(
reactionA, Instant.now(), REMOTE, "stanza-b", null, "id-user-b"));
final var reactionB = new Message(Message.Type.GROUPCHAT);
reactionB.setFrom(JidCreate.fullFrom(group, Resourcepart.from("user-b")));
reactionB.addExtension(Reactions.to("stanza-a")).addExtension(new Reaction("Y"));
this.transformer.transform(
MessageTransformation.of(
reactionB, Instant.now(), REMOTE, "stanza-c", null, "id-user-b"));
final var messages = database.messageDao().getMessagesForTesting(1L);
Assert.assertEquals(1, messages.size());
final var dbMessage = Iterables.getOnlyElement(messages);
Assert.assertEquals(1, dbMessage.reactions.size());
}
@Test
public void twoCorrectionsOneReactionBeforeOriginalInGroupChat()
throws XmppStringprepException {
final var group = JidCreate.bareFrom("a@group.example.com");
final var ogStanzaId = "og-stanza-id";
final var ogMessageId = "og-message-id";
// first correction
final var m1 = new Message(Message.Type.GROUPCHAT);
// m1.setId(ogMessageId);
m1.addExtension(new Body("Please give me an thumbs up"));
m1.addExtension(new Replace()).setId(ogMessageId);
m1.setFrom(JidCreate.fullFrom(group, Resourcepart.from("user-a")));
this.transformer.transform(
MessageTransformation.of(
m1,
Instant.ofEpochMilli(2000),
REMOTE,
"irrelevant-stanza-id1",
null,
"id-user-a"));
// second correction
final var m2 = new Message(Message.Type.GROUPCHAT);
// m2.setId(ogMessageId);
m2.addExtension(new Body("Please give me a thumbs up"));
m2.addExtension(new Replace()).setId(ogMessageId);
m2.setFrom(JidCreate.fullFrom(group, Resourcepart.from("user-a")));
this.transformer.transform(
MessageTransformation.of(
m2,
Instant.ofEpochMilli(3000),
REMOTE,
"irrelevant-stanza-id2",
null,
"id-user-a"));
// a reaction
final var reactionB = new Message(Message.Type.GROUPCHAT);
reactionB.setFrom(JidCreate.fullFrom(group, Resourcepart.from("user-b")));
reactionB.addExtension(Reactions.to(ogStanzaId)).addExtension(new Reaction("Y"));
this.transformer.transform(
MessageTransformation.of(
reactionB,
Instant.now(),
REMOTE,
"irrelevant-stanza-id3",
null,
"id-user-b"));
// the original message
final var m4 = new Message(Message.Type.GROUPCHAT);
m4.setId(ogMessageId);
m4.addExtension(new Body("Please give me thumbs up"));
m4.setFrom(JidCreate.fullFrom(group, Resourcepart.from("user-a")));
this.transformer.transform(
MessageTransformation.of(
m4, Instant.ofEpochMilli(1000), REMOTE, ogStanzaId, null, "id-user-a"));
final var messages = database.messageDao().getMessagesForTesting(1L);
Assert.assertEquals(1, messages.size());
final var dbMessage = Iterables.getOnlyElement(messages);
Assert.assertEquals(1, dbMessage.reactions.size());
Assert.assertEquals(Modification.CORRECTION, dbMessage.modification);
Assert.assertEquals(
"Please give me a thumbs up", Iterables.getOnlyElement(dbMessage.contents).body);
}
@Test
public void twoReactionsOneCorrectionBeforeOriginalInGroupChat()
throws XmppStringprepException {
final var group = JidCreate.bareFrom("a@group.example.com");
final var ogStanzaId = "og-stanza-id";
final var ogMessageId = "og-message-id";
// first reaction
final var reactionA = new Message(Message.Type.GROUPCHAT);
reactionA.setFrom(JidCreate.fullFrom(group, Resourcepart.from("user-b")));
reactionA.addExtension(Reactions.to(ogStanzaId)).addExtension(new Reaction("Y"));
this.transformer.transform(
MessageTransformation.of(
reactionA,
Instant.now(),
REMOTE,
"irrelevant-stanza-id1",
null,
"id-user-b"));
// second reaction
final var reactionB = new Message(Message.Type.GROUPCHAT);
reactionB.setFrom(JidCreate.fullFrom(group, Resourcepart.from("user-c")));
reactionB.addExtension(Reactions.to(ogStanzaId)).addExtension(new Reaction("Y"));
this.transformer.transform(
MessageTransformation.of(
reactionB,
Instant.now(),
REMOTE,
"irrelevant-stanza-id2",
null,
"id-user-c"));
// a correction
final var m1 = new Message(Message.Type.GROUPCHAT);
m1.addExtension(new Body("Please give me a thumbs up"));
m1.addExtension(new Replace()).setId(ogMessageId);
m1.setFrom(JidCreate.fullFrom(group, Resourcepart.from("user-a")));
this.transformer.transform(
MessageTransformation.of(
m1,
Instant.ofEpochMilli(2000),
REMOTE,
"irrelevant-stanza-id3",
null,
"id-user-a"));
// the original message
final var m4 = new Message(Message.Type.GROUPCHAT);
m4.setId(ogMessageId);
m4.addExtension(new Body("Please give me thumbs up (Typo)"));
m4.setFrom(JidCreate.fullFrom(group, Resourcepart.from("user-a")));
this.transformer.transform(
MessageTransformation.of(
m4, Instant.ofEpochMilli(1000), REMOTE, ogStanzaId, null, "id-user-a"));
final var messages = database.messageDao().getMessagesForTesting(1L);
Assert.assertEquals(1, messages.size());
final var dbMessage = Iterables.getOnlyElement(messages);
Assert.assertEquals(2, dbMessage.reactions.size());
final var onlyReaction = Iterables.getOnlyElement(dbMessage.getAggregatedReactions());
Assert.assertEquals(2L, (long) onlyReaction.getValue());
Assert.assertEquals(Modification.CORRECTION, dbMessage.modification);
Assert.assertEquals(
"Please give me a thumbs up", Iterables.getOnlyElement(dbMessage.contents).body);
}
@Test
public void twoReactionsInGroupChat() throws XmppStringprepException {
final var group = JidCreate.bareFrom("a@group.example.com");
final var ogStanzaId = "og-stanza-id";
final var ogMessageId = "og-message-id";
// the original message
final var m4 = new Message(Message.Type.GROUPCHAT);
m4.setId(ogMessageId);
m4.addExtension(new Body("Please give me a thumbs up"));
m4.setFrom(JidCreate.fullFrom(group, Resourcepart.from("user-a")));
this.transformer.transform(
MessageTransformation.of(
m4, Instant.ofEpochMilli(1000), REMOTE, ogStanzaId, null, "id-user-a"));
// first reaction
final var reactionA = new Message(Message.Type.GROUPCHAT);
reactionA.setFrom(JidCreate.fullFrom(group, Resourcepart.from("user-b")));
reactionA.addExtension(Reactions.to(ogStanzaId)).addExtension(new Reaction("Y"));
this.transformer.transform(
MessageTransformation.of(
reactionA,
Instant.now(),
REMOTE,
"irrelevant-stanza-id1",
null,
"id-user-b"));
// second reaction
final var reactionB = new Message(Message.Type.GROUPCHAT);
reactionB.setFrom(JidCreate.fullFrom(group, Resourcepart.from("user-c")));
reactionB.addExtension(Reactions.to(ogStanzaId)).addExtension(new Reaction("Y"));
this.transformer.transform(
MessageTransformation.of(
reactionB,
Instant.now(),
REMOTE,
"irrelevant-stanza-id2",
null,
"id-user-c"));
final var messages = database.messageDao().getMessagesForTesting(1L);
Assert.assertEquals(1, messages.size());
final var dbMessage = Iterables.getOnlyElement(messages);
Assert.assertEquals(2, dbMessage.reactions.size());
final var onlyReaction = Iterables.getOnlyElement(dbMessage.getAggregatedReactions());
Assert.assertEquals(2L, (long) onlyReaction.getValue());
Assert.assertEquals(Modification.ORIGINAL, dbMessage.modification);
Assert.assertEquals(
"Please give me a thumbs up", Iterables.getOnlyElement(dbMessage.contents).body);
}
@Test
public void inReplyTo() throws XmppStringprepException {
final var m1 = new Message();
m1.setId("1");
m1.setTo(ACCOUNT);
m1.setFrom(JidCreate.fullFrom(REMOTE, Resourcepart.from("junit")));
m1.addExtension(new Body("Hi. How are you?"));
this.transformer.transform(
MessageTransformation.of(
m1, Instant.now(), REMOTE, "stanza-a", m1.getFrom().asBareJid(), null));
final var m2 = new Message();
m2.setId("2");
m2.setTo(REMOTE);
m2.setFrom(ACCOUNT);
m2.addExtension(new Body("I am fine."));
final var reply = m2.addExtension(new Reply());
reply.setId("1");
reply.setTo(REMOTE);
this.transformer.transform(
MessageTransformation.of(
m2, Instant.now(), REMOTE, "stanza-b", m2.getFrom().asBareJid(), null));
final var messages = database.messageDao().getMessagesForTesting(1L);
Assert.assertEquals(2, messages.size());
final var response = Iterables.get(messages, 1);
Assert.assertNotNull(response.inReplyToMessageEntityId);
final MessageEmbedded embeddedMessage = response.inReplyTo;
Assert.assertNotNull(embeddedMessage);
Assert.assertEquals(REMOTE, embeddedMessage.fromBare);
Assert.assertEquals(1L, embeddedMessage.contents.size());
Assert.assertEquals(
"Hi. How are you?", Iterables.getOnlyElement(embeddedMessage.contents).body);
Assert.assertNull(response.identityKey);
Assert.assertNull(response.trust);
}
@Test
public void messageWithReceipt() throws XmppStringprepException {
final var m1 = new Message();
m1.setId("1");
m1.setTo(REMOTE);
m1.setFrom(JidCreate.fullFrom(ACCOUNT, Resourcepart.from("junit")));
m1.addExtension(new Body("Hi. How are you?"));
this.transformer.transform(
MessageTransformation.of(
m1, Instant.now(), REMOTE, null, m1.getFrom().asBareJid(), null));
final var m2 = new Message();
m2.setTo(JidCreate.fullFrom(ACCOUNT, Resourcepart.from("junit")));
m2.setFrom(JidCreate.fullFrom(REMOTE, Resourcepart.from("junit")));
m2.addExtension(new Received()).setId("1");
this.transformer.transform(
MessageTransformation.of(
m2, Instant.now(), REMOTE, null, m2.getFrom().asBareJid(), null));
final var messages = database.messageDao().getMessagesForTesting(1L);
final var message = Iterables.getOnlyElement(messages);
Assert.assertEquals(1L, message.states.size());
}
@Test
public void messageAndRetraction() throws XmppStringprepException {
final var m1 = new Message();
m1.setTo(ACCOUNT);
m1.setFrom(JidCreate.fullFrom(REMOTE, Resourcepart.from("junit")));
m1.setId("m1");
m1.addExtension(new Body("It is raining outside"));
this.transformer.transform(
MessageTransformation.of(
m1, Instant.now(), REMOTE, null, m1.getFrom().asBareJid(), null));
final var m2 = new Message();
m2.setTo(ACCOUNT);
m2.setFrom(JidCreate.fullFrom(REMOTE, Resourcepart.from("junit")));
m2.addExtension(new Retract()).setId("m1");
this.transformer.transform(
MessageTransformation.of(
m2, Instant.now(), REMOTE, null, m2.getFrom().asBareJid(), null));
final var messages = database.messageDao().getMessagesForTesting(1L);
final var message = Iterables.getOnlyElement(messages);
Assert.assertEquals(Modification.RETRACTION, message.modification);
Assert.assertEquals(
PartType.RETRACTION, Iterables.getOnlyElement(message.contents).partType);
}
@Test
public void twoChatThreeMessages() throws XmppStringprepException {
final var m1 = new Message();
m1.setId("1");
m1.setTo(REMOTE);
m1.setFrom(JidCreate.fullFrom(ACCOUNT, Resourcepart.from("junit")));
m1.addExtension(new Body("Hi. How are you?"));
this.transformer.transform(
MessageTransformation.of(
m1, Instant.now(), REMOTE, null, m1.getFrom().asBareJid(), null));
final var m2 = new Message();
m2.setId("2");
m2.setTo(REMOTE);
m2.setFrom(JidCreate.fullFrom(ACCOUNT, Resourcepart.from("junit")));
m2.addExtension(new Body("Please answer"));
this.transformer.transform(
MessageTransformation.of(
m2, Instant.now(), REMOTE, null, m2.getFrom().asBareJid(), null));
final var m3 = new Message();
m3.setId("3");
m3.setTo(REMOTE_2);
m3.setFrom(JidCreate.fullFrom(ACCOUNT, Resourcepart.from("junit")));
m3.addExtension(new Body("Another message"));
this.transformer.transform(
MessageTransformation.of(
m3, Instant.now(), REMOTE, null, m3.getFrom().asBareJid(), null));
}
}

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:bottom="2dp"
android:end="4dp"
android:start="4dp"
android:top="2dp">
<shape>
<solid android:color="?colorSurfaceVariant" />
<corners android:radius="12dp" />
</shape>
</item>
</layer-list>

View file

@ -0,0 +1,24 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="1146.7721"
android:viewportHeight="1146.7721">
<group android:translateX="322.69516"
android:translateY="317.38605">
<path
android:pathData="M253.219,17.719C126.144,17.719 22.469,118.884 22.469,243.75C22.469,368.616 126.138,469.844 253.219,469.844C292.739,469.844 323.216,461.736 358,449.094L468.469,493.625A14.556,14.562 0,0 0,488.063 476.625L458.125,355.656C477.356,321.886 483.938,283.416 483.938,243.75C483.938,118.887 380.293,17.719 253.219,17.719zM143.844,222C157.651,222 168.844,233.193 168.844,247C168.844,260.807 157.651,272 143.844,272C130.037,272 118.844,260.807 118.844,247C118.844,233.193 130.037,222 143.844,222zM253.563,222C267.37,222 278.563,233.193 278.563,247C278.563,260.807 267.37,272 253.563,272C239.755,272 228.563,260.807 228.563,247C228.563,233.193 239.755,222 253.563,222zM363.563,222C377.37,222 388.563,233.193 388.563,247C388.563,260.807 377.37,272 363.563,272C349.755,272 338.563,260.807 338.563,247C338.563,233.193 349.755,222 363.563,222z"
android:fillColor="#ffffff"
android:strokeColor="#00000000"
android:fillAlpha="1"/>
<path
android:pathData="M478.641,484.856 L447.361,358.245c19.89,-31.998 26.743,-69.572 26.743,-109.762 0,-116.817 -96.799,-211.484 -216.184,-211.484 -119.384,0 -216.184,94.667 -216.184,211.484 0,116.817 96.799,211.554 216.184,211.554 39.636,0 68.588,-8.142 105.194,-21.761z"
android:strokeAlpha="0"
android:strokeLineJoin="round"
android:strokeWidth="23.55835724"
android:fillColor="#00000000"
android:strokeColor="#000000"
android:fillAlpha="0"
android:strokeLineCap="butt"/>
</group>
</vector>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

View file

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

View file

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

View file

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View file

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

View file

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

View file

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

View file

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="pick_a_server">Vælg din XMPP-udbyder</string>
<string name="use_conversations.im">Brug conversations.im</string>
<string name="create_new_account">Opret ny konto</string>
<string name="do_you_have_an_account">Har du allerede en XMPP-konto? Dette kan være tilfældet, hvis du allerede bruger en anden XMPP-klient eller har brugt Conversations før. Hvis ikke, kan du lige nu oprette en ny XMPP-konto.\nTip: Nogle e-mail-udbydere leverer også XMPP-konti.</string>
<string name="server_select_text">XMPP er et udbyderuafhængigt onlinemeddelelsesnetværk. Du kan bruge denne klient med hvilken XMPP-server du end vælger.\nMen for din nemhedsskyld har vi gjort vi det let at oprette en konto på conversations.im; en udbyder, der er specielt velegnet til brug med Conversations.</string>
<string name="magic_create_text_on_x">Du er blevet inviteret til %1$s. Vi guider dig gennem processen med at oprette en konto.\nNår du vælger %1$s som udbyder, kan du kommunikere med brugere fra andre udbydere ved at give dem din fulde XMPP-adresse.</string>
<string name="magic_create_text_fixed">Du er blevet inviteret til %1$s. Der er allerede valgt et brugernavn til dig. Vi guider dig gennem processen med at oprette en konto.\nDu vil være i stand til at kommunikere med brugere fra andre udbydere ved at give dem din fulde XMPP-adresse.</string>
<string name="your_server_invitation">Din server invitation</string>
<string name="improperly_formatted_provisioning">Forkert formateret klargøringskode</string>
<string name="tap_share_button_send_invite">Tryk på deleknappen for at sende din kontakt en invitation til %1$s.</string>
<string name="if_contact_is_nearby_use_qr">Hvis din kontakt er i nærheden, kan de også skanne koden nedenfor for at acceptere din invitation.</string>
<string name="easy_invite_share_text">Deltag med %1$s og chat med mig: %2$s</string>
<string name="share_invite_with">Del invitation med...</string>
</resources>

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="pick_a_server">Επιλέξτε τον πάροχο XMPP σας</string>
<string name="use_conversations.im">Χρήση του conversations.im</string>
<string name="create_new_account">Δημιουργία νέου λογαριασμού</string>
<string name="do_you_have_an_account">Έχετε ήδη λογαριασμό XMPP; Αυτό μπορεί να συμβαίνει αν ήδη χρησιμοποιείτε ένα άλλο πρόγραμμα XMPP ή έχετε χρησιμοποιήσει το Conversations παλιότερα. Αν όχι, μπορείτε να δημιουργήσετε ένα νέο λογαριασμό XMPP τώρα.\nΧρήσιμη πληροφορία: Κάποιοι πάροχοι e-mail παρέχουν επίσης και λογαριασμούς XMPP.</string>
<string name="server_select_text">Το XMPP είναι ένα δίκτυο άμεσης ανταλλαγής μηνυμάτων ανεξάρτητο παρόχου. Μπορείτε να χρησιμοποιήσετε αυτό το πρόγραμμα με όποιον διακομιστή XMPP επιθυμείτε.\nΓια διευκόλυνση πάντως μπορείτε να δημιουργήσετε έναν λογαριασμό στο conversations.im, έναν πάροχο ειδικά σχεδιασμένο για χρήση με το Conversations.</string>
<string name="magic_create_text_on_x">Έχετε προσκληθεί στο %1$s. Θα σας καθοδηγήσουμε στη διαδικασία δημιουργίας λογαριασμού.\nΕπιλέγοντας τον %1$s ως πάροχο θα μπορείτε να επικοινωνείτε με χρήστες άλλων παρόχων δίνοντάς τους την πλήρη διεύθυνση XMPP σας.</string>
<string name="magic_create_text_fixed">Έχετε προσκληθεί στο %1$s. Ένα όνομα χρήστη έχει ήδη επιλεγεί για εσάς. Θα σας καθοδηγήσουμε στη διαδικασία δημιουργίας λογαριασμού.\nΘα μπορείτε να επικοινωνείτε με χρήστες άλλων παρόχων δίνοντάς τους την πλήρη διεύθυνση XMPP σας.</string>
<string name="your_server_invitation">Η πρόσκλησή σας στον διακομιστή</string>
<string name="improperly_formatted_provisioning">Λάθος μορφοποίηση κώδικα παροχής</string>
<string name="tap_share_button_send_invite">Πατήστε το πλήκτρο διαμοιρασμού για να στείλετε στην επαφή σας μια πρόσκληση στο %1$s.</string>
<string name="if_contact_is_nearby_use_qr">Αν η επαφή σας βρίσκεται κοντά σας, μπορεί επίσης να σαρώσει τον κωδικό παρακάτω για να αποδεχτεί την πρόσκλησή σας.</string>
<string name="easy_invite_share_text">Μπείτε στο %1$s και συνομιλήστε μαζί μου: %2$s</string>
<string name="share_invite_with">Διαμοιρασμός πρόσκλησης με...</string>
</resources>

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="pick_a_server">Choisissez votre fournisseur XMPP</string>
<string name="use_conversations.im">Utiliser conversations.im</string>
<string name="create_new_account">Créer un nouveau compte</string>
<string name="do_you_have_an_account">Avez-vous déjà un compte XMPP? Cela peut être le cas si vous utilisez déjà un autre client XMPP ou si vous avez déjà utilisé Conversations auparavant. Sinon, vous pouvez créer un nouveau compte XMPP dès maintenant.\nRemarque : Certains fournisseurs de messagerie proposent également des comptes XMPP.</string>
<string name="server_select_text">XMPP est un réseau de messagerie instantanée indépendant du fournisseur. Vous pouvez utiliser ce client avec nimporte quel serveur XMPP de votre choix.\nToutefois, pour votre commodité, nous avons facilité la création dun compte sur conversations.im; un fournisseur spécialement conçu pour Conversations.</string>
<string name="magic_create_text_on_x">Vous avez été invité à %1$s. Nous allons vous guider à travers le processus de création dun compte.\nEn choisissant %1$s comme fournisseur, vous pourrez communiquer avec les utilisateurs des autres fournisseurs en leur donnant votre adresse XMPP complète.</string>
<string name="magic_create_text_fixed">Vous avez été invité à %1$s. Un nom dutilisateur a déjà été choisi pour vous. Nous allons vous guider à travers le processus de création dun compte.\nVous pourrez communiquer avec les utilisateurs des autres fournisseurs en leur donnant votre adresse XMPP complète.</string>
<string name="your_server_invitation">Votre invitation au serveur</string>
<string name="improperly_formatted_provisioning">Code de provisionnement mal formaté</string>
<string name="tap_share_button_send_invite">Appuyez sur le bouton partager pour envoyer à votre contact une invitation pour %1$s</string>
<string name="if_contact_is_nearby_use_qr">Si vos contacts sont à votre côté, ils peuvent aussi scanner le code ci dessous pour accepter votre invitation</string>
<string name="easy_invite_share_text">Rejoignez %1$set discutez avec moi : %2$s</string>
<string name="share_invite_with">Partager une invitation avec ...</string>
</resources>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="pick_a_server">Vyberte si svojho XMPP poskytovateľa</string>
<string name="use_conversations.im">Použiť conversations.im</string>
<string name="create_new_account">Vytvoriť nové konto</string>
<string name="do_you_have_an_account">Máte už svoje XMPP konto? Môže to tak byť v prípade, že už používate iného klienta XMPP alebo ste predtým používali Conversations. Ak nie, môžete si vytvoriť nové XMPP konto práve teraz.\nHint: Niektorí poskytovatelia emailu zároveň poskytujú aj XMPP kontá.</string>
<string name="server_select_text">XMPP je sieť pre okamžité správy nezávislá od poskytovateľa. Tohto klienta môžete používať s akýmkoľvek XMPP serverom, ktorý si vyberiete..\nAvšak pre vaše pohodlie sme zjednodušili vytvorenie konta na conversations.im; poskytovateľ špeciálne vhodný na používanie s Conversations.</string>
<string name="magic_create_text_on_x">Boli ste pozvaný do %1$s. Prevedieme vás procesom vytvorenia konta..\nPo výbere %1$s ako poskytovateľa, budete môcť komunikovať s užívateľmi iných poskytovateľov tak, že im dáte vašu úplnú XMPP adresu.</string>
<string name="magic_create_text_fixed">Boli ste pozvaný do %1$s . Užívateľské meno vám už bolo vopred vybrané. Prevedieme vás procesom vytvorenia konta..\nBudete môcť komunikovať s užívateľmi iných poskytovateľov tak, že im dáte vašu úplnú XMPP adresu.</string>
<string name="tap_share_button_send_invite">Ťuknite na tlačidlo zdieľať na odoslanie pozvánky do %1$s vášmu kontaktu.</string>
<string name="if_contact_is_nearby_use_qr">Ak je váš kontakt blízko, na prijatie vašej pozvánky si môže nasnímať kód nižšie.</string>
<string name="easy_invite_share_text">Pripojte sa k %1$sa rozprávajte sa so mnou: %2$s</string>
<string name="share_invite_with">Zdieľať pozvánku s...</string>
</resources>

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="pick_a_server">XMPP sağlayıcınızı seçin</string>
<string name="use_conversations.im">conversations.im kullan</string>
<string name="create_new_account">Yeni hesap oluştur</string>
<string name="do_you_have_an_account">Zaten bir XMPP hesabınız var mı? Bunun sebebi, zaten başka bir XMPP istemcisi kullanıyor oluşunuz veya Conversations\'ı önceden kullanmış olmanız olabilir. Eğer durum bu değilse şimdi yeni bir XMPP hesabı oluşturabilirsiniz.\nİpucu: Bağzı e-posta sağlayıcıları da XMPP hesapları kullanabilir.</string>
<string name="server_select_text">XMPP; anlık yazışmalar için bağımsız bir sağlayıcıdır. Bu istemciyi istediğiniz herhangi bir XMPP sunucusu ile birlikte kullanabilirsiniz.\nAncak kullanım rahatlığı adına sizin için conversations.im; Conversations için özellikle tasarlanmış bir sağlayıcıda hesap açmanızı kolaylaştırdık.</string>
<string name="magic_create_text_on_x">%1$s sağlayıcısına davet edildiniz. Sizi hesap oluşturulması konusunda yönlendireceğiz.\n%1$s bir sağlayıcı olark seçildiğinde, başka sağlayıcılar kullanan kullanıcılarla, onlara tam XMPP adresinizi vererek iletişim kurabileceksiniz.</string>
<string name="magic_create_text_fixed">%1$s sağlayıcısına davet edildiniz. Sizin için zaten bir kullanıcı adı seçildi. Sizi hesap oluşturulması konusunda yönlendireceğiz.\nBaşka sağlayıcılar kullanan kullanıcılarla, onlara tam XMPP adresinizi vererek iletişim kurabileceksiniz.</string>
<string name="your_server_invitation">Sunucu davetiyeniz</string>
<string name="improperly_formatted_provisioning">Yanlış ayarlanmış düzenleme kodu</string>
<string name="tap_share_button_send_invite">Kişinize, %1$s grubuna davet etmek için Paylaş düğmesine basın.</string>
<string name="if_contact_is_nearby_use_qr">Kişiniz yakınınızda ise, aşağıdaki kodu tarayak daveti kabul edebilirler.</string>
<string name="easy_invite_share_text">%1$s grubuna katıl ve benimle sohpet et: %2$s</string>
<string name="share_invite_with">Daveti şununla paylaş...</string>
</resources>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="pick_a_server">Виберіть постачальника послуг обміну повідомленнями XMPP</string>
<string name="use_conversations.im">Скористатися conversations.im</string>
<string name="create_new_account">Створити новий обліковий запис</string>
<string name="do_you_have_an_account">Вже маєте обліковий запис XMPP? Можливо, користуєтеся іншою програмою XMPP або користувалися цією програмою раніше. Якщо ні, можете створити новий обліковий запис XMPP просто зараз.\nЗверніть увагу, що деякі постачальники електронної пошти у той же час надають облікові записи XMPP.</string>
<string name="server_select_text">XMPP — це мережа обміну повідомленнями, незалежна від постачальників. Можете використовувати цю програму з будь-яким XMPP сервером, який оберете.\nПроте, для зручності, ми спростили створення облікового запису на conversations.im — у постачальника, який спеціально налаштований на роботу з цією програмою.</string>
<string name="magic_create_text_on_x">Вас запросили до %1$s. Ми проведемо вас крок за кроком, щоб створити обліковий запис.\nОбираючи %1$s в якості свого постачальника, ви зможете спілкуватися з користувачами інших постачальників, для цього повідомте їм свою повну адресу XMPP.</string>
<string name="magic_create_text_fixed">Вас запросили до %1$s. Для вас створено ім\'я користувача. Ми проведемо вас крок за кроком, щоб створити обліковий запис.\nВи зможете спілкуватися з користувачами інших постачальників, для цього повідомите їм свою повну адресу XMPP.</string>
<string name="your_server_invitation">Ваше запрошення до сервера</string>
<string name="improperly_formatted_provisioning">Неправильно відформатований код забезпечення</string>
</resources>

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="pick_a_server">Chọn nhà cung cấp XMPP của bạn</string>
<string name="use_conversations.im">Sử dụng conversations.im</string>
<string name="create_new_account">Tạo tài khoản mới</string>
<string name="do_you_have_an_account">Bạn đã có tài khoản XMPP chưa? Điều này có thể đúng nếu bạn đang dùng một ứng dụng khách cho XMPP khác hoặc đã sử dụng Conversations trước đó. Nếu không, bạn có thể tạo tài khoản XMPP mới ngay bây giờ.\nGợi ý: Một số nhà cung cấp email cũng cung cấp tài khoản XMPP.</string>
<string name="server_select_text">XMPP là một mạng nhắn tin ngay lập tức không phụ thuộc vào nhà cung cấp. Bạn có thể sử dụng ứng dụng khách này với bất kỳ máy chủ XMPP nào mà bạn chọn.\nTuy nhiên, vì sự thuận tiện của bạn, chúng tôi đã làm cho việc tạo tài khoản trên conversations.im được dễ dàng; một nhà cung cấp đặc biệt phù hợp với việc sử dụng Conversations.</string>
<string name="magic_create_text_on_x">Bạn đã được mời vào %1$s. Chúng tôi sẽ hướng dẫn bạn trong quá trình tạo tài khoản.\nKhi chọn %1$s là nhà cung cấp, bạn sẽ có thể giao tiếp với những người dùng của các nhà cung cấp khác bằng cách đưa cho họ địa chỉ XMPP đầy đủ của bạn.</string>
<string name="magic_create_text_fixed">Bạn đã được mời vào %1$s. Một tên người dùng đã được chọn sẵn cho bạn. Chúng tôi sẽ hướng dẫn bạn trong quá trình tạo tài khoản.\nBạn sẽ có thể giao tiếp với những người dùng của các nhà cung cấp khác bằng cách đưa cho họ địa chỉ XMPP đầy đủ của bạn.</string>
<string name="your_server_invitation">Lời mời vào máy chủ của bạn</string>
<string name="improperly_formatted_provisioning">Mã cung cấp không được định dạng đúng</string>
<string name="tap_share_button_send_invite">Nhấn nút chia sẻ để gửi lời mời vào %1$s đến liên hệ của bạn.</string>
<string name="if_contact_is_nearby_use_qr">Nếu liên hệ của bạn ở gần đây, họ cũng có thể quét mã ở dưới để chấp nhận lời mời của bạn.</string>
<string name="easy_invite_share_text">Hãy tham gia vào %1$s và trò chuyện với tôi: %2$s</string>
<string name="share_invite_with">Chia sẻ lời mời với...</string>
</resources>

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="pick_a_server">选择您的 XMPP 提供者</string>
<string name="use_conversations.im">使用 conversations.im</string>
<string name="create_new_account">创建新账户</string>
<string name="do_you_have_an_account">您有 XMPP 账户吗?如果您之前使用过其他的 XMPP 客户端,那么您已经拥有这种账户了。如果没有的话,您现在可以创建一个。\n提示有些电子邮件服务也提供XMPP账户。</string>
<string name="server_select_text">XMPP 是独立于提供者的即时消息网络。您可以将此客户端与任意 XMPP 服务器一同使用。\n不过您可以很容易地在 conversations.im 上创建账户它是特别适合与“Conversations”一起使用的提供者。</string>
<string name="magic_create_text_on_x">您已受邀加入 %1$s。我们将指导您完成创建帐户的过程。\n使用 %1$s 作为提供者时,您可以通过您的完整 XMPP 地址与其他提供者的用户进行交流。</string>
<string name="magic_create_text_fixed">您已受邀加入 %1$s。已为您选择了一个用户名。我们将指导您完成创建帐户的过程。\n您可以使用完整的 XMPP 地址来与其他提供者的用户进行交流。</string>
<string name="your_server_invitation">你的服务器邀请</string>
<string name="improperly_formatted_provisioning">格式不正确的配置代码</string>
<string name="tap_share_button_send_invite">点击分享按钮向您的联系人发送加入 %1$s 的邀请。</string>
<string name="if_contact_is_nearby_use_qr">如果你的联系人在附近,他们也可以扫描下面的代码来接受你的邀请。</string>
<string name="easy_invite_share_text">加入 %1$s 和我聊天:%2$s</string>
<string name="share_invite_with">分享邀请…</string>
</resources>

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="pick_a_server">挑選您的 XMPP 提供者</string>
<string name="use_conversations.im">使用 conversations.im</string>
<string name="create_new_account">建立新帳戶</string>
<string name="do_you_have_an_account">您已經擁有一個 XMPP 賬戶嗎?如果您之前使用過其他 XMPP 客戶端或 Conversations 的話,那麼您已經擁有 XMPP 賬戶了。如果沒有賬戶的話,您可以現在建立一個。\n提示有些電子郵件服務供應商也會提供 XMPP 賬戶。</string>
<string name="server_select_text">XMPP 是提供者無關的即時訊息網絡。 任何你選擇的 XMPP 伺服器都可在此客戶端上使用。\n不過我們令它在 Coversations.im 中建立帳戶變得更方便; Conversations.im 是特別適合 Conversations 的提供者</string>
<string name="magic_create_text_on_x">你已受邀參加 %1$s 。 我們將指導你完成建立帳戶的過程。選擇 %1$s 作爲提供者後,你可以將你完整的 XMPP 地址交給使用其他提供者的用戶,你便能與他們交流。</string>
<string name="magic_create_text_fixed">您已被邀請參加 %1$s 。 我們已經爲你選擇了一個用戶名。 我們將指導你完成建立帳戶的過程。\n將你完整的 XMPP 地址交給使用其他提供者的用戶後,你便能與他們交流。</string>
<string name="your_server_invitation">您的伺服器邀請</string>
<string name="improperly_formatted_provisioning">配置代碼格式不正確</string>
<string name="tap_share_button_send_invite">輕觸分享按鍵向您的聯絡人發送加入 %1$s 的邀請。</string>
<string name="if_contact_is_nearby_use_qr">如果你的聯絡人就在附近,他們也可以掃描下面的代碼來接受你的邀請。</string>
<string name="easy_invite_share_text">加入 %1$s 和我聊天:%2$s</string>
<string name="share_invite_with">分享邀請到...</string>
</resources>

View file

@ -0,0 +1,120 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.READ_PROFILE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-feature
android:name="android.hardware.location"
android:required="false" />
<uses-feature
android:name="android.hardware.location.gps"
android:required="false" />
<uses-feature
android:name="android.hardware.location.network"
android:required="false" />
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<uses-feature
android:name="android.hardware.camera.autofocus"
android:required="false" />
<uses-feature
android:name="android.hardware.microphone"
android:required="false" />
<queries>
<package android:name="org.torproject.android" />
<intent>
<action android:name="eu.siacs.conversations.location.request" />
</intent>
<intent>
<action android:name="eu.siacs.conversations.location.show" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:mimeType="resource/folder" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
</intent>
<intent>
<action android:name="org.unifiedpush.android.connector.MESSAGE" />
</intent>
</queries>
<application
android:name="im.conversations.android.Conversations"
android:allowBackup="true"
android:appCategory="social"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/new_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/new_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Conversations3"
tools:targetApi="31">
<service
android:name=".service.ForegroundService"
android:exported="false"/>
<service
android:name=".service.RtpSessionService"
android:exported="false"
android:foregroundServiceType="phoneCall" />
<receiver
android:name=".receiver.EventReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.ACTION_SHUTDOWN" />
<action android:name="android.media.RINGER_MODE_CHANGED" />
</intent-filter>
</receiver>
<activity
android:name="im.conversations.android.ui.activity.MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity android:name="im.conversations.android.ui.activity.SettingsActivity" />
<activity
android:name="im.conversations.android.ui.activity.SetupActivity"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".ui.activity.RtpSessionActivity"
android:autoRemoveFromRecents="true"
android:launchMode="singleInstance"
android:supportsPictureInPicture="true" />
</application>
</manifest>

View file

@ -0,0 +1,16 @@
<configuration xmlns="https://tony19.github.io/logback-android/xml"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="https://tony19.github.io/logback-android/xml https://cdn.jsdelivr.net/gh/tony19/logback-android/logback.xsd">
<appender name="logcat" class="ch.qos.logback.classic.android.LogcatAppender">
<tagEncoder>
<pattern>conversations</pattern>
</tagEncoder>
<encoder>
<pattern>%logger{12}: %msg</pattern>
</encoder>
</appender>
<root level="DEBUG">
<appender-ref ref="logcat" />
</root>
</configuration>

View file

@ -0,0 +1,10 @@
package eu.siacs.conversations;
import android.net.Uri;
public class Config {
public static final String LOGTAG = "conversations";
public static final Uri HELP = Uri.parse("https://help.conversations.im");
public static final boolean REQUIRE_RTP_VERIFICATION =
false; // require a/v calls to be verified with OMEMO
}

View file

@ -0,0 +1,54 @@
package eu.siacs.conversations.generator;
import eu.siacs.conversations.xmpp.jingle.JingleRtpConnection;
import eu.siacs.conversations.xmpp.jingle.Media;
import im.conversations.android.xml.Element;
import im.conversations.android.xml.Namespace;
import im.conversations.android.xmpp.manager.JingleConnectionManager;
import im.conversations.android.xmpp.model.stanza.Message;
import org.jxmpp.jid.Jid;
public final class MessageGenerator {
private MessageGenerator() {
throw new IllegalStateException("Do not instantiate me");
}
public static Message sessionProposal(
final JingleConnectionManager.RtpSessionProposal proposal) {
final Message packet = new Message(Message.Type.CHAT); // we want to carbon copy those
packet.setTo(proposal.with);
packet.setId(JingleRtpConnection.JINGLE_MESSAGE_PROPOSE_ID_PREFIX + proposal.sessionId);
final Element propose = packet.addChild("propose", Namespace.JINGLE_MESSAGE);
propose.setAttribute("id", proposal.sessionId);
for (final Media media : proposal.media) {
propose.addChild("description", Namespace.JINGLE_APPS_RTP)
.setAttribute("media", media.toString());
}
packet.addChild("request", "urn:xmpp:receipts");
packet.addChild("store", "urn:xmpp:hints");
return packet;
}
public static Message sessionRetract(
final JingleConnectionManager.RtpSessionProposal proposal) {
final Message packet = new Message(Message.Type.CHAT); // we want to carbon copy those
packet.setTo(proposal.with);
final Element propose = packet.addChild("retract", Namespace.JINGLE_MESSAGE);
propose.setAttribute("id", proposal.sessionId);
propose.addChild("description", Namespace.JINGLE_APPS_RTP);
packet.addChild("store", "urn:xmpp:hints");
return packet;
}
public static Message sessionReject(final Jid with, final String sessionId) {
final Message packet = new Message(Message.Type.CHAT); // we want to carbon copy those
packet.setTo(with);
final Element propose = packet.addChild("reject", Namespace.JINGLE_MESSAGE);
propose.setAttribute("id", sessionId);
propose.addChild("description", Namespace.JINGLE_APPS_RTP);
packet.addChild("store", "urn:xmpp:hints");
return packet;
}
}

View file

@ -0,0 +1,660 @@
/*
* Copyright 2014 The WebRTC Project Authors. All rights reserved.
*
* Use of this source code is governed by a BSD-style license
* that can be found in the LICENSE file in the root of the source
* tree. An additional intellectual property rights grant can be found
* in the file PATENTS. All contributing project authors may
* be found in the AUTHORS file in the root of the source tree.
*/
package eu.siacs.conversations.services;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.PackageManager;
import android.media.AudioDeviceInfo;
import android.media.AudioFormat;
import android.media.AudioManager;
import android.media.AudioRecord;
import android.media.MediaRecorder;
import android.os.Build;
import android.util.Log;
import androidx.annotation.Nullable;
import eu.siacs.conversations.Config;
import eu.siacs.conversations.utils.AppRTCUtils;
import eu.siacs.conversations.xmpp.jingle.Media;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import org.webrtc.ThreadUtils;
/** AppRTCAudioManager manages all audio related parts of the AppRTC demo. */
public class AppRTCAudioManager {
private static CountDownLatch microphoneLatch;
private final Context apprtcContext;
// Contains speakerphone setting: auto, true or false
@Nullable private SpeakerPhonePreference speakerPhonePreference;
// Handles all tasks related to Bluetooth headset devices.
private final AppRTCBluetoothManager bluetoothManager;
@Nullable private final AudioManager audioManager;
@Nullable private AudioManagerEvents audioManagerEvents;
private AudioManagerState amState;
private boolean savedIsSpeakerPhoneOn;
private boolean savedIsMicrophoneMute;
private boolean hasWiredHeadset;
// Default audio device; speaker phone for video calls or earpiece for audio
// only calls.
private AudioDevice defaultAudioDevice;
// Contains the currently selected audio device.
// This device is changed automatically using a certain scheme where e.g.
// a wired headset "wins" over speaker phone. It is also possible for a
// user to explicitly select a device (and overrid any predefined scheme).
// See |userSelectedAudioDevice| for details.
private AudioDevice selectedAudioDevice;
// Contains the user-selected audio device which overrides the predefined
// selection scheme.
// TODO(henrika): always set to AudioDevice.NONE today. Add support for
// explicit selection based on choice by userSelectedAudioDevice.
private AudioDevice userSelectedAudioDevice;
// Proximity sensor object. It measures the proximity of an object in cm
// relative to the view screen of a device and can therefore be used to
// assist device switching (close to ear <=> use headset earpiece if
// available, far from ear <=> use speaker phone).
@Nullable private AppRTCProximitySensor proximitySensor;
// Contains a list of available audio devices. A Set collection is used to
// avoid duplicate elements.
private Set<AudioDevice> audioDevices = new HashSet<>();
// Broadcast receiver for wired headset intent broadcasts.
private final BroadcastReceiver wiredHeadsetReceiver;
// Callback method for changes in audio focus.
@Nullable private AudioManager.OnAudioFocusChangeListener audioFocusChangeListener;
private AppRTCAudioManager(
Context context, final SpeakerPhonePreference speakerPhonePreference) {
Log.d(Config.LOGTAG, "ctor");
ThreadUtils.checkIsOnMainThread();
apprtcContext = context;
audioManager = ((AudioManager) context.getSystemService(Context.AUDIO_SERVICE));
bluetoothManager = AppRTCBluetoothManager.create(context, this);
wiredHeadsetReceiver = new WiredHeadsetReceiver();
amState = AudioManagerState.UNINITIALIZED;
this.speakerPhonePreference = speakerPhonePreference;
if (speakerPhonePreference == SpeakerPhonePreference.EARPIECE && hasEarpiece()) {
defaultAudioDevice = AudioDevice.EARPIECE;
} else {
defaultAudioDevice = AudioDevice.SPEAKER_PHONE;
}
// Create and initialize the proximity sensor.
// Tablet devices (e.g. Nexus 7) does not support proximity sensors.
// Note that, the sensor will not be active until start() has been called.
proximitySensor =
AppRTCProximitySensor.create(
context,
// This method will be called each time a state change is detected.
// Example: user holds his hand over the device (closer than ~5 cm),
// or removes his hand from the device.
this::onProximitySensorChangedState);
Log.d(Config.LOGTAG, "defaultAudioDevice: " + defaultAudioDevice);
AppRTCUtils.logDeviceInfo(Config.LOGTAG);
}
public void switchSpeakerPhonePreference(final SpeakerPhonePreference speakerPhonePreference) {
this.speakerPhonePreference = speakerPhonePreference;
if (speakerPhonePreference == SpeakerPhonePreference.EARPIECE && hasEarpiece()) {
defaultAudioDevice = AudioDevice.EARPIECE;
} else {
defaultAudioDevice = AudioDevice.SPEAKER_PHONE;
}
updateAudioDeviceState();
}
/** Construction. */
public static AppRTCAudioManager create(
Context context, SpeakerPhonePreference speakerPhonePreference) {
return new AppRTCAudioManager(context, speakerPhonePreference);
}
public static boolean isMicrophoneAvailable() {
microphoneLatch = new CountDownLatch(1);
AudioRecord audioRecord = null;
boolean available = true;
try {
final int sampleRate = 44100;
final int channel = AudioFormat.CHANNEL_IN_MONO;
final int format = AudioFormat.ENCODING_PCM_16BIT;
final int bufferSize = AudioRecord.getMinBufferSize(sampleRate, channel, format);
audioRecord =
new AudioRecord(
MediaRecorder.AudioSource.MIC, sampleRate, channel, format, bufferSize);
audioRecord.startRecording();
final short[] buffer = new short[bufferSize];
final int audioStatus = audioRecord.read(buffer, 0, bufferSize);
if (audioStatus == AudioRecord.ERROR_INVALID_OPERATION
|| audioStatus == AudioRecord.STATE_UNINITIALIZED) available = false;
} catch (Exception e) {
available = false;
} finally {
release(audioRecord);
}
microphoneLatch.countDown();
return available;
}
private static void release(final AudioRecord audioRecord) {
if (audioRecord == null) {
return;
}
try {
audioRecord.release();
} catch (Exception e) {
// ignore
}
}
/**
* This method is called when the proximity sensor reports a state change, e.g. from "NEAR to
* FAR" or from "FAR to NEAR".
*/
private void onProximitySensorChangedState() {
if (speakerPhonePreference != SpeakerPhonePreference.AUTO) {
return;
}
// The proximity sensor should only be activated when there are exactly two
// available audio devices.
if (audioDevices.size() == 2
&& audioDevices.contains(AppRTCAudioManager.AudioDevice.EARPIECE)
&& audioDevices.contains(AppRTCAudioManager.AudioDevice.SPEAKER_PHONE)) {
if (proximitySensor.sensorReportsNearState()) {
// Sensor reports that a "handset is being held up to a person's ear",
// or "something is covering the light sensor".
setAudioDeviceInternal(AppRTCAudioManager.AudioDevice.EARPIECE);
} else {
// Sensor reports that a "handset is removed from a person's ear", or
// "the light sensor is no longer covered".
setAudioDeviceInternal(AppRTCAudioManager.AudioDevice.SPEAKER_PHONE);
}
}
}
@SuppressWarnings("deprecation")
public void start(AudioManagerEvents audioManagerEvents) {
Log.d(Config.LOGTAG, AppRTCAudioManager.class.getName() + ".start()");
ThreadUtils.checkIsOnMainThread();
if (amState == AudioManagerState.RUNNING) {
Log.e(Config.LOGTAG, "AudioManager is already active");
return;
}
awaitMicrophoneLatch();
this.audioManagerEvents = audioManagerEvents;
amState = AudioManagerState.RUNNING;
// Store current audio state so we can restore it when stop() is called.
savedIsSpeakerPhoneOn = audioManager.isSpeakerphoneOn();
savedIsMicrophoneMute = audioManager.isMicrophoneMute();
hasWiredHeadset = hasWiredHeadset();
// Create an AudioManager.OnAudioFocusChangeListener instance.
audioFocusChangeListener =
new AudioManager.OnAudioFocusChangeListener() {
// Called on the listener to notify if the audio focus for this listener has
// been changed.
// The |focusChange| value indicates whether the focus was gained, whether the
// focus was lost,
// and whether that loss is transient, or whether the new focus holder will hold
// it for an
// unknown amount of time.
// TODO(henrika): possibly extend support of handling audio-focus changes. Only
// contains
// logging for now.
@Override
public void onAudioFocusChange(int focusChange) {
final String typeOfChange;
switch (focusChange) {
case AudioManager.AUDIOFOCUS_GAIN:
typeOfChange = "AUDIOFOCUS_GAIN";
break;
case AudioManager.AUDIOFOCUS_GAIN_TRANSIENT:
typeOfChange = "AUDIOFOCUS_GAIN_TRANSIENT";
break;
case AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE:
typeOfChange = "AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE";
break;
case AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK:
typeOfChange = "AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK";
break;
case AudioManager.AUDIOFOCUS_LOSS:
typeOfChange = "AUDIOFOCUS_LOSS";
break;
case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT:
typeOfChange = "AUDIOFOCUS_LOSS_TRANSIENT";
break;
case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK:
typeOfChange = "AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK";
break;
default:
typeOfChange = "AUDIOFOCUS_INVALID";
break;
}
Log.d(Config.LOGTAG, "onAudioFocusChange: " + typeOfChange);
}
};
// Request audio playout focus (without ducking) and install listener for changes in focus.
int result =
audioManager.requestAudioFocus(
audioFocusChangeListener,
AudioManager.STREAM_VOICE_CALL,
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT);
if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
Log.d(Config.LOGTAG, "Audio focus request granted for VOICE_CALL streams");
} else {
Log.e(Config.LOGTAG, "Audio focus request failed");
}
// Start by setting MODE_IN_COMMUNICATION as default audio mode. It is
// required to be in this mode when playout and/or recording starts for
// best possible VoIP performance.
audioManager.setMode(AudioManager.MODE_IN_COMMUNICATION);
// Always disable microphone mute during a WebRTC call.
setMicrophoneMute(false);
// Set initial device states.
userSelectedAudioDevice = AudioDevice.NONE;
selectedAudioDevice = AudioDevice.NONE;
audioDevices.clear();
// Initialize and start Bluetooth if a BT device is available or initiate
// detection of new (enabled) BT devices.
bluetoothManager.start();
// Do initial selection of audio device. This setting can later be changed
// either by adding/removing a BT or wired headset or by covering/uncovering
// the proximity sensor.
updateAudioDeviceState();
// Register receiver for broadcast intents related to adding/removing a
// wired headset.
registerReceiver(wiredHeadsetReceiver, new IntentFilter(Intent.ACTION_HEADSET_PLUG));
Log.d(Config.LOGTAG, "AudioManager started");
}
private void awaitMicrophoneLatch() {
final CountDownLatch latch = microphoneLatch;
if (latch == null) {
return;
}
try {
latch.await();
} catch (InterruptedException e) {
// ignore
}
}
@SuppressWarnings("deprecation")
public void stop() {
Log.d(Config.LOGTAG, AppRTCAudioManager.class.getName() + ".stop()");
ThreadUtils.checkIsOnMainThread();
if (amState != AudioManagerState.RUNNING) {
Log.e(Config.LOGTAG, "Trying to stop AudioManager in incorrect state: " + amState);
return;
}
amState = AudioManagerState.UNINITIALIZED;
unregisterReceiver(wiredHeadsetReceiver);
bluetoothManager.stop();
// Restore previously stored audio states.
setSpeakerphoneOn(savedIsSpeakerPhoneOn);
setMicrophoneMute(savedIsMicrophoneMute);
audioManager.setMode(AudioManager.MODE_NORMAL);
// Abandon audio focus. Gives the previous focus owner, if any, focus.
audioManager.abandonAudioFocus(audioFocusChangeListener);
audioFocusChangeListener = null;
Log.d(Config.LOGTAG, "Abandoned audio focus for VOICE_CALL streams");
if (proximitySensor != null) {
proximitySensor.stop();
proximitySensor = null;
}
audioManagerEvents = null;
}
/** Changes selection of the currently active audio device. */
private void setAudioDeviceInternal(AudioDevice device) {
Log.d(Config.LOGTAG, "setAudioDeviceInternal(device=" + device + ")");
AppRTCUtils.assertIsTrue(audioDevices.contains(device));
switch (device) {
case SPEAKER_PHONE:
setSpeakerphoneOn(true);
break;
case EARPIECE:
case WIRED_HEADSET:
case BLUETOOTH:
setSpeakerphoneOn(false);
break;
default:
Log.e(Config.LOGTAG, "Invalid audio device selection");
break;
}
selectedAudioDevice = device;
}
/**
* Changes default audio device. TODO(henrika): add usage of this method in the AppRTCMobile
* client.
*/
public void setDefaultAudioDevice(AudioDevice defaultDevice) {
ThreadUtils.checkIsOnMainThread();
switch (defaultDevice) {
case SPEAKER_PHONE:
defaultAudioDevice = defaultDevice;
break;
case EARPIECE:
if (hasEarpiece()) {
defaultAudioDevice = defaultDevice;
} else {
defaultAudioDevice = AudioDevice.SPEAKER_PHONE;
}
break;
default:
Log.e(Config.LOGTAG, "Invalid default audio device selection");
break;
}
Log.d(Config.LOGTAG, "setDefaultAudioDevice(device=" + defaultAudioDevice + ")");
updateAudioDeviceState();
}
/** Changes selection of the currently active audio device. */
public void selectAudioDevice(AudioDevice device) {
ThreadUtils.checkIsOnMainThread();
if (!audioDevices.contains(device)) {
Log.e(Config.LOGTAG, "Can not select " + device + " from available " + audioDevices);
}
userSelectedAudioDevice = device;
updateAudioDeviceState();
}
/** Returns current set of available/selectable audio devices. */
public Set<AudioDevice> getAudioDevices() {
ThreadUtils.checkIsOnMainThread();
return Collections.unmodifiableSet(new HashSet<>(audioDevices));
}
/** Returns the currently selected audio device. */
public AudioDevice getSelectedAudioDevice() {
ThreadUtils.checkIsOnMainThread();
return selectedAudioDevice;
}
/** Helper method for receiver registration. */
private void registerReceiver(BroadcastReceiver receiver, IntentFilter filter) {
apprtcContext.registerReceiver(receiver, filter);
}
/** Helper method for unregistration of an existing receiver. */
private void unregisterReceiver(BroadcastReceiver receiver) {
apprtcContext.unregisterReceiver(receiver);
}
/** Sets the speaker phone mode. */
private void setSpeakerphoneOn(boolean on) {
boolean wasOn = audioManager.isSpeakerphoneOn();
if (wasOn == on) {
return;
}
audioManager.setSpeakerphoneOn(on);
}
/** Sets the microphone mute state. */
private void setMicrophoneMute(boolean on) {
boolean wasMuted = audioManager.isMicrophoneMute();
if (wasMuted == on) {
return;
}
audioManager.setMicrophoneMute(on);
}
/** Gets the current earpiece state. */
private boolean hasEarpiece() {
return apprtcContext.getPackageManager().hasSystemFeature(PackageManager.FEATURE_TELEPHONY);
}
/**
* Checks whether a wired headset is connected or not. This is not a valid indication that audio
* playback is actually over the wired headset as audio routing depends on other conditions. We
* only use it as an early indicator (during initialization) of an attached wired headset.
*/
@Deprecated
private boolean hasWiredHeadset() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
return audioManager.isWiredHeadsetOn();
} else {
final AudioDeviceInfo[] devices = audioManager.getDevices(AudioManager.GET_DEVICES_ALL);
for (AudioDeviceInfo device : devices) {
final int type = device.getType();
if (type == AudioDeviceInfo.TYPE_WIRED_HEADSET) {
Log.d(Config.LOGTAG, "hasWiredHeadset: found wired headset");
return true;
} else if (type == AudioDeviceInfo.TYPE_USB_DEVICE) {
Log.d(Config.LOGTAG, "hasWiredHeadset: found USB audio device");
return true;
}
}
return false;
}
}
/**
* Updates list of possible audio devices and make new device selection. TODO(henrika): add unit
* test to verify all state transitions.
*/
public void updateAudioDeviceState() {
ThreadUtils.checkIsOnMainThread();
Log.d(
Config.LOGTAG,
"--- updateAudioDeviceState: "
+ "wired headset="
+ hasWiredHeadset
+ ", "
+ "BT state="
+ bluetoothManager.getState());
Log.d(
Config.LOGTAG,
"Device status: "
+ "available="
+ audioDevices
+ ", "
+ "selected="
+ selectedAudioDevice
+ ", "
+ "user selected="
+ userSelectedAudioDevice);
// Check if any Bluetooth headset is connected. The internal BT state will
// change accordingly.
// TODO(henrika): perhaps wrap required state into BT manager.
if (bluetoothManager.getState() == AppRTCBluetoothManager.State.HEADSET_AVAILABLE
|| bluetoothManager.getState() == AppRTCBluetoothManager.State.HEADSET_UNAVAILABLE
|| bluetoothManager.getState() == AppRTCBluetoothManager.State.SCO_DISCONNECTING) {
bluetoothManager.updateDevice();
}
// Update the set of available audio devices.
Set<AudioDevice> newAudioDevices = new HashSet<>();
if (bluetoothManager.getState() == AppRTCBluetoothManager.State.SCO_CONNECTED
|| bluetoothManager.getState() == AppRTCBluetoothManager.State.SCO_CONNECTING
|| bluetoothManager.getState() == AppRTCBluetoothManager.State.HEADSET_AVAILABLE) {
newAudioDevices.add(AudioDevice.BLUETOOTH);
}
if (hasWiredHeadset) {
// If a wired headset is connected, then it is the only possible option.
newAudioDevices.add(AudioDevice.WIRED_HEADSET);
} else {
// No wired headset, hence the audio-device list can contain speaker
// phone (on a tablet), or speaker phone and earpiece (on mobile phone).
newAudioDevices.add(AudioDevice.SPEAKER_PHONE);
if (hasEarpiece()) {
newAudioDevices.add(AudioDevice.EARPIECE);
}
}
// Store state which is set to true if the device list has changed.
boolean audioDeviceSetUpdated = !audioDevices.equals(newAudioDevices);
// Update the existing audio device set.
audioDevices = newAudioDevices;
// Correct user selected audio devices if needed.
if (bluetoothManager.getState() == AppRTCBluetoothManager.State.HEADSET_UNAVAILABLE
&& userSelectedAudioDevice == AudioDevice.BLUETOOTH) {
// If BT is not available, it can't be the user selection.
userSelectedAudioDevice = AudioDevice.NONE;
}
if (hasWiredHeadset && userSelectedAudioDevice == AudioDevice.SPEAKER_PHONE) {
// If user selected speaker phone, but then plugged wired headset then make
// wired headset as user selected device.
userSelectedAudioDevice = AudioDevice.WIRED_HEADSET;
}
if (!hasWiredHeadset && userSelectedAudioDevice == AudioDevice.WIRED_HEADSET) {
// If user selected wired headset, but then unplugged wired headset then make
// speaker phone as user selected device.
userSelectedAudioDevice = AudioDevice.SPEAKER_PHONE;
}
// Need to start Bluetooth if it is available and user either selected it explicitly or
// user did not select any output device.
boolean needBluetoothAudioStart =
bluetoothManager.getState() == AppRTCBluetoothManager.State.HEADSET_AVAILABLE
&& (userSelectedAudioDevice == AudioDevice.NONE
|| userSelectedAudioDevice == AudioDevice.BLUETOOTH);
// Need to stop Bluetooth audio if user selected different device and
// Bluetooth SCO connection is established or in the process.
boolean needBluetoothAudioStop =
(bluetoothManager.getState() == AppRTCBluetoothManager.State.SCO_CONNECTED
|| bluetoothManager.getState()
== AppRTCBluetoothManager.State.SCO_CONNECTING)
&& (userSelectedAudioDevice != AudioDevice.NONE
&& userSelectedAudioDevice != AudioDevice.BLUETOOTH);
if (bluetoothManager.getState() == AppRTCBluetoothManager.State.HEADSET_AVAILABLE
|| bluetoothManager.getState() == AppRTCBluetoothManager.State.SCO_CONNECTING
|| bluetoothManager.getState() == AppRTCBluetoothManager.State.SCO_CONNECTED) {
Log.d(
Config.LOGTAG,
"Need BT audio: start="
+ needBluetoothAudioStart
+ ", "
+ "stop="
+ needBluetoothAudioStop
+ ", "
+ "BT state="
+ bluetoothManager.getState());
}
// Start or stop Bluetooth SCO connection given states set earlier.
if (needBluetoothAudioStop) {
bluetoothManager.stopScoAudio();
bluetoothManager.updateDevice();
}
if (needBluetoothAudioStart && !needBluetoothAudioStop) {
// Attempt to start Bluetooth SCO audio (takes a few second to start).
if (!bluetoothManager.startScoAudio()) {
// Remove BLUETOOTH from list of available devices since SCO failed.
audioDevices.remove(AudioDevice.BLUETOOTH);
audioDeviceSetUpdated = true;
}
}
// Update selected audio device.
final AudioDevice newAudioDevice;
if (bluetoothManager.getState() == AppRTCBluetoothManager.State.SCO_CONNECTED) {
// If a Bluetooth is connected, then it should be used as output audio
// device. Note that it is not sufficient that a headset is available;
// an active SCO channel must also be up and running.
newAudioDevice = AudioDevice.BLUETOOTH;
} else if (hasWiredHeadset) {
// If a wired headset is connected, but Bluetooth is not, then wired headset is used as
// audio device.
newAudioDevice = AudioDevice.WIRED_HEADSET;
} else {
// No wired headset and no Bluetooth, hence the audio-device list can contain speaker
// phone (on a tablet), or speaker phone and earpiece (on mobile phone).
// |defaultAudioDevice| contains either AudioDevice.SPEAKER_PHONE or
// AudioDevice.EARPIECE
// depending on the user's selection.
newAudioDevice = defaultAudioDevice;
}
// Switch to new device but only if there has been any changes.
if (newAudioDevice != selectedAudioDevice || audioDeviceSetUpdated) {
// Do the required device switch.
setAudioDeviceInternal(newAudioDevice);
Log.d(
Config.LOGTAG,
"New device status: "
+ "available="
+ audioDevices
+ ", "
+ "selected="
+ newAudioDevice);
if (audioManagerEvents != null) {
// Notify a listening client that audio device has been changed.
audioManagerEvents.onAudioDeviceChanged(selectedAudioDevice, audioDevices);
}
}
Log.d(Config.LOGTAG, "--- updateAudioDeviceState done");
}
/** AudioDevice is the names of possible audio devices that we currently support. */
public enum AudioDevice {
SPEAKER_PHONE,
WIRED_HEADSET,
EARPIECE,
BLUETOOTH,
NONE
}
/** AudioManager state. */
public enum AudioManagerState {
UNINITIALIZED,
PREINITIALIZED,
RUNNING,
}
public enum SpeakerPhonePreference {
AUTO,
EARPIECE,
SPEAKER;
public static SpeakerPhonePreference of(final Set<Media> media) {
if (media.contains(Media.VIDEO)) {
return SPEAKER;
} else {
return EARPIECE;
}
}
}
/** Selected audio device change event. */
public interface AudioManagerEvents {
// Callback fired once audio device is changed or list of available audio devices changed.
void onAudioDeviceChanged(
AudioDevice selectedAudioDevice, Set<AudioDevice> availableAudioDevices);
}
/* Receiver which handles changes in wired headset availability. */
private class WiredHeadsetReceiver extends BroadcastReceiver {
private static final int STATE_UNPLUGGED = 0;
private static final int STATE_PLUGGED = 1;
private static final int HAS_NO_MIC = 0;
private static final int HAS_MIC = 1;
@Override
public void onReceive(Context context, Intent intent) {
int state = intent.getIntExtra("state", STATE_UNPLUGGED);
int microphone = intent.getIntExtra("microphone", HAS_NO_MIC);
String name = intent.getStringExtra("name");
Log.d(
Config.LOGTAG,
"WiredHeadsetReceiver.onReceive"
+ AppRTCUtils.getThreadInfo()
+ ": "
+ "a="
+ intent.getAction()
+ ", s="
+ (state == STATE_UNPLUGGED ? "unplugged" : "plugged")
+ ", m="
+ (microphone == HAS_MIC ? "mic" : "no mic")
+ ", n="
+ name
+ ", sb="
+ isInitialStickyBroadcast());
hasWiredHeadset = (state == STATE_PLUGGED);
updateAudioDeviceState();
}
}
}

View file

@ -25,19 +25,14 @@ import android.os.Build;
import android.os.Handler; import android.os.Handler;
import android.os.Looper; import android.os.Looper;
import android.util.Log; import android.util.Log;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.core.app.ActivityCompat; import androidx.core.app.ActivityCompat;
import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableList;
import org.webrtc.ThreadUtils;
import java.util.Collections;
import java.util.List;
import eu.siacs.conversations.Config; import eu.siacs.conversations.Config;
import eu.siacs.conversations.utils.AppRTCUtils; import eu.siacs.conversations.utils.AppRTCUtils;
import java.util.Collections;
import java.util.List;
import org.webrtc.ThreadUtils;
/** AppRTCProximitySensor manages functions related to Bluetoth devices in the AppRTC demo. */ /** AppRTCProximitySensor manages functions related to Bluetoth devices in the AppRTC demo. */
public class AppRTCBluetoothManager { public class AppRTCBluetoothManager {

View file

@ -16,22 +16,17 @@ import android.hardware.SensorEventListener;
import android.hardware.SensorManager; import android.hardware.SensorManager;
import android.os.Build; import android.os.Build;
import android.util.Log; import android.util.Log;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import org.webrtc.ThreadUtils;
import eu.siacs.conversations.Config; import eu.siacs.conversations.Config;
import eu.siacs.conversations.utils.AppRTCUtils; import eu.siacs.conversations.utils.AppRTCUtils;
import org.webrtc.ThreadUtils;
/** /**
* AppRTCProximitySensor manages functions related to the proximity sensor in * AppRTCProximitySensor manages functions related to the proximity sensor in the AppRTC demo. On
* the AppRTC demo. * most device, the proximity sensor is implemented as a boolean-sensor. It returns just two values
* On most device, the proximity sensor is implemented as a boolean-sensor. * "NEAR" or "FAR". Thresholding is done on the LUX value i.e. the LUX value of the light sensor is
* It returns just two values "NEAR" or "FAR". Thresholding is done on the LUX * compared with a threshold. A LUX-value more than the threshold means the proximity sensor returns
* value i.e. the LUX value of the light sensor is compared with a threshold. * "FAR". Anything less than the threshold value and the sensor returns "NEAR".
* A LUX-value more than the threshold means the proximity sensor returns "FAR".
* Anything less than the threshold value and the sensor returns "NEAR".
*/ */
public class AppRTCProximitySensor implements SensorEventListener { public class AppRTCProximitySensor implements SensorEventListener {
// This class should be created, started and stopped on one thread // This class should be created, started and stopped on one thread
@ -40,8 +35,7 @@ public class AppRTCProximitySensor implements SensorEventListener {
private final ThreadUtils.ThreadChecker threadChecker = new ThreadUtils.ThreadChecker(); private final ThreadUtils.ThreadChecker threadChecker = new ThreadUtils.ThreadChecker();
private final Runnable onSensorStateListener; private final Runnable onSensorStateListener;
private final SensorManager sensorManager; private final SensorManager sensorManager;
@Nullable @Nullable private Sensor proximitySensor;
private Sensor proximitySensor;
private boolean lastStateReportIsNear; private boolean lastStateReportIsNear;
private AppRTCProximitySensor(Context context, Runnable sensorStateListener) { private AppRTCProximitySensor(Context context, Runnable sensorStateListener) {
@ -50,17 +44,12 @@ public class AppRTCProximitySensor implements SensorEventListener {
sensorManager = ((SensorManager) context.getSystemService(Context.SENSOR_SERVICE)); sensorManager = ((SensorManager) context.getSystemService(Context.SENSOR_SERVICE));
} }
/** /** Construction */
* Construction
*/
static AppRTCProximitySensor create(Context context, Runnable sensorStateListener) { static AppRTCProximitySensor create(Context context, Runnable sensorStateListener) {
return new AppRTCProximitySensor(context, sensorStateListener); return new AppRTCProximitySensor(context, sensorStateListener);
} }
/** /** Activate the proximity sensor. Also do initialization if called for the first time. */
* Activate the proximity sensor. Also do initialization if called for the
* first time.
*/
public boolean start() { public boolean start() {
threadChecker.checkIsOnValidThread(); threadChecker.checkIsOnValidThread();
Log.d(Config.LOGTAG, "start" + AppRTCUtils.getThreadInfo()); Log.d(Config.LOGTAG, "start" + AppRTCUtils.getThreadInfo());
@ -72,9 +61,7 @@ public class AppRTCProximitySensor implements SensorEventListener {
return true; return true;
} }
/** /** Deactivate the proximity sensor. */
* Deactivate the proximity sensor.
*/
public void stop() { public void stop() {
threadChecker.checkIsOnValidThread(); threadChecker.checkIsOnValidThread();
Log.d(Config.LOGTAG, "stop" + AppRTCUtils.getThreadInfo()); Log.d(Config.LOGTAG, "stop" + AppRTCUtils.getThreadInfo());
@ -84,9 +71,7 @@ public class AppRTCProximitySensor implements SensorEventListener {
sensorManager.unregisterListener(this, proximitySensor); sensorManager.unregisterListener(this, proximitySensor);
} }
/** /** Getter for last reported state. Set to true if "near" is reported. */
* Getter for last reported state. Set to true if "near" is reported.
*/
public boolean sensorReportsNearState() { public boolean sensorReportsNearState() {
threadChecker.checkIsOnValidThread(); threadChecker.checkIsOnValidThread();
return lastStateReportIsNear; return lastStateReportIsNear;
@ -120,15 +105,22 @@ public class AppRTCProximitySensor implements SensorEventListener {
if (onSensorStateListener != null) { if (onSensorStateListener != null) {
onSensorStateListener.run(); onSensorStateListener.run();
} }
Log.d(Config.LOGTAG, "onSensorChanged" + AppRTCUtils.getThreadInfo() + ": " Log.d(
+ "accuracy=" + event.accuracy + ", timestamp=" + event.timestamp + ", distance=" Config.LOGTAG,
"onSensorChanged"
+ AppRTCUtils.getThreadInfo()
+ ": "
+ "accuracy="
+ event.accuracy
+ ", timestamp="
+ event.timestamp
+ ", distance="
+ event.values[0]); + event.values[0]);
} }
/** /**
* Get default proximity sensor if it exists. Tablet devices (e.g. Nexus 7) * Get default proximity sensor if it exists. Tablet devices (e.g. Nexus 7) does not support
* does not support this type of sensor and false will be returned in such * this type of sensor and false will be returned in such cases.
* cases.
*/ */
private boolean initDefaultSensor() { private boolean initDefaultSensor() {
if (proximitySensor != null) { if (proximitySensor != null) {
@ -142,9 +134,7 @@ public class AppRTCProximitySensor implements SensorEventListener {
return true; return true;
} }
/** /** Helper method for logging information about the proximity sensor. */
* Helper method for logging information about the proximity sensor.
*/
private void logProximitySensorInfo() { private void logProximitySensorInfo() {
if (proximitySensor == null) { if (proximitySensor == null) {
return; return;

View file

@ -0,0 +1,66 @@
/*
* Copyright 2014 The WebRTC Project Authors. All rights reserved.
*
* Use of this source code is governed by a BSD-style license
* that can be found in the LICENSE file in the root of the source
* tree. An additional intellectual property rights grant can be found
* in the file PATENTS. All contributing project authors may
* be found in the AUTHORS file in the root of the source tree.
*/
package eu.siacs.conversations.utils;
import android.os.Build;
import android.util.Log;
/** AppRTCUtils provides helper functions for managing thread safety. */
public final class AppRTCUtils {
private AppRTCUtils() {}
/** Helper method which throws an exception when an assertion has failed. */
public static void assertIsTrue(boolean condition) {
if (!condition) {
throw new AssertionError("Expected condition to be true");
}
}
/** Helper method for building a string of thread information. */
public static String getThreadInfo() {
return "@[name="
+ Thread.currentThread().getName()
+ ", id="
+ Thread.currentThread().getId()
+ "]";
}
/** Information about the current build, taken from system properties. */
public static void logDeviceInfo(String tag) {
Log.d(
tag,
"Android SDK: "
+ Build.VERSION.SDK_INT
+ ", "
+ "Release: "
+ Build.VERSION.RELEASE
+ ", "
+ "Brand: "
+ Build.BRAND
+ ", "
+ "Device: "
+ Build.DEVICE
+ ", "
+ "Id: "
+ Build.ID
+ ", "
+ "Hardware: "
+ Build.HARDWARE
+ ", "
+ "Manufacturer: "
+ Build.MANUFACTURER
+ ", "
+ "Model: "
+ Build.MODEL
+ ", "
+ "Product: "
+ Build.PRODUCT);
}
}

View file

@ -0,0 +1,119 @@
package eu.siacs.conversations.xmpp.jingle;
import android.content.Context;
import com.google.common.base.MoreObjects;
import com.google.common.base.Objects;
import com.google.common.base.Preconditions;
import eu.siacs.conversations.xmpp.jingle.stanzas.JinglePacket;
import im.conversations.android.IDs;
import im.conversations.android.xmpp.XmppConnection;
import im.conversations.android.xmpp.model.stanza.Iq;
import org.jxmpp.jid.Jid;
public abstract class AbstractJingleConnection extends XmppConnection.Delegate {
public static final String JINGLE_MESSAGE_PROPOSE_ID_PREFIX = "jm-propose-";
public static final String JINGLE_MESSAGE_PROCEED_ID_PREFIX = "jm-proceed-";
protected final Id id;
private final Jid initiator;
AbstractJingleConnection(
final Context context,
final XmppConnection connection,
final Id id,
final Jid initiator) {
super(context, connection);
this.id = id;
this.initiator = initiator;
}
boolean isInitiator() {
return initiator.equals(connection.getBoundAddress());
}
public abstract void deliverPacket(Iq jinglePacket);
public Id getId() {
return id;
}
public abstract void notifyRebound();
public static class Id implements OngoingRtpSession {
public final Jid with;
public final String sessionId;
private Id(final Jid with, final String sessionId) {
Preconditions.checkNotNull(with);
Preconditions.checkNotNull(sessionId);
this.with = with;
this.sessionId = sessionId;
}
public static Id of(final JinglePacket jinglePacket) {
return new Id(jinglePacket.getFrom(), jinglePacket.getSessionId());
}
public static Id of(Jid with, final String sessionId) {
return new Id(with, sessionId);
}
public static Id of(Jid with) {
return new Id(with, IDs.medium());
}
@Override
public Jid getWith() {
return with;
}
@Override
public String getSessionId() {
return sessionId;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Id id = (Id) o;
return Objects.equal(with, id.with) && Objects.equal(sessionId, id.sessionId);
}
@Override
public int hashCode() {
return Objects.hashCode(with, sessionId);
}
@Override
public String toString() {
return MoreObjects.toStringHelper(this)
.add("with", with)
.add("sessionId", sessionId)
.toString();
}
}
public enum State {
NULL, // default value; nothing has been sent or received yet
PROPOSED,
ACCEPTED,
PROCEED,
REJECTED,
REJECTED_RACED, // used when we want to reject but havent received session init yet
RETRACTED,
RETRACTED_RACED, // used when receiving a retract after we already asked to proceed
SESSION_INITIALIZED, // equal to 'PENDING'
SESSION_INITIALIZED_PRE_APPROVED,
SESSION_ACCEPTED, // equal to 'ACTIVE'
TERMINATED_SUCCESS, // equal to 'ENDED' (after successful call) ui will just close
TERMINATED_DECLINED_OR_BUSY, // equal to 'ENDED' (after other party declined the call)
TERMINATED_CONNECTIVITY_ERROR, // equal to 'ENDED' (but after network failures; ui will
// display retry button)
TERMINATED_CANCEL_OR_TIMEOUT, // more or less the same as retracted; caller pressed end call
// before session was accepted
TERMINATED_APPLICATION_FAILURE,
TERMINATED_SECURITY_ERROR
}
}

View file

@ -4,10 +4,8 @@ import com.google.common.base.MoreObjects;
import com.google.common.base.Objects; import com.google.common.base.Objects;
import com.google.common.collect.Collections2; import com.google.common.collect.Collections2;
import com.google.common.collect.ImmutableSet; import com.google.common.collect.ImmutableSet;
import java.util.Set;
import eu.siacs.conversations.xmpp.jingle.stanzas.Content; import eu.siacs.conversations.xmpp.jingle.stanzas.Content;
import java.util.Set;
public final class ContentAddition { public final class ContentAddition {

View file

@ -10,8 +10,7 @@ import java.util.ArrayList;
import java.util.Enumeration; import java.util.Enumeration;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
import org.jxmpp.jid.Jid;
import eu.siacs.conversations.xmpp.Jid;
public class DirectConnectionUtils { public class DirectConnectionUtils {
@ -25,7 +24,8 @@ public class DirectConnectionUtils {
} }
while (interfaces.hasMoreElements()) { while (interfaces.hasMoreElements()) {
NetworkInterface networkInterface = interfaces.nextElement(); NetworkInterface networkInterface = interfaces.nextElement();
final Enumeration<InetAddress> inetAddressEnumeration = networkInterface.getInetAddresses(); final Enumeration<InetAddress> inetAddressEnumeration =
networkInterface.getInetAddresses();
while (inetAddressEnumeration.hasMoreElements()) { while (inetAddressEnumeration.hasMoreElements()) {
final InetAddress inetAddress = inetAddressEnumeration.nextElement(); final InetAddress inetAddress = inetAddressEnumeration.nextElement();
if (inetAddress.isLoopbackAddress() || inetAddress.isLinkLocalAddress()) { if (inetAddress.isLoopbackAddress() || inetAddress.isLinkLocalAddress()) {
@ -50,7 +50,8 @@ public class DirectConnectionUtils {
SecureRandom random = new SecureRandom(); SecureRandom random = new SecureRandom();
ArrayList<JingleCandidate> candidates = new ArrayList<>(); ArrayList<JingleCandidate> candidates = new ArrayList<>();
for (InetAddress inetAddress : getLocalAddresses()) { for (InetAddress inetAddress : getLocalAddresses()) {
final JingleCandidate candidate = new JingleCandidate(UUID.randomUUID().toString(), true); final JingleCandidate candidate =
new JingleCandidate(UUID.randomUUID().toString(), true);
candidate.setHost(inetAddress.getHostAddress()); candidate.setHost(inetAddress.getHostAddress());
candidate.setPort(random.nextInt(60000) + 1024); candidate.setPort(random.nextInt(60000) + 1024);
candidate.setType(JingleCandidate.TYPE_DIRECT); candidate.setType(JingleCandidate.TYPE_DIRECT);
@ -60,5 +61,4 @@ public class DirectConnectionUtils {
} }
return candidates; return candidates;
} }
} }

View file

@ -0,0 +1,153 @@
package eu.siacs.conversations.xmpp.jingle;
import im.conversations.android.xml.Element;
import java.util.ArrayList;
import java.util.List;
import org.jxmpp.jid.Jid;
public class JingleCandidate {
public static int TYPE_UNKNOWN;
public static int TYPE_DIRECT = 0;
public static int TYPE_PROXY = 1;
private final boolean ours;
private boolean usedByCounterpart = false;
private final String cid;
private String host;
private int port;
private int type;
private Jid jid;
private int priority;
public JingleCandidate(String cid, boolean ours) {
this.ours = ours;
this.cid = cid;
}
public String getCid() {
return cid;
}
public void setHost(String host) {
this.host = host;
}
public String getHost() {
return this.host;
}
public void setJid(final Jid jid) {
this.jid = jid;
}
public Jid getJid() {
return this.jid;
}
public void setPort(int port) {
this.port = port;
}
public int getPort() {
return this.port;
}
public void setType(int type) {
this.type = type;
}
public void setType(String type) {
if (type == null) {
this.type = TYPE_UNKNOWN;
return;
}
switch (type) {
case "proxy":
this.type = TYPE_PROXY;
break;
case "direct":
this.type = TYPE_DIRECT;
break;
default:
this.type = TYPE_UNKNOWN;
break;
}
}
public void setPriority(int i) {
this.priority = i;
}
public int getPriority() {
return this.priority;
}
public boolean equals(JingleCandidate other) {
return this.getCid().equals(other.getCid());
}
public boolean equalValues(JingleCandidate other) {
return other != null
&& other.getHost().equals(this.getHost())
&& (other.getPort() == this.getPort());
}
public boolean isOurs() {
return ours;
}
public int getType() {
return this.type;
}
public static List<JingleCandidate> parse(final List<Element> elements) {
final List<JingleCandidate> candidates = new ArrayList<>();
for (final Element element : elements) {
if ("candidate".equals(element.getName())) {
candidates.add(JingleCandidate.parse(element));
}
}
return candidates;
}
public static JingleCandidate parse(Element element) {
final JingleCandidate candidate = new JingleCandidate(element.getAttribute("cid"), false);
candidate.setHost(element.getAttribute("host"));
candidate.setJid(element.getAttributeAsJid("jid"));
candidate.setType(element.getAttribute("type"));
candidate.setPriority(Integer.parseInt(element.getAttribute("priority")));
candidate.setPort(Integer.parseInt(element.getAttribute("port")));
return candidate;
}
public Element toElement() {
Element element = new Element("candidate");
element.setAttribute("cid", this.getCid());
element.setAttribute("host", this.getHost());
element.setAttribute("port", Integer.toString(this.getPort()));
if (jid != null) {
element.setAttribute("jid", jid);
}
element.setAttribute("priority", Integer.toString(this.getPriority()));
if (this.getType() == TYPE_DIRECT) {
element.setAttribute("type", "direct");
} else if (this.getType() == TYPE_PROXY) {
element.setAttribute("type", "proxy");
}
return element;
}
public void flagAsUsedByCounterpart() {
this.usedByCounterpart = true;
}
public boolean isUsedByCounterpart() {
return this.usedByCounterpart;
}
public String toString() {
return String.format(
"%s:%s (priority=%s,ours=%s)", getHost(), getPort(), getPriority(), isOurs());
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,15 +1,14 @@
package eu.siacs.conversations.xmpp.jingle; package eu.siacs.conversations.xmpp.jingle;
import com.google.common.collect.ImmutableSet; import com.google.common.collect.ImmutableSet;
import java.util.Locale; import java.util.Locale;
import java.util.Set; import java.util.Set;
import javax.annotation.Nonnull; import javax.annotation.Nonnull;
public enum Media { public enum Media {
VIDEO,
VIDEO, AUDIO, UNKNOWN; AUDIO,
UNKNOWN;
@Override @Override
@Nonnull @Nonnull

View file

@ -1,7 +1,6 @@
package eu.siacs.conversations.xmpp.jingle; package eu.siacs.conversations.xmpp.jingle;
import com.google.common.collect.ArrayListMultimap; import com.google.common.collect.ArrayListMultimap;
import java.util.List; import java.util.List;
public class MediaBuilder { public class MediaBuilder {
@ -43,6 +42,7 @@ public class MediaBuilder {
} }
public SessionDescription.Media createMedia() { public SessionDescription.Media createMedia() {
return new SessionDescription.Media(media, port, protocol, formats, connectionData, attributes); return new SessionDescription.Media(
media, port, protocol, formats, connectionData, attributes);
} }
} }

View file

@ -0,0 +1,83 @@
package eu.siacs.conversations.xmpp.jingle;
import com.google.common.base.MoreObjects;
import com.google.common.base.Preconditions;
import im.conversations.android.axolotl.AxolotlService;
import java.util.concurrent.atomic.AtomicBoolean;
import org.whispersystems.libsignal.IdentityKey;
public class OmemoVerification {
private final AtomicBoolean deviceIdWritten = new AtomicBoolean(false);
private final AtomicBoolean identityKeyWritten = new AtomicBoolean(false);
private Integer deviceId;
private IdentityKey identityKey;
public void setDeviceId(final Integer id) {
if (deviceIdWritten.compareAndSet(false, true)) {
this.deviceId = id;
return;
}
throw new IllegalStateException("Device Id has already been set");
}
public int getDeviceId() {
Preconditions.checkNotNull(this.deviceId, "Device ID is null");
return this.deviceId;
}
public boolean hasDeviceId() {
return this.deviceId != null;
}
public void setSessionFingerprint(final IdentityKey identityKey) {
Preconditions.checkNotNull(identityKey, "IdentityKey must not be null");
if (identityKeyWritten.compareAndSet(false, true)) {
this.identityKey = identityKey;
return;
}
throw new IllegalStateException("Identity Key has already been set");
}
public IdentityKey getFingerprint() {
return this.identityKey;
}
public void setOrEnsureEqual(AxolotlService.OmemoVerifiedPayload<?> omemoVerifiedPayload) {
setOrEnsureEqual(omemoVerifiedPayload.getDeviceId(), omemoVerifiedPayload.getFingerprint());
}
public void setOrEnsureEqual(final int deviceId, final IdentityKey identityKey) {
Preconditions.checkNotNull(identityKey, "IdentityKey must not be null");
if (this.deviceIdWritten.get() || this.identityKeyWritten.get()) {
if (this.identityKey == null) {
throw new IllegalStateException(
"No session fingerprint has been previously provided");
}
if (!identityKey.equals(this.identityKey)) {
throw new SecurityException("IdentityKeys did not match");
}
if (this.deviceId == null) {
throw new IllegalStateException("No Device Id has been previously provided");
}
if (this.deviceId != deviceId) {
throw new IllegalStateException("Device Ids did not match");
}
} else {
this.setSessionFingerprint(identityKey);
this.setDeviceId(deviceId);
}
}
public boolean hasFingerprint() {
return this.identityKey != null;
}
@Override
public String toString() {
return MoreObjects.toStringHelper(this)
.add("deviceId", deviceId)
.add("fingerprint", identityKey)
.toString();
}
}

View file

@ -0,0 +1,20 @@
package eu.siacs.conversations.xmpp.jingle;
import eu.siacs.conversations.xmpp.jingle.stanzas.Group;
import eu.siacs.conversations.xmpp.jingle.stanzas.OmemoVerifiedIceUdpTransportInfo;
import java.util.Map;
public class OmemoVerifiedRtpContentMap extends RtpContentMap {
public OmemoVerifiedRtpContentMap(Group group, Map<String, DescriptionTransport> contents) {
super(group, contents);
for (final DescriptionTransport descriptionTransport : contents.values()) {
if (descriptionTransport.transport instanceof OmemoVerifiedIceUdpTransportInfo) {
((OmemoVerifiedIceUdpTransportInfo) descriptionTransport.transport)
.ensureNoPlaintextFingerprint();
continue;
}
throw new IllegalStateException(
"OmemoVerifiedRtpContentMap contains non-verified transport info");
}
}
}

View file

@ -0,0 +1,5 @@
package eu.siacs.conversations.xmpp.jingle;
public interface OnPrimaryCandidateFound {
void onPrimaryCandidateFound(boolean success, JingleCandidate canditate);
}

View file

@ -0,0 +1,7 @@
package eu.siacs.conversations.xmpp.jingle;
public interface OnTransportConnected {
void failed();
void established();
}

View file

@ -0,0 +1,9 @@
package eu.siacs.conversations.xmpp.jingle;
import org.jxmpp.jid.Jid;
public interface OngoingRtpSession {
Jid getWith();
String getSessionId();
}

View file

@ -8,12 +8,10 @@ import com.google.common.base.Strings;
import com.google.common.collect.Collections2; import com.google.common.collect.Collections2;
import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableMultimap;
import com.google.common.collect.ImmutableSet; import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Iterables; import com.google.common.collect.Iterables;
import com.google.common.collect.Maps; import com.google.common.collect.Maps;
import com.google.common.collect.Sets; import com.google.common.collect.Sets;
import eu.siacs.conversations.xmpp.jingle.stanzas.Content; import eu.siacs.conversations.xmpp.jingle.stanzas.Content;
import eu.siacs.conversations.xmpp.jingle.stanzas.GenericDescription; import eu.siacs.conversations.xmpp.jingle.stanzas.GenericDescription;
import eu.siacs.conversations.xmpp.jingle.stanzas.GenericTransportInfo; import eu.siacs.conversations.xmpp.jingle.stanzas.GenericTransportInfo;
@ -22,13 +20,11 @@ import eu.siacs.conversations.xmpp.jingle.stanzas.IceUdpTransportInfo;
import eu.siacs.conversations.xmpp.jingle.stanzas.JinglePacket; import eu.siacs.conversations.xmpp.jingle.stanzas.JinglePacket;
import eu.siacs.conversations.xmpp.jingle.stanzas.OmemoVerifiedIceUdpTransportInfo; import eu.siacs.conversations.xmpp.jingle.stanzas.OmemoVerifiedIceUdpTransportInfo;
import eu.siacs.conversations.xmpp.jingle.stanzas.RtpDescription; import eu.siacs.conversations.xmpp.jingle.stanzas.RtpDescription;
import java.util.Collection; import java.util.Collection;
import java.util.LinkedHashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import javax.annotation.Nonnull; import javax.annotation.Nonnull;
public class RtpContentMap { public class RtpContentMap {
@ -137,7 +133,8 @@ public class RtpContentMap {
if (setup == null) { if (setup == null) {
throw new SecurityException( throw new SecurityException(
String.format( String.format(
"Use of DTLS-SRTP (XEP-0320) is required for content %s but missing setup attribute", "Use of DTLS-SRTP (XEP-0320) is required for content %s but"
+ " missing setup attribute",
entry.getKey())); entry.getKey()));
} }
if (requireActPass && setup != IceUdpTransportInfo.Setup.ACTPASS) { if (requireActPass && setup != IceUdpTransportInfo.Setup.ACTPASS) {
@ -197,24 +194,6 @@ public class RtpContentMap {
dt.senders, null, dt.transport.cloneWrapper()))); dt.senders, null, dt.transport.cloneWrapper())));
} }
RtpContentMap withCandidates(
ImmutableMultimap<String, IceUdpTransportInfo.Candidate> candidates) {
final ImmutableMap.Builder<String, DescriptionTransport> contentBuilder =
new ImmutableMap.Builder<>();
for (final Map.Entry<String, DescriptionTransport> entry : this.contents.entrySet()) {
final String name = entry.getKey();
final DescriptionTransport descriptionTransport = entry.getValue();
final var transport = descriptionTransport.transport;
contentBuilder.put(
name,
new DescriptionTransport(
descriptionTransport.senders,
descriptionTransport.description,
transport.withCandidates(candidates.get(name))));
}
return new RtpContentMap(group, contentBuilder.build());
}
public IceUdpTransportInfo.Credentials getDistinctCredentials() { public IceUdpTransportInfo.Credentials getDistinctCredentials() {
final Set<IceUdpTransportInfo.Credentials> allCredentials = getCredentials(); final Set<IceUdpTransportInfo.Credentials> allCredentials = getCredentials();
final IceUdpTransportInfo.Credentials credentials = final IceUdpTransportInfo.Credentials credentials =
@ -229,12 +208,6 @@ public class RtpContentMap {
throw new IllegalStateException("Content map does not have distinct credentials"); throw new IllegalStateException("Content map does not have distinct credentials");
} }
private Set<String> getCombinedIceOptions() {
final Collection<List<String>> combinedIceOptions =
Collections2.transform(contents.values(), dt -> dt.transport.getIceOptions());
return ImmutableSet.copyOf(Iterables.concat(combinedIceOptions));
}
public Set<IceUdpTransportInfo.Credentials> getCredentials() { public Set<IceUdpTransportInfo.Credentials> getCredentials() {
final Set<IceUdpTransportInfo.Credentials> credentials = final Set<IceUdpTransportInfo.Credentials> credentials =
ImmutableSet.copyOf( ImmutableSet.copyOf(
@ -293,11 +266,6 @@ public class RtpContentMap {
return count == 0; return count == 0;
} }
public boolean hasFullTransportInfo() {
return Collections2.transform(this.contents.values(), dt -> dt.transport.isStub())
.contains(false);
}
public RtpContentMap modifiedCredentials( public RtpContentMap modifiedCredentials(
IceUdpTransportInfo.Credentials credentials, final IceUdpTransportInfo.Setup setup) { IceUdpTransportInfo.Credentials credentials, final IceUdpTransportInfo.Setup setup) {
final ImmutableMap.Builder<String, DescriptionTransport> contentMapBuilder = final ImmutableMap.Builder<String, DescriptionTransport> contentMapBuilder =
@ -324,60 +292,14 @@ public class RtpContentMap {
dt -> new DescriptionTransport(senders, dt.description, dt.transport))); dt -> new DescriptionTransport(senders, dt.description, dt.transport)));
} }
public RtpContentMap modifiedSendersChecked(
final boolean isInitiator, final Map<String, Content.Senders> modification) {
final ImmutableMap.Builder<String, DescriptionTransport> contentMapBuilder =
new ImmutableMap.Builder<>();
for (final Map.Entry<String, DescriptionTransport> content : contents.entrySet()) {
final String id = content.getKey();
final DescriptionTransport descriptionTransport = content.getValue();
final Content.Senders currentSenders = descriptionTransport.senders;
final Content.Senders targetSenders = modification.get(id);
if (targetSenders == null || currentSenders == targetSenders) {
contentMapBuilder.put(id, descriptionTransport);
} else {
checkSenderModification(isInitiator, currentSenders, targetSenders);
contentMapBuilder.put(
id,
new DescriptionTransport(
targetSenders,
descriptionTransport.description,
descriptionTransport.transport));
}
}
return new RtpContentMap(this.group, contentMapBuilder.build());
}
private static void checkSenderModification(
final boolean isInitiator,
final Content.Senders current,
final Content.Senders target) {
if (isInitiator) {
// we were both sending and now other party only wants to receive
if (current == Content.Senders.BOTH && target == Content.Senders.INITIATOR) {
return;
}
// only we were sending but now other party wants to send too
if (current == Content.Senders.INITIATOR && target == Content.Senders.BOTH) {
return;
}
} else {
// we were both sending and now other party only wants to receive
if (current == Content.Senders.BOTH && target == Content.Senders.RESPONDER) {
return;
}
// only we were sending but now other party wants to send too
if (current == Content.Senders.RESPONDER && target == Content.Senders.BOTH) {
return;
}
}
throw new IllegalArgumentException(
String.format("Unsupported senders modification %s -> %s", current, target));
}
public RtpContentMap toContentModification(final Collection<String> modifications) { public RtpContentMap toContentModification(final Collection<String> modifications) {
return new RtpContentMap( return new RtpContentMap(
this.group, Maps.filterKeys(contents, Predicates.in(modifications))); this.group,
Maps.transformValues(
Maps.filterKeys(contents, Predicates.in(modifications)),
dt ->
new DescriptionTransport(
dt.senders, dt.description, IceUdpTransportInfo.STUB)));
} }
public RtpContentMap toStub() { public RtpContentMap toStub() {
@ -414,48 +336,28 @@ public class RtpContentMap {
} }
public RtpContentMap addContent( public RtpContentMap addContent(
final RtpContentMap modification, final IceUdpTransportInfo.Setup setupOverwrite) { final RtpContentMap modification, final IceUdpTransportInfo.Setup setup) {
final IceUdpTransportInfo.Credentials credentials = getDistinctCredentials();
final DTLS dtls = getDistinctDtls();
final IceUdpTransportInfo iceUdpTransportInfo =
IceUdpTransportInfo.of(credentials, setup, dtls.hash, dtls.fingerprint);
final Map<String, DescriptionTransport> combined = merge(contents, modification.contents); final Map<String, DescriptionTransport> combined = merge(contents, modification.contents);
/*new ImmutableMap.Builder<String, DescriptionTransport>()
.putAll(contents)
.putAll(modification.contents)
.build();*/
final Map<String, DescriptionTransport> combinedFixedTransport = final Map<String, DescriptionTransport> combinedFixedTransport =
Maps.transformValues( Maps.transformValues(
combined, combined,
dt -> { dt ->
final IceUdpTransportInfo iceUdpTransportInfo; new DescriptionTransport(
if (dt.transport.isStub()) { dt.senders, dt.description, iceUdpTransportInfo));
final IceUdpTransportInfo.Credentials credentials = return new RtpContentMap(modification.group, combinedFixedTransport);
getDistinctCredentials();
final Collection<String> iceOptions = getCombinedIceOptions();
final DTLS dtls = getDistinctDtls();
iceUdpTransportInfo =
IceUdpTransportInfo.of(
credentials,
iceOptions,
setupOverwrite,
dtls.hash,
dtls.fingerprint);
} else {
final IceUdpTransportInfo.Fingerprint fp =
dt.transport.getFingerprint();
final IceUdpTransportInfo.Setup setup = fp.getSetup();
iceUdpTransportInfo =
IceUdpTransportInfo.of(
dt.transport.getCredentials(),
dt.transport.getIceOptions(),
setup == IceUdpTransportInfo.Setup.ACTPASS
? setupOverwrite
: setup,
fp.getHash(),
fp.getContent());
}
return new DescriptionTransport(
dt.senders, dt.description, iceUdpTransportInfo);
});
return new RtpContentMap(modification.group, ImmutableMap.copyOf(combinedFixedTransport));
} }
private static Map<String, DescriptionTransport> merge( private static Map<String, DescriptionTransport> merge(
final Map<String, DescriptionTransport> a, final Map<String, DescriptionTransport> b) { final Map<String, DescriptionTransport> a, final Map<String, DescriptionTransport> b) {
final Map<String, DescriptionTransport> combined = new LinkedHashMap<>(); final Map<String, DescriptionTransport> combined = new HashMap<>();
combined.putAll(a); combined.putAll(a);
combined.putAll(b); combined.putAll(b);
return ImmutableMap.copyOf(combined); return ImmutableMap.copyOf(combined);

View file

@ -0,0 +1,21 @@
package eu.siacs.conversations.xmpp.jingle;
public enum RtpEndUserState {
INCOMING_CALL, // received a 'propose' message
CONNECTING, // session-initiate or session-accepted but no webrtc peer connection yet
CONNECTED, // session-accepted and webrtc peer connection is connected
RECONNECTING, // session-accepted and webrtc peer connection was connected once but is currently
// disconnected or failed
INCOMING_CONTENT_ADD, // session-accepted with a pending, incoming content-add
FINDING_DEVICE, // 'propose' has been sent out; no 184 ack yet
RINGING, // 'propose' has been sent out and it has been 184 acked
ACCEPTING_CALL, // 'proceed' message has been sent; but no session-initiate has been received
ENDING_CALL, // libwebrt says 'closed' but session-terminate hasnt gone through
ENDED, // close UI
DECLINED_OR_BUSY, // other party declined; no retry button
CONNECTIVITY_ERROR, // network error; retry button
CONNECTIVITY_LOST_ERROR, // network error but for call duration > 0
RETRACTED, // user pressed home or power button during 'ringing' - shows retry button
APPLICATION_ERROR, // something rather bad happened; libwebrtc failed or we got in IQ-error
SECURITY_ERROR // problem with DTLS (missing) or verification
}

View file

@ -2,23 +2,17 @@ package eu.siacs.conversations.xmpp.jingle;
import android.util.Log; import android.util.Log;
import android.util.Pair; import android.util.Pair;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import com.google.common.base.CharMatcher; import com.google.common.base.CharMatcher;
import com.google.common.base.Joiner; import com.google.common.base.Joiner;
import com.google.common.base.Strings; import com.google.common.base.Strings;
import com.google.common.collect.ArrayListMultimap; import com.google.common.collect.ArrayListMultimap;
import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableList;
import eu.siacs.conversations.Config; import eu.siacs.conversations.Config;
import eu.siacs.conversations.xml.Namespace;
import eu.siacs.conversations.xmpp.jingle.stanzas.Group; import eu.siacs.conversations.xmpp.jingle.stanzas.Group;
import eu.siacs.conversations.xmpp.jingle.stanzas.IceUdpTransportInfo; import eu.siacs.conversations.xmpp.jingle.stanzas.IceUdpTransportInfo;
import eu.siacs.conversations.xmpp.jingle.stanzas.RtpDescription; import eu.siacs.conversations.xmpp.jingle.stanzas.RtpDescription;
import im.conversations.android.xml.Namespace;
import java.util.Collection;
import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
@ -29,8 +23,7 @@ public class SessionDescription {
private static final String HARDCODED_MEDIA_PROTOCOL = private static final String HARDCODED_MEDIA_PROTOCOL =
"UDP/TLS/RTP/SAVPF"; // probably only true for DTLS-SRTP aka when we have a fingerprint "UDP/TLS/RTP/SAVPF"; // probably only true for DTLS-SRTP aka when we have a fingerprint
private static final int HARDCODED_MEDIA_PORT = 9; private static final int HARDCODED_MEDIA_PORT = 9;
private static final Collection<String> HARDCODED_ICE_OPTIONS = private static final String HARDCODED_ICE_OPTIONS = "trickle";
Collections.singleton("trickle");
private static final String HARDCODED_CONNECTION = "IN IP4 0.0.0.0"; private static final String HARDCODED_CONNECTION = "IN IP4 0.0.0.0";
public final int version; public final int version;
@ -170,21 +163,11 @@ public class SessionDescription {
} }
checkNoWhitespace(pwd, "pwd value must not contain any whitespaces"); checkNoWhitespace(pwd, "pwd value must not contain any whitespaces");
mediaAttributes.put("ice-pwd", pwd); mediaAttributes.put("ice-pwd", pwd);
final List<String> negotiatedIceOptions = transport.getIceOptions(); mediaAttributes.put("ice-options", HARDCODED_ICE_OPTIONS);
final Collection<String> iceOptions =
negotiatedIceOptions.isEmpty() ? HARDCODED_ICE_OPTIONS : negotiatedIceOptions;
mediaAttributes.put("ice-options", Joiner.on(' ').join(iceOptions));
final IceUdpTransportInfo.Fingerprint fingerprint = transport.getFingerprint(); final IceUdpTransportInfo.Fingerprint fingerprint = transport.getFingerprint();
if (fingerprint != null) { if (fingerprint != null) {
final String hashFunction = fingerprint.getHash(); mediaAttributes.put(
final String hash = fingerprint.getContent(); "fingerprint", fingerprint.getHash() + " " + fingerprint.getContent());
if (Strings.isNullOrEmpty(hashFunction) || Strings.isNullOrEmpty(hash)) {
throw new IllegalArgumentException("DTLS-SRTP missing hash");
}
checkNoWhitespace(
hashFunction, "DTLS-SRTP hash function must not contain whitespace");
checkNoWhitespace(hash, "DTLS-SRTP hash must not contain whitespace");
mediaAttributes.put("fingerprint", hashFunction + " " + hash);
final IceUdpTransportInfo.Setup setup = fingerprint.getSetup(); final IceUdpTransportInfo.Setup setup = fingerprint.getSetup();
if (setup != null) { if (setup != null) {
mediaAttributes.put("setup", setup.toString().toLowerCase(Locale.ROOT)); mediaAttributes.put("setup", setup.toString().toLowerCase(Locale.ROOT));
@ -221,14 +204,12 @@ public class SessionDescription {
} }
checkNoWhitespace( checkNoWhitespace(
type, "feedback negotiation type must not contain whitespace"); type, "feedback negotiation type must not contain whitespace");
if (Strings.isNullOrEmpty(subtype)) { mediaAttributes.put(
mediaAttributes.put("rtcp-fb", id + " " + type); "rtcp-fb",
} else { id
checkNoWhitespace( + " "
subtype, + type
"feedback negotiation subtype must not contain whitespace"); + (Strings.isNullOrEmpty(subtype) ? "" : " " + subtype));
mediaAttributes.put("rtcp-fb", id + " " + type + " " + subtype);
}
} }
for (RtpDescription.FeedbackNegotiationTrrInt feedbackNegotiationTrrInt : for (RtpDescription.FeedbackNegotiationTrrInt feedbackNegotiationTrrInt :
payloadType.feedbackNegotiationTrrInts()) { payloadType.feedbackNegotiationTrrInts()) {
@ -245,13 +226,9 @@ public class SessionDescription {
throw new IllegalArgumentException("a feedback negotiation is missing type"); throw new IllegalArgumentException("a feedback negotiation is missing type");
} }
checkNoWhitespace(type, "feedback negotiation type must not contain whitespace"); checkNoWhitespace(type, "feedback negotiation type must not contain whitespace");
if (Strings.isNullOrEmpty(subtype)) { mediaAttributes.put(
mediaAttributes.put("rtcp-fb", "* " + type); "rtcp-fb",
} else { "* " + type + (Strings.isNullOrEmpty(subtype) ? "" : " " + subtype));
checkNoWhitespace(
subtype, "feedback negotiation subtype must not contain whitespace");
mediaAttributes.put("rtcp-fb", "* " + type + " " + subtype); /**/
}
} }
for (final RtpDescription.FeedbackNegotiationTrrInt feedbackNegotiationTrrInt : for (final RtpDescription.FeedbackNegotiationTrrInt feedbackNegotiationTrrInt :
description.feedbackNegotiationTrrInts()) { description.feedbackNegotiationTrrInts()) {
@ -288,9 +265,6 @@ public class SessionDescription {
if (groups.size() == 0) { if (groups.size() == 0) {
throw new IllegalArgumentException("A SSRC group is missing SSRC ids"); throw new IllegalArgumentException("A SSRC group is missing SSRC ids");
} }
for (final String source : groups) {
checkNoWhitespace(source, "Sources must not contain whitespace");
}
mediaAttributes.put( mediaAttributes.put(
"ssrc-group", "ssrc-group",
String.format("%s %s", semantics, Joiner.on(' ').join(groups))); String.format("%s %s", semantics, Joiner.on(' ').join(groups)));
@ -314,14 +288,7 @@ public class SessionDescription {
throw new IllegalArgumentException( throw new IllegalArgumentException(
"A source specific media attribute is missing its value"); "A source specific media attribute is missing its value");
} }
checkNoWhitespace( mediaAttributes.put("ssrc", id + " " + parameterName + ":" + parameterValue);
parameterName,
"A source specific media attribute name not not contain whitespace");
checkNoNewline(
parameterValue,
"A source specific media attribute value must not contain new lines");
mediaAttributes.put(
"ssrc", id + " " + parameterName + ":" + parameterValue.trim());
} }
} }
@ -360,13 +327,6 @@ public class SessionDescription {
return input; return input;
} }
public static String checkNoNewline(final String input, final String message) {
if (CharMatcher.anyOf("\r\n").matchesAnyOf(message)) {
throw new IllegalArgumentException(message);
}
return input;
}
public static int ignorantIntParser(final String input) { public static int ignorantIntParser(final String input) {
try { try {
return Integer.parseInt(input); return Integer.parseInt(input);

View file

@ -1,7 +1,6 @@
package eu.siacs.conversations.xmpp.jingle; package eu.siacs.conversations.xmpp.jingle;
import com.google.common.collect.ArrayListMultimap; import com.google.common.collect.ArrayListMultimap;
import java.util.List; import java.util.List;
public class SessionDescriptionBuilder { public class SessionDescriptionBuilder {

View file

@ -0,0 +1,274 @@
package eu.siacs.conversations.xmpp.jingle;
import static java.util.Arrays.asList;
import android.content.Context;
import android.media.AudioManager;
import android.media.ToneGenerator;
import android.util.Log;
import eu.siacs.conversations.Config;
import im.conversations.android.xmpp.manager.JingleConnectionManager;
import java.util.Arrays;
import java.util.Set;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
public class ToneManager {
private final ToneGenerator toneGenerator;
private final Context context;
private ToneState state = null;
private RtpEndUserState endUserState = null;
private ScheduledFuture<?> currentTone;
private ScheduledFuture<?> currentResetFuture;
private boolean appRtcAudioManagerHasControl = false;
private static volatile ToneManager INSTANCE;
private ToneManager(final Context context) {
ToneGenerator toneGenerator;
try {
toneGenerator = new ToneGenerator(AudioManager.STREAM_VOICE_CALL, 60);
} catch (final RuntimeException e) {
Log.e(Config.LOGTAG, "unable to instantiate ToneGenerator", e);
toneGenerator = null;
}
this.toneGenerator = toneGenerator;
this.context = context.getApplicationContext();
}
private static ToneState of(
final boolean isInitiator, final RtpEndUserState state, final Set<Media> media) {
if (isInitiator) {
if (asList(
RtpEndUserState.FINDING_DEVICE,
RtpEndUserState.RINGING,
RtpEndUserState.CONNECTING)
.contains(state)) {
return ToneState.RINGING;
}
if (state == RtpEndUserState.DECLINED_OR_BUSY) {
return ToneState.BUSY;
}
}
if (state == RtpEndUserState.ENDING_CALL) {
if (media.contains(Media.VIDEO)) {
return ToneState.NULL;
} else {
return ToneState.ENDING_CALL;
}
}
if (Arrays.asList(
RtpEndUserState.CONNECTED,
RtpEndUserState.RECONNECTING,
RtpEndUserState.INCOMING_CONTENT_ADD)
.contains(state)) {
if (media.contains(Media.VIDEO)) {
return ToneState.NULL;
} else {
return ToneState.CONNECTED;
}
}
return ToneState.NULL;
}
public void transition(final RtpEndUserState state, final Set<Media> media) {
transition(state, of(true, state, media), media);
}
void transition(
final boolean isInitiator, final RtpEndUserState state, final Set<Media> media) {
transition(state, of(isInitiator, state, media), media);
}
private synchronized void transition(
final RtpEndUserState endUserState, final ToneState state, final Set<Media> media) {
final RtpEndUserState normalizeEndUserState = normalize(endUserState);
if (this.endUserState == normalizeEndUserState) {
return;
}
this.endUserState = normalizeEndUserState;
if (this.state == state) {
return;
}
if (state == ToneState.NULL && this.state == ToneState.ENDING_CALL) {
return;
}
cancelCurrentTone();
Log.d(Config.LOGTAG, getClass().getName() + ".transition(" + state + ")");
if (state != ToneState.NULL) {
configureAudioManagerForCall(media);
}
switch (state) {
case RINGING:
scheduleWaitingTone();
break;
case CONNECTED:
scheduleConnected();
break;
case BUSY:
scheduleBusy();
break;
case ENDING_CALL:
scheduleEnding();
break;
case NULL:
if (noResetScheduled()) {
resetAudioManager();
}
break;
default:
throw new IllegalStateException("Unable to handle transition to " + state);
}
this.state = state;
}
private static RtpEndUserState normalize(final RtpEndUserState endUserState) {
if (Arrays.asList(
RtpEndUserState.CONNECTED,
RtpEndUserState.RECONNECTING,
RtpEndUserState.INCOMING_CONTENT_ADD)
.contains(endUserState)) {
return RtpEndUserState.CONNECTED;
} else {
return endUserState;
}
}
void setAppRtcAudioManagerHasControl(final boolean appRtcAudioManagerHasControl) {
this.appRtcAudioManagerHasControl = appRtcAudioManagerHasControl;
}
private void scheduleConnected() {
this.currentTone =
JingleConnectionManager.SCHEDULED_EXECUTOR_SERVICE.schedule(
() -> {
startTone(ToneGenerator.TONE_PROP_PROMPT, 200);
},
0,
TimeUnit.SECONDS);
}
private void scheduleEnding() {
this.currentTone =
JingleConnectionManager.SCHEDULED_EXECUTOR_SERVICE.schedule(
() -> {
startTone(ToneGenerator.TONE_CDMA_CALLDROP_LITE, 375);
},
0,
TimeUnit.SECONDS);
this.currentResetFuture =
JingleConnectionManager.SCHEDULED_EXECUTOR_SERVICE.schedule(
this::resetAudioManager, 375, TimeUnit.MILLISECONDS);
}
private void scheduleBusy() {
this.currentTone =
JingleConnectionManager.SCHEDULED_EXECUTOR_SERVICE.schedule(
() -> {
startTone(ToneGenerator.TONE_CDMA_NETWORK_BUSY, 2500);
},
0,
TimeUnit.SECONDS);
this.currentResetFuture =
JingleConnectionManager.SCHEDULED_EXECUTOR_SERVICE.schedule(
this::resetAudioManager, 2500, TimeUnit.MILLISECONDS);
}
private void scheduleWaitingTone() {
this.currentTone =
JingleConnectionManager.SCHEDULED_EXECUTOR_SERVICE.scheduleAtFixedRate(
() -> {
startTone(ToneGenerator.TONE_CDMA_DIAL_TONE_LITE, 750);
},
0,
3,
TimeUnit.SECONDS);
}
private boolean noResetScheduled() {
return this.currentResetFuture == null || this.currentResetFuture.isDone();
}
private void cancelCurrentTone() {
if (currentTone != null) {
currentTone.cancel(true);
}
if (toneGenerator != null) {
toneGenerator.stopTone();
}
}
private void startTone(final int toneType, final int durationMs) {
if (toneGenerator != null) {
this.toneGenerator.startTone(toneType, durationMs);
} else {
Log.e(Config.LOGTAG, "failed to start tone. ToneGenerator doesn't exist");
}
}
private void configureAudioManagerForCall(final Set<Media> media) {
if (appRtcAudioManagerHasControl) {
Log.d(
Config.LOGTAG,
ToneManager.class.getName()
+ ": do not configure audio manager because RTC has control");
return;
}
final AudioManager audioManager =
(AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
if (audioManager == null) {
return;
}
final boolean isSpeakerPhone = media.contains(Media.VIDEO);
Log.d(
Config.LOGTAG,
ToneManager.class.getName()
+ ": putting AudioManager into communication mode. speaker="
+ isSpeakerPhone);
audioManager.setMode(AudioManager.MODE_IN_COMMUNICATION);
audioManager.setSpeakerphoneOn(isSpeakerPhone);
}
private void resetAudioManager() {
if (appRtcAudioManagerHasControl) {
Log.d(
Config.LOGTAG,
ToneManager.class.getName()
+ ": do not reset audio manager because RTC has control");
return;
}
final AudioManager audioManager =
(AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
if (audioManager == null) {
return;
}
Log.d(
Config.LOGTAG,
ToneManager.class.getName() + ": putting AudioManager back into normal mode");
audioManager.setMode(AudioManager.MODE_NORMAL);
audioManager.setSpeakerphoneOn(false);
}
public static ToneManager getInstance(final Context context) {
if (INSTANCE != null) {
return INSTANCE;
}
synchronized (ToneManager.class) {
if (INSTANCE != null) {
return INSTANCE;
}
INSTANCE = new ToneManager(context);
return INSTANCE;
}
}
private enum ToneState {
NULL,
RINGING,
CONNECTED,
BUSY,
ENDING_CALL
}
}

View file

@ -1,23 +1,18 @@
package eu.siacs.conversations.xmpp.jingle; package eu.siacs.conversations.xmpp.jingle;
import android.util.Log; import android.util.Log;
import com.google.common.base.CaseFormat; import com.google.common.base.CaseFormat;
import com.google.common.base.Optional; import com.google.common.base.Optional;
import com.google.common.base.Preconditions; import com.google.common.base.Preconditions;
import eu.siacs.conversations.Config; import eu.siacs.conversations.Config;
import java.util.UUID;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import org.webrtc.MediaStreamTrack; import org.webrtc.MediaStreamTrack;
import org.webrtc.PeerConnection; import org.webrtc.PeerConnection;
import org.webrtc.RtpSender; import org.webrtc.RtpSender;
import org.webrtc.RtpTransceiver; import org.webrtc.RtpTransceiver;
import java.util.UUID;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
class TrackWrapper<T extends MediaStreamTrack> { class TrackWrapper<T extends MediaStreamTrack> {
public final T track; public final T track;
public final RtpSender rtpSender; public final RtpSender rtpSender;
@ -43,13 +38,9 @@ class TrackWrapper<T extends MediaStreamTrack> {
final RtpTransceiver transceiver = final RtpTransceiver transceiver =
peerConnection == null ? null : getTransceiver(peerConnection, trackWrapper); peerConnection == null ? null : getTransceiver(peerConnection, trackWrapper);
if (transceiver == null) { if (transceiver == null) {
final String id; Log.w(
try { Config.LOGTAG,
id = trackWrapper.rtpSender.id(); "unable to detect transceiver for " + trackWrapper.getRtpSenderId());
} catch (final IllegalStateException e) {
return Optional.absent();
}
Log.w(Config.LOGTAG, "unable to detect transceiver for " + id);
return Optional.of(trackWrapper.track); return Optional.of(trackWrapper.track);
} }
final RtpTransceiver.RtpTransceiverDirection direction = transceiver.getDirection(); final RtpTransceiver.RtpTransceiverDirection direction = transceiver.getDirection();
@ -62,11 +53,22 @@ class TrackWrapper<T extends MediaStreamTrack> {
} }
} }
public String getRtpSenderId() {
try {
return track.id();
} catch (final IllegalStateException e) {
return null;
}
}
public static <T extends MediaStreamTrack> RtpTransceiver getTransceiver( public static <T extends MediaStreamTrack> RtpTransceiver getTransceiver(
@Nonnull final PeerConnection peerConnection, final TrackWrapper<T> trackWrapper) { @Nonnull final PeerConnection peerConnection, final TrackWrapper<T> trackWrapper) {
final RtpSender rtpSender = trackWrapper.rtpSender; final String rtpSenderId = trackWrapper.getRtpSenderId();
if (rtpSenderId == null) {
return null;
}
for (final RtpTransceiver transceiver : peerConnection.getTransceivers()) { for (final RtpTransceiver transceiver : peerConnection.getTransceivers()) {
if (transceiver.getSender().id().equals(rtpSender.id())) { if (transceiver.getSender().id().equals(rtpSenderId)) {
return transceiver; return transceiver;
} }
} }

View file

@ -2,12 +2,15 @@ package eu.siacs.conversations.xmpp.jingle;
import android.content.Context; import android.content.Context;
import android.util.Log; import android.util.Log;
import com.google.common.collect.ImmutableSet; import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Iterables; import com.google.common.collect.Iterables;
import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.SettableFuture; import com.google.common.util.concurrent.SettableFuture;
import eu.siacs.conversations.Config;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Set;
import javax.annotation.Nullable;
import org.webrtc.Camera2Enumerator; import org.webrtc.Camera2Enumerator;
import org.webrtc.CameraEnumerationAndroid; import org.webrtc.CameraEnumerationAndroid;
import org.webrtc.CameraEnumerator; import org.webrtc.CameraEnumerator;
@ -17,14 +20,6 @@ import org.webrtc.PeerConnectionFactory;
import org.webrtc.SurfaceTextureHelper; import org.webrtc.SurfaceTextureHelper;
import org.webrtc.VideoSource; import org.webrtc.VideoSource;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Set;
import javax.annotation.Nullable;
import eu.siacs.conversations.Config;
class VideoSourceWrapper { class VideoSourceWrapper {
private static final int CAPTURING_RESOLUTION = 1920; private static final int CAPTURING_RESOLUTION = 1920;

View file

@ -1,32 +1,34 @@
package eu.siacs.conversations.xmpp.jingle; package eu.siacs.conversations.xmpp.jingle;
import android.content.Context; import android.content.Context;
import android.media.ToneGenerator;
import android.os.Build; import android.os.Build;
import android.os.Handler; import android.os.Handler;
import android.os.Looper; import android.os.Looper;
import android.util.Log; import android.util.Log;
import com.google.common.base.Optional; import com.google.common.base.Optional;
import com.google.common.base.Preconditions; import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet; import com.google.common.collect.ImmutableSet;
import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors; import com.google.common.util.concurrent.MoreExecutors;
import com.google.common.util.concurrent.SettableFuture; import com.google.common.util.concurrent.SettableFuture;
import eu.siacs.conversations.Config; import eu.siacs.conversations.Config;
import eu.siacs.conversations.services.AppRTCAudioManager; import eu.siacs.conversations.services.AppRTCAudioManager;
import eu.siacs.conversations.services.XmppConnectionService; import java.util.LinkedList;
import java.util.List;
import java.util.Queue;
import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import org.webrtc.AudioSource; import org.webrtc.AudioSource;
import org.webrtc.AudioTrack; import org.webrtc.AudioTrack;
import org.webrtc.CandidatePairChangeEvent; import org.webrtc.CandidatePairChangeEvent;
import org.webrtc.DataChannel; import org.webrtc.DataChannel;
import org.webrtc.DefaultVideoDecoderFactory; import org.webrtc.DefaultVideoDecoderFactory;
import org.webrtc.DefaultVideoEncoderFactory; import org.webrtc.DefaultVideoEncoderFactory;
import org.webrtc.DtmfSender;
import org.webrtc.EglBase; import org.webrtc.EglBase;
import org.webrtc.IceCandidate; import org.webrtc.IceCandidate;
import org.webrtc.MediaConstraints; import org.webrtc.MediaConstraints;
@ -40,20 +42,7 @@ import org.webrtc.SdpObserver;
import org.webrtc.SessionDescription; import org.webrtc.SessionDescription;
import org.webrtc.VideoTrack; import org.webrtc.VideoTrack;
import org.webrtc.audio.JavaAudioDeviceModule; import org.webrtc.audio.JavaAudioDeviceModule;
import org.webrtc.voiceengine.WebRtcAudioEffects;
import java.util.LinkedList;
import java.util.List;
import java.util.Queue;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
@SuppressWarnings("UnstableApiUsage") @SuppressWarnings("UnstableApiUsage")
public class WebRTCWrapper { public class WebRTCWrapper {
@ -61,27 +50,6 @@ public class WebRTCWrapper {
private static final String EXTENDED_LOGGING_TAG = WebRTCWrapper.class.getSimpleName(); private static final String EXTENDED_LOGGING_TAG = WebRTCWrapper.class.getSimpleName();
private final ExecutorService executorService = Executors.newSingleThreadExecutor(); private final ExecutorService executorService = Executors.newSingleThreadExecutor();
private final ExecutorService localDescriptionExecutorService =
Executors.newSingleThreadExecutor();
private static final int TONE_DURATION = 500;
private static final Map<String,Integer> TONE_CODES;
static {
ImmutableMap.Builder<String,Integer> builder = new ImmutableMap.Builder<>();
builder.put("0", ToneGenerator.TONE_DTMF_0);
builder.put("1", ToneGenerator.TONE_DTMF_1);
builder.put("2", ToneGenerator.TONE_DTMF_2);
builder.put("3", ToneGenerator.TONE_DTMF_3);
builder.put("4", ToneGenerator.TONE_DTMF_4);
builder.put("5", ToneGenerator.TONE_DTMF_5);
builder.put("6", ToneGenerator.TONE_DTMF_6);
builder.put("7", ToneGenerator.TONE_DTMF_7);
builder.put("8", ToneGenerator.TONE_DTMF_8);
builder.put("9", ToneGenerator.TONE_DTMF_9);
builder.put("*", ToneGenerator.TONE_DTMF_S);
builder.put("#", ToneGenerator.TONE_DTMF_P);
TONE_CODES = builder.build();
}
private static final Set<String> HARDWARE_AEC_BLACKLIST = private static final Set<String> HARDWARE_AEC_BLACKLIST =
new ImmutableSet.Builder<String>() new ImmutableSet.Builder<String>()
@ -96,7 +64,6 @@ public class WebRTCWrapper {
.add("E5823") // Sony z5 compact .add("E5823") // Sony z5 compact
.add("Redmi Note 5") .add("Redmi Note 5")
.add("FP2") // Fairphone FP2 .add("FP2") // Fairphone FP2
.add("FP4") // Fairphone FP4
.add("MI 5") .add("MI 5")
.add("GT-I9515") // Samsung Galaxy S4 Value Edition (jfvelte) .add("GT-I9515") // Samsung Galaxy S4 Value Edition (jfvelte)
.add("GT-I9515L") // Samsung Galaxy S4 Value Edition (jfvelte) .add("GT-I9515L") // Samsung Galaxy S4 Value Edition (jfvelte)
@ -119,8 +86,6 @@ public class WebRTCWrapper {
private TrackWrapper<AudioTrack> localAudioTrack = null; private TrackWrapper<AudioTrack> localAudioTrack = null;
private TrackWrapper<VideoTrack> localVideoTrack = null; private TrackWrapper<VideoTrack> localVideoTrack = null;
private VideoTrack remoteVideoTrack = null; private VideoTrack remoteVideoTrack = null;
private final SettableFuture<Void> iceGatheringComplete = SettableFuture.create();
private final PeerConnection.Observer peerConnectionObserver = private final PeerConnection.Observer peerConnectionObserver =
new PeerConnection.Observer() { new PeerConnection.Observer() {
@Override @Override
@ -155,11 +120,8 @@ public class WebRTCWrapper {
@Override @Override
public void onIceGatheringChange( public void onIceGatheringChange(
final PeerConnection.IceGatheringState iceGatheringState) { PeerConnection.IceGatheringState iceGatheringState) {
Log.d(EXTENDED_LOGGING_TAG, "onIceGatheringChange(" + iceGatheringState + ")"); Log.d(EXTENDED_LOGGING_TAG, "onIceGatheringChange(" + iceGatheringState + ")");
if (iceGatheringState == PeerConnection.IceGatheringState.COMPLETE) {
iceGatheringComplete.set(null);
}
} }
@Override @Override
@ -238,7 +200,6 @@ public class WebRTCWrapper {
@Nullable private PeerConnectionFactory peerConnectionFactory = null; @Nullable private PeerConnectionFactory peerConnectionFactory = null;
@Nullable private PeerConnection peerConnection = null; @Nullable private PeerConnection peerConnection = null;
private AppRTCAudioManager appRTCAudioManager = null; private AppRTCAudioManager appRTCAudioManager = null;
private ToneManager toneManager = null;
private Context context = null; private Context context = null;
private EglBase eglBase = null; private EglBase eglBase = null;
private VideoSourceWrapper videoSourceWrapper; private VideoSourceWrapper videoSourceWrapper;
@ -255,8 +216,16 @@ public class WebRTCWrapper {
} }
} }
private static void dispose(final VideoTrack videoTrack) {
try {
videoTrack.dispose();
} catch (final IllegalStateException e) {
Log.e(Config.LOGTAG, "unable to dispose of video track", e);
}
}
public void setup( public void setup(
final XmppConnectionService service, final Context service,
@Nonnull final AppRTCAudioManager.SpeakerPhonePreference speakerPhonePreference) @Nonnull final AppRTCAudioManager.SpeakerPhonePreference speakerPhonePreference)
throws InitializationException { throws InitializationException {
try { try {
@ -273,11 +242,10 @@ public class WebRTCWrapper {
throw new InitializationException("Unable to create EGL base", e); throw new InitializationException("Unable to create EGL base", e);
} }
this.context = service; this.context = service;
this.toneManager = service.getJingleConnectionManager().toneManager;
mainHandler.post( mainHandler.post(
() -> { () -> {
appRTCAudioManager = AppRTCAudioManager.create(service, speakerPhonePreference); appRTCAudioManager = AppRTCAudioManager.create(service, speakerPhonePreference);
toneManager.setAppRtcAudioManagerHasControl(true); ToneManager.getInstance(context).setAppRtcAudioManagerHasControl(true);
appRTCAudioManager.start(audioManagerEvents); appRTCAudioManager.start(audioManagerEvents);
eventCallback.onAudioDeviceChanged( eventCallback.onAudioDeviceChanged(
appRTCAudioManager.getSelectedAudioDevice(), appRTCAudioManager.getSelectedAudioDevice(),
@ -286,16 +254,15 @@ public class WebRTCWrapper {
} }
synchronized void initializePeerConnection( synchronized void initializePeerConnection(
final Set<Media> media, final Set<Media> media, final List<PeerConnection.IceServer> iceServers)
final List<PeerConnection.IceServer> iceServers,
final boolean trickle)
throws InitializationException { throws InitializationException {
Preconditions.checkState(this.eglBase != null); Preconditions.checkState(this.eglBase != null);
Preconditions.checkNotNull(media); Preconditions.checkNotNull(media);
Preconditions.checkArgument( Preconditions.checkArgument(
media.size() > 0, "media can not be empty when initializing peer connection"); media.size() > 0, "media can not be empty when initializing peer connection");
final boolean setUseHardwareAcousticEchoCanceler = final boolean setUseHardwareAcousticEchoCanceler =
!HARDWARE_AEC_BLACKLIST.contains(Build.MODEL); WebRtcAudioEffects.canUseAcousticEchoCanceler()
&& !HARDWARE_AEC_BLACKLIST.contains(Build.MODEL);
Log.d( Log.d(
Config.LOGTAG, Config.LOGTAG,
String.format( String.format(
@ -315,7 +282,7 @@ public class WebRTCWrapper {
.createAudioDeviceModule()) .createAudioDeviceModule())
.createPeerConnectionFactory(); .createPeerConnectionFactory();
final PeerConnection.RTCConfiguration rtcConfig = buildConfiguration(iceServers, trickle); final PeerConnection.RTCConfiguration rtcConfig = buildConfiguration(iceServers);
final PeerConnection peerConnection = final PeerConnection peerConnection =
requirePeerConnectionFactory() requirePeerConnectionFactory()
.createPeerConnection(rtcConfig, peerConnectionObserver); .createPeerConnection(rtcConfig, peerConnectionObserver);
@ -430,43 +397,38 @@ public class WebRTCWrapper {
} }
private static PeerConnection.RTCConfiguration buildConfiguration( private static PeerConnection.RTCConfiguration buildConfiguration(
final List<PeerConnection.IceServer> iceServers, final boolean trickle) { final List<PeerConnection.IceServer> iceServers) {
final PeerConnection.RTCConfiguration rtcConfig = final PeerConnection.RTCConfiguration rtcConfig =
new PeerConnection.RTCConfiguration(iceServers); new PeerConnection.RTCConfiguration(iceServers);
rtcConfig.tcpCandidatePolicy = rtcConfig.tcpCandidatePolicy =
PeerConnection.TcpCandidatePolicy.DISABLED; // XEP-0176 doesn't support tcp PeerConnection.TcpCandidatePolicy.DISABLED; // XEP-0176 doesn't support tcp
if (trickle) {
rtcConfig.continualGatheringPolicy = rtcConfig.continualGatheringPolicy =
PeerConnection.ContinualGatheringPolicy.GATHER_CONTINUALLY; PeerConnection.ContinualGatheringPolicy.GATHER_CONTINUALLY;
} else {
rtcConfig.continualGatheringPolicy =
PeerConnection.ContinualGatheringPolicy.GATHER_ONCE;
}
rtcConfig.sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN; rtcConfig.sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN;
rtcConfig.rtcpMuxPolicy = PeerConnection.RtcpMuxPolicy.NEGOTIATE; rtcConfig.rtcpMuxPolicy = PeerConnection.RtcpMuxPolicy.NEGOTIATE;
rtcConfig.enableImplicitRollback = true; rtcConfig.enableImplicitRollback = true;
return rtcConfig; return rtcConfig;
} }
void reconfigurePeerConnection( void reconfigurePeerConnection(final List<PeerConnection.IceServer> iceServers) {
final List<PeerConnection.IceServer> iceServers, final boolean trickle) { requirePeerConnection().setConfiguration(buildConfiguration(iceServers));
requirePeerConnection().setConfiguration(buildConfiguration(iceServers, trickle));
} }
void restartIceAsync() { void restartIce() {
this.execute(this::restartIce); executorService.execute(
} () -> {
private void restartIce() {
final PeerConnection peerConnection; final PeerConnection peerConnection;
try { try {
peerConnection = requirePeerConnection(); peerConnection = requirePeerConnection();
} catch (final PeerConnectionNotInitialized e) { } catch (final PeerConnectionNotInitialized e) {
Log.w(EXTENDED_LOGGING_TAG, "PeerConnection vanished before we could execute restart"); Log.w(
EXTENDED_LOGGING_TAG,
"PeerConnection vanished before we could execute restart");
return; return;
} }
setIsReadyToReceiveIceCandidates(false); setIsReadyToReceiveIceCandidates(false);
peerConnection.restartIce(); peerConnection.restartIce();
});
} }
public void setIsReadyToReceiveIceCandidates(final boolean ready) { public void setIsReadyToReceiveIceCandidates(final boolean ready) {
@ -487,15 +449,19 @@ public class WebRTCWrapper {
final VideoSourceWrapper videoSourceWrapper = this.videoSourceWrapper; final VideoSourceWrapper videoSourceWrapper = this.videoSourceWrapper;
final AppRTCAudioManager audioManager = this.appRTCAudioManager; final AppRTCAudioManager audioManager = this.appRTCAudioManager;
final EglBase eglBase = this.eglBase; final EglBase eglBase = this.eglBase;
final var localVideoTrack = this.localVideoTrack;
if (peerConnection != null) { if (peerConnection != null) {
this.peerConnection = null; this.peerConnection = null;
dispose(peerConnection); dispose(peerConnection);
} }
if (audioManager != null) { if (audioManager != null) {
toneManager.setAppRtcAudioManagerHasControl(false); ToneManager.getInstance(context).setAppRtcAudioManagerHasControl(false);
mainHandler.post(audioManager::stop); mainHandler.post(audioManager::stop);
} }
if (localVideoTrack != null) {
this.localVideoTrack = null; this.localVideoTrack = null;
dispose(localVideoTrack.track);
}
this.remoteVideoTrack = null; this.remoteVideoTrack = null;
if (videoSourceWrapper != null) { if (videoSourceWrapper != null) {
this.videoSourceWrapper = null; this.videoSourceWrapper = null;
@ -507,8 +473,8 @@ public class WebRTCWrapper {
videoSourceWrapper.dispose(); videoSourceWrapper.dispose();
} }
if (eglBase != null) { if (eglBase != null) {
eglBase.release();
this.eglBase = null; this.eglBase = null;
eglBase.release();
} }
if (peerConnectionFactory != null) { if (peerConnectionFactory != null) {
this.peerConnectionFactory = null; this.peerConnectionFactory = null;
@ -548,14 +514,8 @@ public class WebRTCWrapper {
} }
boolean isMicrophoneEnabled() { boolean isMicrophoneEnabled() {
Optional<AudioTrack> audioTrack = null; final Optional<AudioTrack> audioTrack =
try { TrackWrapper.get(peerConnection, this.localAudioTrack);
audioTrack = TrackWrapper.get(peerConnection, this.localAudioTrack);
} catch (final IllegalStateException e) {
Log.d(Config.LOGTAG, "unable to check microphone", e);
// ignoring race condition in case sender has been disposed
return false;
}
if (audioTrack.isPresent()) { if (audioTrack.isPresent()) {
try { try {
return audioTrack.get().enabled(); return audioTrack.get().enabled();
@ -565,19 +525,13 @@ public class WebRTCWrapper {
return false; return false;
} }
} else { } else {
return false; throw new IllegalStateException("Local audio track does not exist (yet)");
} }
} }
boolean setMicrophoneEnabled(final boolean enabled) { boolean setMicrophoneEnabled(final boolean enabled) {
Optional<AudioTrack> audioTrack = null; final Optional<AudioTrack> audioTrack =
try { TrackWrapper.get(peerConnection, this.localAudioTrack);
audioTrack = TrackWrapper.get(peerConnection, this.localAudioTrack);
} catch (final IllegalStateException e) {
Log.d(Config.LOGTAG, "unable to toggle microphone", e);
// ignoring race condition in case sender has been disposed
return false;
}
if (audioTrack.isPresent()) { if (audioTrack.isPresent()) {
try { try {
audioTrack.get().setEnabled(enabled); audioTrack.get().setEnabled(enabled);
@ -611,9 +565,7 @@ public class WebRTCWrapper {
throw new IllegalStateException("Local video track does not exist"); throw new IllegalStateException("Local video track does not exist");
} }
synchronized ListenableFuture<SessionDescription> setLocalDescription( synchronized ListenableFuture<SessionDescription> setLocalDescription() {
final boolean waitForCandidates) {
this.setIsReadyToReceiveIceCandidates(false);
return Futures.transformAsync( return Futures.transformAsync(
getPeerConnectionFuture(), getPeerConnectionFuture(),
peerConnection -> { peerConnection -> {
@ -626,20 +578,11 @@ public class WebRTCWrapper {
new SetSdpObserver() { new SetSdpObserver() {
@Override @Override
public void onSetSuccess() { public void onSetSuccess() {
if (waitForCandidates) { final SessionDescription description =
final var delay = getIceGatheringCompleteOrTimeout(); peerConnection.getLocalDescription();
final var delayedSessionDescription = Log.d(EXTENDED_LOGGING_TAG, "set local description:");
Futures.transformAsync( logDescription(description);
delay, future.set(description);
v -> {
iceCandidates.clear();
return getLocalDescriptionFuture();
},
MoreExecutors.directExecutor());
future.setFuture(delayedSessionDescription);
} else {
future.setFuture(getLocalDescriptionFuture());
}
} }
@Override @Override
@ -653,35 +596,6 @@ public class WebRTCWrapper {
MoreExecutors.directExecutor()); MoreExecutors.directExecutor());
} }
private ListenableFuture<Void> getIceGatheringCompleteOrTimeout() {
return Futures.catching(
Futures.withTimeout(
iceGatheringComplete,
2,
TimeUnit.SECONDS,
JingleConnectionManager.SCHEDULED_EXECUTOR_SERVICE),
TimeoutException.class,
ex -> {
Log.d(
EXTENDED_LOGGING_TAG,
"timeout while waiting for ICE gathering to complete");
return null;
},
MoreExecutors.directExecutor());
}
private ListenableFuture<SessionDescription> getLocalDescriptionFuture() {
return Futures.submit(
() -> {
final SessionDescription description =
requirePeerConnection().getLocalDescription();
Log.d(EXTENDED_LOGGING_TAG, "local description:");
logDescription(description);
return description;
},
localDescriptionExecutorService);
}
public static void logDescription(final SessionDescription sessionDescription) { public static void logDescription(final SessionDescription sessionDescription) {
for (final String line : for (final String line :
sessionDescription.description.split( sessionDescription.description.split(
@ -740,15 +654,6 @@ public class WebRTCWrapper {
return peerConnection; return peerConnection;
} }
public boolean applyDtmfTone(String tone) {
if (toneManager == null || peerConnection == null || localAudioTrack == null) {
return false;
}
localAudioTrack.rtpSender.dtmf().insertDtmf(tone, TONE_DURATION, 100);
toneManager.startTone(TONE_CODES.get(tone), TONE_DURATION);
return true;
}
@Nonnull @Nonnull
private PeerConnectionFactory requirePeerConnectionFactory() { private PeerConnectionFactory requirePeerConnectionFactory() {
final PeerConnectionFactory peerConnectionFactory = this.peerConnectionFactory; final PeerConnectionFactory peerConnectionFactory = this.peerConnectionFactory;
@ -799,7 +704,7 @@ public class WebRTCWrapper {
} }
void execute(final Runnable command) { void execute(final Runnable command) {
this.executorService.execute(command); executorService.execute(command);
} }
public void switchSpeakerPhonePreference(AppRTCAudioManager.SpeakerPhonePreference preference) { public void switchSpeakerPhonePreference(AppRTCAudioManager.SpeakerPhonePreference preference) {

View file

@ -1,21 +1,16 @@
package eu.siacs.conversations.xmpp.jingle.stanzas; package eu.siacs.conversations.xmpp.jingle.stanzas;
import android.util.Log; import android.util.Log;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import com.google.common.base.Preconditions; import com.google.common.base.Preconditions;
import com.google.common.base.Strings; import com.google.common.base.Strings;
import com.google.common.collect.ImmutableSet; import eu.siacs.conversations.Config;
import eu.siacs.conversations.xmpp.jingle.SessionDescription;
import im.conversations.android.xml.Element;
import im.conversations.android.xml.Namespace;
import java.util.Locale; import java.util.Locale;
import java.util.Set; import java.util.Set;
import eu.siacs.conversations.Config;
import eu.siacs.conversations.xml.Element;
import eu.siacs.conversations.xml.Namespace;
import eu.siacs.conversations.xmpp.jingle.SessionDescription;
public class Content extends Element { public class Content extends Element {
public Content(final Creator creator, final Senders senders, final String name) { public Content(final Creator creator, final Senders senders, final String name) {
@ -100,7 +95,6 @@ public class Content extends Element {
} }
} }
public void setTransport(GenericTransportInfo transportInfo) { public void setTransport(GenericTransportInfo transportInfo) {
this.addChild(transportInfo); this.addChild(transportInfo);
} }
@ -148,10 +142,6 @@ public class Content extends Element {
return BOTH; return BOTH;
} }
public static Set<Senders> receiveOnly(final boolean initiator) {
return ImmutableSet.of(initiator ? RESPONDER : INITIATOR);
}
@Override @Override
@NonNull @NonNull
public String toString() { public String toString() {

View file

@ -0,0 +1,69 @@
package eu.siacs.conversations.xmpp.jingle.stanzas;
import com.google.common.base.Preconditions;
import im.conversations.android.xml.Element;
import java.util.Arrays;
import java.util.List;
public class FileTransferDescription extends GenericDescription {
public static List<String> NAMESPACES =
Arrays.asList(Version.FT_3.namespace, Version.FT_4.namespace, Version.FT_5.namespace);
private FileTransferDescription(String name, String namespace) {
super(name, namespace);
}
public Version getVersion() {
final String namespace = getNamespace();
if (namespace.equals(Version.FT_3.namespace)) {
return Version.FT_3;
} else if (namespace.equals(Version.FT_4.namespace)) {
return Version.FT_4;
} else if (namespace.equals(Version.FT_5.namespace)) {
return Version.FT_5;
} else {
throw new IllegalStateException("Unknown namespace");
}
}
public Element getFileOffer() {
final Version version = getVersion();
if (version == Version.FT_3) {
final Element offer = this.findChild("offer");
return offer == null ? null : offer.findChild("file");
} else {
return this.findChild("file");
}
}
public static FileTransferDescription upgrade(final Element element) {
Preconditions.checkArgument(
"description".equals(element.getName()),
"Name of provided element is not description");
Preconditions.checkArgument(
NAMESPACES.contains(element.getNamespace()),
"Element does not match a file transfer namespace");
final FileTransferDescription description =
new FileTransferDescription("description", element.getNamespace());
description.setAttributes(element.getAttributes());
description.setChildren(element.getChildren());
return description;
}
public enum Version {
FT_3("urn:xmpp:jingle:apps:file-transfer:3"),
FT_4("urn:xmpp:jingle:apps:file-transfer:4"),
FT_5("urn:xmpp:jingle:apps:file-transfer:5");
private final String namespace;
Version(String namespace) {
this.namespace = namespace;
}
public String getNamespace() {
return namespace;
}
}
}

View file

@ -1,8 +1,7 @@
package eu.siacs.conversations.xmpp.jingle.stanzas; package eu.siacs.conversations.xmpp.jingle.stanzas;
import com.google.common.base.Preconditions; import com.google.common.base.Preconditions;
import im.conversations.android.xml.Element;
import eu.siacs.conversations.xml.Element;
public class GenericDescription extends Element { public class GenericDescription extends Element {
@ -12,7 +11,8 @@ public class GenericDescription extends Element {
public static GenericDescription upgrade(final Element element) { public static GenericDescription upgrade(final Element element) {
Preconditions.checkArgument("description".equals(element.getName())); Preconditions.checkArgument("description".equals(element.getName()));
final GenericDescription description = new GenericDescription("description", element.getNamespace()); final GenericDescription description =
new GenericDescription("description", element.getNamespace());
description.setAttributes(element.getAttributes()); description.setAttributes(element.getAttributes());
description.setChildren(element.getChildren()); description.setChildren(element.getChildren());
return description; return description;

View file

@ -1,8 +1,7 @@
package eu.siacs.conversations.xmpp.jingle.stanzas; package eu.siacs.conversations.xmpp.jingle.stanzas;
import com.google.common.base.Preconditions; import com.google.common.base.Preconditions;
import im.conversations.android.xml.Element;
import eu.siacs.conversations.xml.Element;
public class GenericTransportInfo extends Element { public class GenericTransportInfo extends Element {
@ -12,7 +11,8 @@ public class GenericTransportInfo extends Element {
public static GenericTransportInfo upgrade(final Element element) { public static GenericTransportInfo upgrade(final Element element) {
Preconditions.checkArgument("transport".equals(element.getName())); Preconditions.checkArgument("transport".equals(element.getName()));
final GenericTransportInfo transport = new GenericTransportInfo("transport", element.getNamespace()); final GenericTransportInfo transport =
new GenericTransportInfo("transport", element.getNamespace());
transport.setAttributes(element.getAttributes()); transport.setAttributes(element.getAttributes());
transport.setChildren(element.getChildren()); transport.setChildren(element.getChildren());
return transport; return transport;

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