Compare commits
190 commits
Author | SHA1 | Date | |
---|---|---|---|
69866e591c | |||
506e4e1d0c | |||
c858b5346f | |||
e6bf595388 | |||
9127d68197 | |||
340bf45095 | |||
acfcde8416 | |||
4f654044b4 | |||
1b3c7b6a42 | |||
a4fe60dece | |||
03cf48f4c1 | |||
4d5445d123 | |||
4bfcf209d7 | |||
5b777ef657 | |||
d52cbb8e8c | |||
cc07f86bf4 | |||
f13f15cc91 | |||
405eeadd95 | |||
75a4008aee | |||
4fae8d4e11 | |||
805d0db486 | |||
779e6fa61e | |||
da528776db | |||
4fd96e740f | |||
4139c11771 | |||
1e884ec435 | |||
86d9264ee5 | |||
0f6f9b0001 | |||
e22fcab844 | |||
e3f5f6404b | |||
7c820f7b32 | |||
ee1c938f2a | |||
d9e8918727 | |||
97f54b6673 | |||
58c5bd0f1b | |||
bb2d077b7c | |||
b2c348a1df | |||
9a0c2226c1 | |||
e971b77539 | |||
c1ef2ac628 | |||
eb15dc1260 | |||
26d856e91f | |||
9819ef7d05 | |||
417e801811 | |||
0d134a919e | |||
260654f171 | |||
cfaf6162e6 | |||
e4fb793769 | |||
f1fbf15fea | |||
f9b3d42a8a | |||
a67979adf8 | |||
8be8d7df8f | |||
2e5e2ff6fe | |||
807078b24f | |||
4addeaa356 | |||
100c735636 | |||
b2414434dc | |||
0c4771e2a8 | |||
177320d8fe | |||
9c64f9c24c | |||
786a6c4c2a | |||
be6f4300da | |||
c2bf9d0413 | |||
303f14200f | |||
1a924d3efd | |||
86ef179c42 | |||
5e79dd8b68 | |||
3c207c28b4 | |||
9c95554782 | |||
ac2866a682 | |||
cf17a2ac6d | |||
c3f5273813 | |||
6ef2997b5e | |||
b8f3472af0 | |||
d54978f593 | |||
99c11fba17 | |||
cf5910e96e | |||
677cfcd34c | |||
2abcb1b4e4 | |||
49b4f54285 | |||
1be1334794 | |||
63df518c19 | |||
63bfbfb40a | |||
44ac7190a9 | |||
bfafad6c65 | |||
f5203b082b | |||
eafa93d132 | |||
d7ab5e1a4b | |||
d136928322 | |||
0727b0aba6 | |||
1f22c5f534 | |||
7d42da8c34 | |||
09b28358ab | |||
7567dcff5e | |||
b80fe9802a | |||
fe9b3b8ed9 | |||
cdcd323c36 | |||
867db9d54c | |||
87e33a779f | |||
c105c3420e | |||
2212c63810 | |||
d6edea8ddf | |||
bca253faa4 | |||
68e9f25da2 | |||
a1e97461f9 | |||
bf9b0b18f9 | |||
a09cc126ea | |||
b0010307c0 | |||
b5a47000c9 | |||
7d34c894d0 | |||
5866974eff | |||
3c42066a7c | |||
6845380be5 | |||
eeac779e25 | |||
35360fde91 | |||
a204bf9ec1 | |||
79eebe68e2 | |||
268bef4433 | |||
69d212141b | |||
94c8b9ed04 | |||
2d10a561e4 | |||
acb297ac96 | |||
405445afbe | |||
56a462833e | |||
2728a96ab9 | |||
7e2bff9d03 | |||
4c09b20aa4 | |||
fbb900d4ad | |||
6c24cb12dd | |||
a69b4b14a5 | |||
be3a8dc5e1 | |||
9b62861a64 | |||
dc371d7017 | |||
a43160b13d | |||
458f0ef280 | |||
3f59dd2688 | |||
ca0a0c07fc | |||
bed6b07bdd | |||
870393df8e | |||
e2ea1f9437 | |||
3be56b6775 | |||
58b1e26367 | |||
c077e4e8da | |||
f1e1cf9653 | |||
e073f22ec0 | |||
57d264d72e | |||
9a855a57ac | |||
ddcab5fb58 | |||
fe32526de8 | |||
164ac450d4 | |||
d2794ccf32 | |||
f16603742f | |||
f982885d2e | |||
8df97067bb | |||
bd343eafa0 | |||
c31fa7ed2b | |||
d25cc059c5 | |||
359ef330df | |||
de06bfb8f0 | |||
1e6aed759b | |||
1a09b3ed05 | |||
90e613f94e | |||
09db9e574b | |||
f5faa8fc4d | |||
bfa61d56af | |||
da65960fd1 | |||
6983aedddc | |||
27952c00ed | |||
944c48e00b | |||
26bff8028a | |||
873644f528 | |||
199a1cdc64 | |||
43a82e504b | |||
a2b21d97eb | |||
6458c6e9f9 | |||
1b438117a3 | |||
78af8cbd87 | |||
482dc8cfe9 | |||
3e9029dc8f | |||
38c612d35d | |||
07c1669813 | |||
20962554a4 | |||
6b232f7a5a | |||
9e7bbcc272 | |||
49bf92f7ca | |||
2c32f9738c | |||
7ee3e07946 | |||
94dde9f433 | |||
5d79cfbf0d | |||
80d97c3fcc |
|
@ -1,7 +0,0 @@
|
|||
steps:
|
||||
build:
|
||||
image: codeberg.org/freeyourgadget/android-fdroid-tools:latest
|
||||
commands:
|
||||
- ./gradlew clean
|
||||
- ./gradlew assembleConversationsFreeDebug
|
||||
- ./gradlew assembleQuicksyFreeDebug
|
55
CHANGELOG.md
|
@ -1,60 +1,5 @@
|
|||
# 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
|
||||
|
||||
* Increase corner radius on profile pictures
|
||||
|
|
235
README.md
|
@ -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&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
|
||||
|
||||
|
@ -11,9 +22,9 @@
|
|||
|
||||
## 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
|
||||
* 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
|
||||
* Send voice messages
|
||||
* Indication when your contact has read your message
|
||||
|
@ -28,15 +39,14 @@
|
|||
|
||||
### 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
|
||||
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
|
||||
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 —
|
||||
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
|
||||
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.
|
||||
|
@ -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
|
||||
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-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
|
||||
your desktop client and thus allows you to switch seamlessly from your mobile
|
||||
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
|
||||
server. Catch up with messages that were sent while Conversations Classic was
|
||||
server. Catch up with messages that were sent while Conversations was
|
||||
offline.
|
||||
* [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
|
||||
withholding unimportant packages.
|
||||
* [XEP-0363: HTTP File Upload](http://xmpp.org/extensions/xep-0363.html) allows you to share files in conferences
|
||||
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
|
||||
|
||||
|
@ -67,30 +74,44 @@ run your own XMPP server for you and your friends. These XEP's are:
|
|||
|
||||
#### 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
|
||||
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
|
||||
|
||||
I’m 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?
|
||||
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 don’t 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 don’t 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
|
||||
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
|
||||
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
|
||||
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 doesn’t provide
|
||||
those please contact your admin and have them read
|
||||
[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
|
||||
Conversations Classic.
|
||||
Conversations.
|
||||
|
||||
#### 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).
|
||||
|
||||
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.
|
||||
|
||||
#### I get 'Bind failure'. What does that mean?
|
||||
|
||||
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 doesn’t 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 doesn’t like that.
|
||||
This can be fixed by creating a new account in Conversations Classic that uses the Jabber ID `user@a.tld`.
|
||||
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 doesn’t 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 doesn’t like that.
|
||||
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.
|
||||
|
||||
|
@ -119,10 +140,16 @@ Maybe you attempted to use the Jabber ID `test@b.tld` because `a.tld` doesn’t
|
|||
|
||||
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 <= 7.1 or Conversations Classic from F-Droid (all Android versions)
|
||||
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.
|
||||
#### I’m getting this annoying permanent notification
|
||||
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 won’t 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 <= 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
|
||||
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+
|
||||
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 *Google’s 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 doesn’t 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 doesn’t 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 don’t 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 doesn’t 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. That’s 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 doesn’t 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
|
||||
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
|
||||
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?
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
with the JID as the payload. This doesn't require Conversations Classic to have write
|
||||
details within Conversations. This will start an "add to address book" intent
|
||||
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
|
||||
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.
|
||||
|
||||
Unfortunately these modern replacements for legacy XMPP features are not widely
|
||||
adopted. However Conversations Classic should be an instant messenger for the future and
|
||||
instead of making Conversations Classic compatible with the past we should work on
|
||||
adopted. However Conversations should be an instant messenger for the future and
|
||||
instead of making Conversations compatible with the past we should work on
|
||||
implementing new, improved technologies and getting them into other XMPP clients
|
||||
as well.
|
||||
|
||||
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.
|
||||
|
||||
#### 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
|
||||
|
||||
|
@ -231,7 +299,7 @@ Please report it to our XMPP conference [`xmppclient-dev@conference.narayana.im`
|
|||
#### How do I use OpenPGP
|
||||
|
||||
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.
|
||||
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
|
||||
|
@ -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).
|
||||
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.
|
||||
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
|
||||
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
|
||||
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
|
||||
this.)
|
||||
|
||||
|
@ -282,6 +350,89 @@ this.)
|
|||
|
||||
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 didn’t 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 didn’t 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 don‘t 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
|
||||
|
||||
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 doesn’t work as expected please always
|
||||
include a logcat debug output with your issue (see above).
|
||||
|
|
15
annotation-processor/build.gradle
Normal 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'
|
||||
|
||||
}
|
|
@ -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
|
@ -0,0 +1,6 @@
|
|||
apply plugin: "java-library"
|
||||
|
||||
java {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
|
@ -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 "";
|
||||
}
|
|
@ -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
|
@ -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
|
@ -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
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
BIN
app/src/conversations/res/mipmap-hdpi/ic_launcher_background.png
Normal file
After Width: | Height: | Size: 1.7 KiB |
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.1 KiB |
BIN
app/src/conversations/res/mipmap-mdpi/ic_launcher_background.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
After Width: | Height: | Size: 2.4 KiB |
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.3 KiB |
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 5.9 KiB |
After Width: | Height: | Size: 3.8 KiB |
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.7 KiB |
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 9.1 KiB |
After Width: | Height: | Size: 5.2 KiB |
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.4 KiB |
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
16
app/src/conversations/res/values-da-rDK/strings.xml
Normal 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>
|
16
app/src/conversations/res/values-el/strings.xml
Normal 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>
|
16
app/src/conversations/res/values-fr/strings.xml
Normal 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 n’importe quel serveur XMPP de votre choix.\nToutefois, pour votre commodité, nous avons facilité la création d’un 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 d’un 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 d’utilisateur a déjà été choisi pour vous. Nous allons vous guider à travers le processus de création d’un 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>
|
14
app/src/conversations/res/values-sk/strings.xml
Normal 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>
|
16
app/src/conversations/res/values-tr-rTR/strings.xml
Normal 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>
|
12
app/src/conversations/res/values-uk/strings.xml
Normal 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>
|
16
app/src/conversations/res/values-vi/strings.xml
Normal 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>
|
16
app/src/conversations/res/values-zh-rCN/strings.xml
Normal 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>
|
16
app/src/conversations/res/values-zh-rTW/strings.xml
Normal 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>
|
120
app/src/main/AndroidManifest.xml
Normal 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>
|
16
app/src/main/assets/logback.xml
Normal 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>
|
10
app/src/main/java/eu/siacs/conversations/Config.java
Normal 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
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -25,19 +25,14 @@ import android.os.Build;
|
|||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.app.ActivityCompat;
|
||||
|
||||
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.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. */
|
||||
public class AppRTCBluetoothManager {
|
|
@ -16,22 +16,17 @@ import android.hardware.SensorEventListener;
|
|||
import android.hardware.SensorManager;
|
||||
import android.os.Build;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.webrtc.ThreadUtils;
|
||||
|
||||
import eu.siacs.conversations.Config;
|
||||
import eu.siacs.conversations.utils.AppRTCUtils;
|
||||
import org.webrtc.ThreadUtils;
|
||||
|
||||
/**
|
||||
* AppRTCProximitySensor manages functions related to the proximity sensor in
|
||||
* the AppRTC demo.
|
||||
* On most device, the proximity sensor is implemented as a boolean-sensor.
|
||||
* It returns just two values "NEAR" or "FAR". Thresholding is done on the LUX
|
||||
* value i.e. the LUX value of the light sensor is compared with a threshold.
|
||||
* A LUX-value more than the threshold means the proximity sensor returns "FAR".
|
||||
* Anything less than the threshold value and the sensor returns "NEAR".
|
||||
* AppRTCProximitySensor manages functions related to the proximity sensor in the AppRTC demo. On
|
||||
* most device, the proximity sensor is implemented as a boolean-sensor. It returns just two values
|
||||
* "NEAR" or "FAR". Thresholding is done on the LUX value i.e. the LUX value of the light sensor is
|
||||
* compared with a threshold. 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 {
|
||||
// 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 Runnable onSensorStateListener;
|
||||
private final SensorManager sensorManager;
|
||||
@Nullable
|
||||
private Sensor proximitySensor;
|
||||
@Nullable private Sensor proximitySensor;
|
||||
private boolean lastStateReportIsNear;
|
||||
|
||||
private AppRTCProximitySensor(Context context, Runnable sensorStateListener) {
|
||||
|
@ -50,17 +44,12 @@ public class AppRTCProximitySensor implements SensorEventListener {
|
|||
sensorManager = ((SensorManager) context.getSystemService(Context.SENSOR_SERVICE));
|
||||
}
|
||||
|
||||
/**
|
||||
* Construction
|
||||
*/
|
||||
/** Construction */
|
||||
static AppRTCProximitySensor create(Context context, Runnable 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() {
|
||||
threadChecker.checkIsOnValidThread();
|
||||
Log.d(Config.LOGTAG, "start" + AppRTCUtils.getThreadInfo());
|
||||
|
@ -72,9 +61,7 @@ public class AppRTCProximitySensor implements SensorEventListener {
|
|||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate the proximity sensor.
|
||||
*/
|
||||
/** Deactivate the proximity sensor. */
|
||||
public void stop() {
|
||||
threadChecker.checkIsOnValidThread();
|
||||
Log.d(Config.LOGTAG, "stop" + AppRTCUtils.getThreadInfo());
|
||||
|
@ -84,9 +71,7 @@ public class AppRTCProximitySensor implements SensorEventListener {
|
|||
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() {
|
||||
threadChecker.checkIsOnValidThread();
|
||||
return lastStateReportIsNear;
|
||||
|
@ -120,15 +105,22 @@ public class AppRTCProximitySensor implements SensorEventListener {
|
|||
if (onSensorStateListener != null) {
|
||||
onSensorStateListener.run();
|
||||
}
|
||||
Log.d(Config.LOGTAG, "onSensorChanged" + AppRTCUtils.getThreadInfo() + ": "
|
||||
+ "accuracy=" + event.accuracy + ", timestamp=" + event.timestamp + ", distance="
|
||||
+ event.values[0]);
|
||||
Log.d(
|
||||
Config.LOGTAG,
|
||||
"onSensorChanged"
|
||||
+ AppRTCUtils.getThreadInfo()
|
||||
+ ": "
|
||||
+ "accuracy="
|
||||
+ event.accuracy
|
||||
+ ", timestamp="
|
||||
+ event.timestamp
|
||||
+ ", distance="
|
||||
+ event.values[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default proximity sensor if it exists. Tablet devices (e.g. Nexus 7)
|
||||
* does not support this type of sensor and false will be returned in such
|
||||
* cases.
|
||||
* Get default proximity sensor if it exists. Tablet devices (e.g. Nexus 7) does not support
|
||||
* this type of sensor and false will be returned in such cases.
|
||||
*/
|
||||
private boolean initDefaultSensor() {
|
||||
if (proximitySensor != null) {
|
||||
|
@ -142,9 +134,7 @@ public class AppRTCProximitySensor implements SensorEventListener {
|
|||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method for logging information about the proximity sensor.
|
||||
*/
|
||||
/** Helper method for logging information about the proximity sensor. */
|
||||
private void logProximitySensorInfo() {
|
||||
if (proximitySensor == null) {
|
||||
return;
|
||||
|
@ -168,4 +158,4 @@ public class AppRTCProximitySensor implements SensorEventListener {
|
|||
}
|
||||
Log.d(Config.LOGTAG, info.toString());
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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 haven’t 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
|
||||
}
|
||||
}
|
|
@ -4,10 +4,8 @@ import com.google.common.base.MoreObjects;
|
|||
import com.google.common.base.Objects;
|
||||
import com.google.common.collect.Collections2;
|
||||
import com.google.common.collect.ImmutableSet;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
import eu.siacs.conversations.xmpp.jingle.stanzas.Content;
|
||||
import java.util.Set;
|
||||
|
||||
public final class ContentAddition {
|
||||
|
|
@ -10,8 +10,7 @@ import java.util.ArrayList;
|
|||
import java.util.Enumeration;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import eu.siacs.conversations.xmpp.Jid;
|
||||
import org.jxmpp.jid.Jid;
|
||||
|
||||
public class DirectConnectionUtils {
|
||||
|
||||
|
@ -25,18 +24,19 @@ public class DirectConnectionUtils {
|
|||
}
|
||||
while (interfaces.hasMoreElements()) {
|
||||
NetworkInterface networkInterface = interfaces.nextElement();
|
||||
final Enumeration<InetAddress> inetAddressEnumeration = networkInterface.getInetAddresses();
|
||||
final Enumeration<InetAddress> inetAddressEnumeration =
|
||||
networkInterface.getInetAddresses();
|
||||
while (inetAddressEnumeration.hasMoreElements()) {
|
||||
final InetAddress inetAddress = inetAddressEnumeration.nextElement();
|
||||
if (inetAddress.isLoopbackAddress() || inetAddress.isLinkLocalAddress()) {
|
||||
continue;
|
||||
}
|
||||
if (inetAddress instanceof Inet6Address) {
|
||||
//let's get rid of scope
|
||||
// let's get rid of scope
|
||||
try {
|
||||
addresses.add(Inet6Address.getByAddress(inetAddress.getAddress()));
|
||||
} catch (UnknownHostException e) {
|
||||
//ignored
|
||||
// ignored
|
||||
}
|
||||
} else {
|
||||
addresses.add(inetAddress);
|
||||
|
@ -50,7 +50,8 @@ public class DirectConnectionUtils {
|
|||
SecureRandom random = new SecureRandom();
|
||||
ArrayList<JingleCandidate> candidates = new ArrayList<>();
|
||||
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.setPort(random.nextInt(60000) + 1024);
|
||||
candidate.setType(JingleCandidate.TYPE_DIRECT);
|
||||
|
@ -60,5 +61,4 @@ public class DirectConnectionUtils {
|
|||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
}
|
|
@ -1,15 +1,14 @@
|
|||
package eu.siacs.conversations.xmpp.jingle;
|
||||
|
||||
import com.google.common.collect.ImmutableSet;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
|
||||
public enum Media {
|
||||
|
||||
VIDEO, AUDIO, UNKNOWN;
|
||||
VIDEO,
|
||||
AUDIO,
|
||||
UNKNOWN;
|
||||
|
||||
@Override
|
||||
@Nonnull
|
|
@ -1,7 +1,6 @@
|
|||
package eu.siacs.conversations.xmpp.jingle;
|
||||
|
||||
import com.google.common.collect.ArrayListMultimap;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class MediaBuilder {
|
||||
|
@ -10,7 +9,7 @@ public class MediaBuilder {
|
|||
private String protocol;
|
||||
private List<Integer> formats;
|
||||
private String connectionData;
|
||||
private ArrayListMultimap<String,String> attributes;
|
||||
private ArrayListMultimap<String, String> attributes;
|
||||
|
||||
public MediaBuilder setMedia(String media) {
|
||||
this.media = media;
|
||||
|
@ -37,12 +36,13 @@ public class MediaBuilder {
|
|||
return this;
|
||||
}
|
||||
|
||||
public MediaBuilder setAttributes(ArrayListMultimap<String,String> attributes) {
|
||||
public MediaBuilder setAttributes(ArrayListMultimap<String, String> attributes) {
|
||||
this.attributes = attributes;
|
||||
return this;
|
||||
}
|
||||
|
||||
public SessionDescription.Media createMedia() {
|
||||
return new SessionDescription.Media(media, port, protocol, formats, connectionData, attributes);
|
||||
return new SessionDescription.Media(
|
||||
media, port, protocol, formats, connectionData, attributes);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
package eu.siacs.conversations.xmpp.jingle;
|
||||
|
||||
public interface OnPrimaryCandidateFound {
|
||||
void onPrimaryCandidateFound(boolean success, JingleCandidate canditate);
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
package eu.siacs.conversations.xmpp.jingle;
|
||||
|
||||
public interface OnTransportConnected {
|
||||
void failed();
|
||||
|
||||
void established();
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
package eu.siacs.conversations.xmpp.jingle;
|
||||
|
||||
import org.jxmpp.jid.Jid;
|
||||
|
||||
public interface OngoingRtpSession {
|
||||
Jid getWith();
|
||||
|
||||
String getSessionId();
|
||||
}
|
|
@ -8,12 +8,10 @@ import com.google.common.base.Strings;
|
|||
import com.google.common.collect.Collections2;
|
||||
import com.google.common.collect.ImmutableList;
|
||||
import com.google.common.collect.ImmutableMap;
|
||||
import com.google.common.collect.ImmutableMultimap;
|
||||
import com.google.common.collect.ImmutableSet;
|
||||
import com.google.common.collect.Iterables;
|
||||
import com.google.common.collect.Maps;
|
||||
import com.google.common.collect.Sets;
|
||||
|
||||
import eu.siacs.conversations.xmpp.jingle.stanzas.Content;
|
||||
import eu.siacs.conversations.xmpp.jingle.stanzas.GenericDescription;
|
||||
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.OmemoVerifiedIceUdpTransportInfo;
|
||||
import eu.siacs.conversations.xmpp.jingle.stanzas.RtpDescription;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
|
||||
public class RtpContentMap {
|
||||
|
@ -137,7 +133,8 @@ public class RtpContentMap {
|
|||
if (setup == null) {
|
||||
throw new SecurityException(
|
||||
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()));
|
||||
}
|
||||
if (requireActPass && setup != IceUdpTransportInfo.Setup.ACTPASS) {
|
||||
|
@ -197,24 +194,6 @@ public class RtpContentMap {
|
|||
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() {
|
||||
final Set<IceUdpTransportInfo.Credentials> allCredentials = getCredentials();
|
||||
final IceUdpTransportInfo.Credentials credentials =
|
||||
|
@ -229,12 +208,6 @@ public class RtpContentMap {
|
|||
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() {
|
||||
final Set<IceUdpTransportInfo.Credentials> credentials =
|
||||
ImmutableSet.copyOf(
|
||||
|
@ -293,11 +266,6 @@ public class RtpContentMap {
|
|||
return count == 0;
|
||||
}
|
||||
|
||||
public boolean hasFullTransportInfo() {
|
||||
return Collections2.transform(this.contents.values(), dt -> dt.transport.isStub())
|
||||
.contains(false);
|
||||
}
|
||||
|
||||
public RtpContentMap modifiedCredentials(
|
||||
IceUdpTransportInfo.Credentials credentials, final IceUdpTransportInfo.Setup setup) {
|
||||
final ImmutableMap.Builder<String, DescriptionTransport> contentMapBuilder =
|
||||
|
@ -324,60 +292,14 @@ public class RtpContentMap {
|
|||
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) {
|
||||
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() {
|
||||
|
@ -414,48 +336,28 @@ public class RtpContentMap {
|
|||
}
|
||||
|
||||
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);
|
||||
/*new ImmutableMap.Builder<String, DescriptionTransport>()
|
||||
.putAll(contents)
|
||||
.putAll(modification.contents)
|
||||
.build();*/
|
||||
final Map<String, DescriptionTransport> combinedFixedTransport =
|
||||
Maps.transformValues(
|
||||
combined,
|
||||
dt -> {
|
||||
final IceUdpTransportInfo iceUdpTransportInfo;
|
||||
if (dt.transport.isStub()) {
|
||||
final IceUdpTransportInfo.Credentials credentials =
|
||||
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));
|
||||
dt ->
|
||||
new DescriptionTransport(
|
||||
dt.senders, dt.description, iceUdpTransportInfo));
|
||||
return new RtpContentMap(modification.group, combinedFixedTransport);
|
||||
}
|
||||
|
||||
private static Map<String, DescriptionTransport> merge(
|
||||
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(b);
|
||||
return ImmutableMap.copyOf(combined);
|
|
@ -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
|
||||
}
|
|
@ -2,23 +2,17 @@ package eu.siacs.conversations.xmpp.jingle;
|
|||
|
||||
import android.util.Log;
|
||||
import android.util.Pair;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.google.common.base.CharMatcher;
|
||||
import com.google.common.base.Joiner;
|
||||
import com.google.common.base.Strings;
|
||||
import com.google.common.collect.ArrayListMultimap;
|
||||
import com.google.common.collect.ImmutableList;
|
||||
|
||||
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.IceUdpTransportInfo;
|
||||
import eu.siacs.conversations.xmpp.jingle.stanzas.RtpDescription;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import im.conversations.android.xml.Namespace;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
@ -29,8 +23,7 @@ public class SessionDescription {
|
|||
private static final String HARDCODED_MEDIA_PROTOCOL =
|
||||
"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 Collection<String> HARDCODED_ICE_OPTIONS =
|
||||
Collections.singleton("trickle");
|
||||
private static final String HARDCODED_ICE_OPTIONS = "trickle";
|
||||
private static final String HARDCODED_CONNECTION = "IN IP4 0.0.0.0";
|
||||
|
||||
public final int version;
|
||||
|
@ -170,21 +163,11 @@ public class SessionDescription {
|
|||
}
|
||||
checkNoWhitespace(pwd, "pwd value must not contain any whitespaces");
|
||||
mediaAttributes.put("ice-pwd", pwd);
|
||||
final List<String> negotiatedIceOptions = transport.getIceOptions();
|
||||
final Collection<String> iceOptions =
|
||||
negotiatedIceOptions.isEmpty() ? HARDCODED_ICE_OPTIONS : negotiatedIceOptions;
|
||||
mediaAttributes.put("ice-options", Joiner.on(' ').join(iceOptions));
|
||||
mediaAttributes.put("ice-options", HARDCODED_ICE_OPTIONS);
|
||||
final IceUdpTransportInfo.Fingerprint fingerprint = transport.getFingerprint();
|
||||
if (fingerprint != null) {
|
||||
final String hashFunction = fingerprint.getHash();
|
||||
final String hash = 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);
|
||||
mediaAttributes.put(
|
||||
"fingerprint", fingerprint.getHash() + " " + fingerprint.getContent());
|
||||
final IceUdpTransportInfo.Setup setup = fingerprint.getSetup();
|
||||
if (setup != null) {
|
||||
mediaAttributes.put("setup", setup.toString().toLowerCase(Locale.ROOT));
|
||||
|
@ -221,14 +204,12 @@ public class SessionDescription {
|
|||
}
|
||||
checkNoWhitespace(
|
||||
type, "feedback negotiation type must not contain whitespace");
|
||||
if (Strings.isNullOrEmpty(subtype)) {
|
||||
mediaAttributes.put("rtcp-fb", id + " " + type);
|
||||
} else {
|
||||
checkNoWhitespace(
|
||||
subtype,
|
||||
"feedback negotiation subtype must not contain whitespace");
|
||||
mediaAttributes.put("rtcp-fb", id + " " + type + " " + subtype);
|
||||
}
|
||||
mediaAttributes.put(
|
||||
"rtcp-fb",
|
||||
id
|
||||
+ " "
|
||||
+ type
|
||||
+ (Strings.isNullOrEmpty(subtype) ? "" : " " + subtype));
|
||||
}
|
||||
for (RtpDescription.FeedbackNegotiationTrrInt feedbackNegotiationTrrInt :
|
||||
payloadType.feedbackNegotiationTrrInts()) {
|
||||
|
@ -245,13 +226,9 @@ public class SessionDescription {
|
|||
throw new IllegalArgumentException("a feedback negotiation is missing type");
|
||||
}
|
||||
checkNoWhitespace(type, "feedback negotiation type must not contain whitespace");
|
||||
if (Strings.isNullOrEmpty(subtype)) {
|
||||
mediaAttributes.put("rtcp-fb", "* " + type);
|
||||
} else {
|
||||
checkNoWhitespace(
|
||||
subtype, "feedback negotiation subtype must not contain whitespace");
|
||||
mediaAttributes.put("rtcp-fb", "* " + type + " " + subtype); /**/
|
||||
}
|
||||
mediaAttributes.put(
|
||||
"rtcp-fb",
|
||||
"* " + type + (Strings.isNullOrEmpty(subtype) ? "" : " " + subtype));
|
||||
}
|
||||
for (final RtpDescription.FeedbackNegotiationTrrInt feedbackNegotiationTrrInt :
|
||||
description.feedbackNegotiationTrrInts()) {
|
||||
|
@ -288,9 +265,6 @@ public class SessionDescription {
|
|||
if (groups.size() == 0) {
|
||||
throw new IllegalArgumentException("A SSRC group is missing SSRC ids");
|
||||
}
|
||||
for (final String source : groups) {
|
||||
checkNoWhitespace(source, "Sources must not contain whitespace");
|
||||
}
|
||||
mediaAttributes.put(
|
||||
"ssrc-group",
|
||||
String.format("%s %s", semantics, Joiner.on(' ').join(groups)));
|
||||
|
@ -314,14 +288,7 @@ public class SessionDescription {
|
|||
throw new IllegalArgumentException(
|
||||
"A source specific media attribute is missing its value");
|
||||
}
|
||||
checkNoWhitespace(
|
||||
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());
|
||||
mediaAttributes.put("ssrc", id + " " + parameterName + ":" + parameterValue);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -360,13 +327,6 @@ public class SessionDescription {
|
|||
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) {
|
||||
try {
|
||||
return Integer.parseInt(input);
|
|
@ -1,14 +1,13 @@
|
|||
package eu.siacs.conversations.xmpp.jingle;
|
||||
|
||||
import com.google.common.collect.ArrayListMultimap;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class SessionDescriptionBuilder {
|
||||
private int version;
|
||||
private String name;
|
||||
private String connectionData;
|
||||
private ArrayListMultimap<String,String> attributes;
|
||||
private ArrayListMultimap<String, String> attributes;
|
||||
private List<SessionDescription.Media> media;
|
||||
|
||||
public SessionDescriptionBuilder setVersion(int version) {
|
||||
|
@ -26,7 +25,7 @@ public class SessionDescriptionBuilder {
|
|||
return this;
|
||||
}
|
||||
|
||||
public SessionDescriptionBuilder setAttributes(ArrayListMultimap<String,String> attributes) {
|
||||
public SessionDescriptionBuilder setAttributes(ArrayListMultimap<String, String> attributes) {
|
||||
this.attributes = attributes;
|
||||
return this;
|
||||
}
|
||||
|
@ -39,4 +38,4 @@ public class SessionDescriptionBuilder {
|
|||
public SessionDescription createSessionDescription() {
|
||||
return new SessionDescription(version, name, connectionData, attributes, media);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -1,23 +1,18 @@
|
|||
package eu.siacs.conversations.xmpp.jingle;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import com.google.common.base.CaseFormat;
|
||||
import com.google.common.base.Optional;
|
||||
import com.google.common.base.Preconditions;
|
||||
|
||||
import eu.siacs.conversations.Config;
|
||||
|
||||
import java.util.UUID;
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
import org.webrtc.MediaStreamTrack;
|
||||
import org.webrtc.PeerConnection;
|
||||
import org.webrtc.RtpSender;
|
||||
import org.webrtc.RtpTransceiver;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
|
||||
class TrackWrapper<T extends MediaStreamTrack> {
|
||||
public final T track;
|
||||
public final RtpSender rtpSender;
|
||||
|
@ -43,13 +38,9 @@ class TrackWrapper<T extends MediaStreamTrack> {
|
|||
final RtpTransceiver transceiver =
|
||||
peerConnection == null ? null : getTransceiver(peerConnection, trackWrapper);
|
||||
if (transceiver == null) {
|
||||
final String id;
|
||||
try {
|
||||
id = trackWrapper.rtpSender.id();
|
||||
} catch (final IllegalStateException e) {
|
||||
return Optional.absent();
|
||||
}
|
||||
Log.w(Config.LOGTAG, "unable to detect transceiver for " + id);
|
||||
Log.w(
|
||||
Config.LOGTAG,
|
||||
"unable to detect transceiver for " + trackWrapper.getRtpSenderId());
|
||||
return Optional.of(trackWrapper.track);
|
||||
}
|
||||
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(
|
||||
@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()) {
|
||||
if (transceiver.getSender().id().equals(rtpSender.id())) {
|
||||
if (transceiver.getSender().id().equals(rtpSenderId)) {
|
||||
return transceiver;
|
||||
}
|
||||
}
|
|
@ -2,12 +2,15 @@ package eu.siacs.conversations.xmpp.jingle;
|
|||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
|
||||
import com.google.common.collect.ImmutableSet;
|
||||
import com.google.common.collect.Iterables;
|
||||
import com.google.common.util.concurrent.ListenableFuture;
|
||||
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.CameraEnumerationAndroid;
|
||||
import org.webrtc.CameraEnumerator;
|
||||
|
@ -17,14 +20,6 @@ import org.webrtc.PeerConnectionFactory;
|
|||
import org.webrtc.SurfaceTextureHelper;
|
||||
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 {
|
||||
|
||||
private static final int CAPTURING_RESOLUTION = 1920;
|
|
@ -1,32 +1,34 @@
|
|||
package eu.siacs.conversations.xmpp.jingle;
|
||||
|
||||
import android.content.Context;
|
||||
import android.media.ToneGenerator;
|
||||
import android.os.Build;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.Log;
|
||||
|
||||
import com.google.common.base.Optional;
|
||||
import com.google.common.base.Preconditions;
|
||||
import com.google.common.collect.ImmutableMap;
|
||||
import com.google.common.collect.ImmutableSet;
|
||||
import com.google.common.util.concurrent.Futures;
|
||||
import com.google.common.util.concurrent.ListenableFuture;
|
||||
import com.google.common.util.concurrent.MoreExecutors;
|
||||
import com.google.common.util.concurrent.SettableFuture;
|
||||
|
||||
import eu.siacs.conversations.Config;
|
||||
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.AudioTrack;
|
||||
import org.webrtc.CandidatePairChangeEvent;
|
||||
import org.webrtc.DataChannel;
|
||||
import org.webrtc.DefaultVideoDecoderFactory;
|
||||
import org.webrtc.DefaultVideoEncoderFactory;
|
||||
import org.webrtc.DtmfSender;
|
||||
import org.webrtc.EglBase;
|
||||
import org.webrtc.IceCandidate;
|
||||
import org.webrtc.MediaConstraints;
|
||||
|
@ -40,20 +42,7 @@ import org.webrtc.SdpObserver;
|
|||
import org.webrtc.SessionDescription;
|
||||
import org.webrtc.VideoTrack;
|
||||
import org.webrtc.audio.JavaAudioDeviceModule;
|
||||
|
||||
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;
|
||||
import org.webrtc.voiceengine.WebRtcAudioEffects;
|
||||
|
||||
@SuppressWarnings("UnstableApiUsage")
|
||||
public class WebRTCWrapper {
|
||||
|
@ -61,27 +50,6 @@ public class WebRTCWrapper {
|
|||
private static final String EXTENDED_LOGGING_TAG = WebRTCWrapper.class.getSimpleName();
|
||||
|
||||
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 =
|
||||
new ImmutableSet.Builder<String>()
|
||||
|
@ -96,7 +64,6 @@ public class WebRTCWrapper {
|
|||
.add("E5823") // Sony z5 compact
|
||||
.add("Redmi Note 5")
|
||||
.add("FP2") // Fairphone FP2
|
||||
.add("FP4") // Fairphone FP4
|
||||
.add("MI 5")
|
||||
.add("GT-I9515") // 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<VideoTrack> localVideoTrack = null;
|
||||
private VideoTrack remoteVideoTrack = null;
|
||||
|
||||
private final SettableFuture<Void> iceGatheringComplete = SettableFuture.create();
|
||||
private final PeerConnection.Observer peerConnectionObserver =
|
||||
new PeerConnection.Observer() {
|
||||
@Override
|
||||
|
@ -155,11 +120,8 @@ public class WebRTCWrapper {
|
|||
|
||||
@Override
|
||||
public void onIceGatheringChange(
|
||||
final PeerConnection.IceGatheringState iceGatheringState) {
|
||||
PeerConnection.IceGatheringState iceGatheringState) {
|
||||
Log.d(EXTENDED_LOGGING_TAG, "onIceGatheringChange(" + iceGatheringState + ")");
|
||||
if (iceGatheringState == PeerConnection.IceGatheringState.COMPLETE) {
|
||||
iceGatheringComplete.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -238,7 +200,6 @@ public class WebRTCWrapper {
|
|||
@Nullable private PeerConnectionFactory peerConnectionFactory = null;
|
||||
@Nullable private PeerConnection peerConnection = null;
|
||||
private AppRTCAudioManager appRTCAudioManager = null;
|
||||
private ToneManager toneManager = null;
|
||||
private Context context = null;
|
||||
private EglBase eglBase = null;
|
||||
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(
|
||||
final XmppConnectionService service,
|
||||
final Context service,
|
||||
@Nonnull final AppRTCAudioManager.SpeakerPhonePreference speakerPhonePreference)
|
||||
throws InitializationException {
|
||||
try {
|
||||
|
@ -273,11 +242,10 @@ public class WebRTCWrapper {
|
|||
throw new InitializationException("Unable to create EGL base", e);
|
||||
}
|
||||
this.context = service;
|
||||
this.toneManager = service.getJingleConnectionManager().toneManager;
|
||||
mainHandler.post(
|
||||
() -> {
|
||||
appRTCAudioManager = AppRTCAudioManager.create(service, speakerPhonePreference);
|
||||
toneManager.setAppRtcAudioManagerHasControl(true);
|
||||
ToneManager.getInstance(context).setAppRtcAudioManagerHasControl(true);
|
||||
appRTCAudioManager.start(audioManagerEvents);
|
||||
eventCallback.onAudioDeviceChanged(
|
||||
appRTCAudioManager.getSelectedAudioDevice(),
|
||||
|
@ -286,16 +254,15 @@ public class WebRTCWrapper {
|
|||
}
|
||||
|
||||
synchronized void initializePeerConnection(
|
||||
final Set<Media> media,
|
||||
final List<PeerConnection.IceServer> iceServers,
|
||||
final boolean trickle)
|
||||
final Set<Media> media, final List<PeerConnection.IceServer> iceServers)
|
||||
throws InitializationException {
|
||||
Preconditions.checkState(this.eglBase != null);
|
||||
Preconditions.checkNotNull(media);
|
||||
Preconditions.checkArgument(
|
||||
media.size() > 0, "media can not be empty when initializing peer connection");
|
||||
final boolean setUseHardwareAcousticEchoCanceler =
|
||||
!HARDWARE_AEC_BLACKLIST.contains(Build.MODEL);
|
||||
WebRtcAudioEffects.canUseAcousticEchoCanceler()
|
||||
&& !HARDWARE_AEC_BLACKLIST.contains(Build.MODEL);
|
||||
Log.d(
|
||||
Config.LOGTAG,
|
||||
String.format(
|
||||
|
@ -315,7 +282,7 @@ public class WebRTCWrapper {
|
|||
.createAudioDeviceModule())
|
||||
.createPeerConnectionFactory();
|
||||
|
||||
final PeerConnection.RTCConfiguration rtcConfig = buildConfiguration(iceServers, trickle);
|
||||
final PeerConnection.RTCConfiguration rtcConfig = buildConfiguration(iceServers);
|
||||
final PeerConnection peerConnection =
|
||||
requirePeerConnectionFactory()
|
||||
.createPeerConnection(rtcConfig, peerConnectionObserver);
|
||||
|
@ -430,43 +397,38 @@ public class WebRTCWrapper {
|
|||
}
|
||||
|
||||
private static PeerConnection.RTCConfiguration buildConfiguration(
|
||||
final List<PeerConnection.IceServer> iceServers, final boolean trickle) {
|
||||
final List<PeerConnection.IceServer> iceServers) {
|
||||
final PeerConnection.RTCConfiguration rtcConfig =
|
||||
new PeerConnection.RTCConfiguration(iceServers);
|
||||
rtcConfig.tcpCandidatePolicy =
|
||||
PeerConnection.TcpCandidatePolicy.DISABLED; // XEP-0176 doesn't support tcp
|
||||
if (trickle) {
|
||||
rtcConfig.continualGatheringPolicy =
|
||||
PeerConnection.ContinualGatheringPolicy.GATHER_CONTINUALLY;
|
||||
} else {
|
||||
rtcConfig.continualGatheringPolicy =
|
||||
PeerConnection.ContinualGatheringPolicy.GATHER_ONCE;
|
||||
}
|
||||
rtcConfig.continualGatheringPolicy =
|
||||
PeerConnection.ContinualGatheringPolicy.GATHER_CONTINUALLY;
|
||||
rtcConfig.sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN;
|
||||
rtcConfig.rtcpMuxPolicy = PeerConnection.RtcpMuxPolicy.NEGOTIATE;
|
||||
rtcConfig.enableImplicitRollback = true;
|
||||
return rtcConfig;
|
||||
}
|
||||
|
||||
void reconfigurePeerConnection(
|
||||
final List<PeerConnection.IceServer> iceServers, final boolean trickle) {
|
||||
requirePeerConnection().setConfiguration(buildConfiguration(iceServers, trickle));
|
||||
void reconfigurePeerConnection(final List<PeerConnection.IceServer> iceServers) {
|
||||
requirePeerConnection().setConfiguration(buildConfiguration(iceServers));
|
||||
}
|
||||
|
||||
void restartIceAsync() {
|
||||
this.execute(this::restartIce);
|
||||
}
|
||||
|
||||
private void restartIce() {
|
||||
final PeerConnection peerConnection;
|
||||
try {
|
||||
peerConnection = requirePeerConnection();
|
||||
} catch (final PeerConnectionNotInitialized e) {
|
||||
Log.w(EXTENDED_LOGGING_TAG, "PeerConnection vanished before we could execute restart");
|
||||
return;
|
||||
}
|
||||
setIsReadyToReceiveIceCandidates(false);
|
||||
peerConnection.restartIce();
|
||||
void restartIce() {
|
||||
executorService.execute(
|
||||
() -> {
|
||||
final PeerConnection peerConnection;
|
||||
try {
|
||||
peerConnection = requirePeerConnection();
|
||||
} catch (final PeerConnectionNotInitialized e) {
|
||||
Log.w(
|
||||
EXTENDED_LOGGING_TAG,
|
||||
"PeerConnection vanished before we could execute restart");
|
||||
return;
|
||||
}
|
||||
setIsReadyToReceiveIceCandidates(false);
|
||||
peerConnection.restartIce();
|
||||
});
|
||||
}
|
||||
|
||||
public void setIsReadyToReceiveIceCandidates(final boolean ready) {
|
||||
|
@ -487,15 +449,19 @@ public class WebRTCWrapper {
|
|||
final VideoSourceWrapper videoSourceWrapper = this.videoSourceWrapper;
|
||||
final AppRTCAudioManager audioManager = this.appRTCAudioManager;
|
||||
final EglBase eglBase = this.eglBase;
|
||||
final var localVideoTrack = this.localVideoTrack;
|
||||
if (peerConnection != null) {
|
||||
this.peerConnection = null;
|
||||
dispose(peerConnection);
|
||||
}
|
||||
if (audioManager != null) {
|
||||
toneManager.setAppRtcAudioManagerHasControl(false);
|
||||
ToneManager.getInstance(context).setAppRtcAudioManagerHasControl(false);
|
||||
mainHandler.post(audioManager::stop);
|
||||
}
|
||||
this.localVideoTrack = null;
|
||||
if (localVideoTrack != null) {
|
||||
this.localVideoTrack = null;
|
||||
dispose(localVideoTrack.track);
|
||||
}
|
||||
this.remoteVideoTrack = null;
|
||||
if (videoSourceWrapper != null) {
|
||||
this.videoSourceWrapper = null;
|
||||
|
@ -507,8 +473,8 @@ public class WebRTCWrapper {
|
|||
videoSourceWrapper.dispose();
|
||||
}
|
||||
if (eglBase != null) {
|
||||
eglBase.release();
|
||||
this.eglBase = null;
|
||||
eglBase.release();
|
||||
}
|
||||
if (peerConnectionFactory != null) {
|
||||
this.peerConnectionFactory = null;
|
||||
|
@ -548,14 +514,8 @@ public class WebRTCWrapper {
|
|||
}
|
||||
|
||||
boolean isMicrophoneEnabled() {
|
||||
Optional<AudioTrack> audioTrack = null;
|
||||
try {
|
||||
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;
|
||||
}
|
||||
final Optional<AudioTrack> audioTrack =
|
||||
TrackWrapper.get(peerConnection, this.localAudioTrack);
|
||||
if (audioTrack.isPresent()) {
|
||||
try {
|
||||
return audioTrack.get().enabled();
|
||||
|
@ -565,19 +525,13 @@ public class WebRTCWrapper {
|
|||
return false;
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
throw new IllegalStateException("Local audio track does not exist (yet)");
|
||||
}
|
||||
}
|
||||
|
||||
boolean setMicrophoneEnabled(final boolean enabled) {
|
||||
Optional<AudioTrack> audioTrack = null;
|
||||
try {
|
||||
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;
|
||||
}
|
||||
final Optional<AudioTrack> audioTrack =
|
||||
TrackWrapper.get(peerConnection, this.localAudioTrack);
|
||||
if (audioTrack.isPresent()) {
|
||||
try {
|
||||
audioTrack.get().setEnabled(enabled);
|
||||
|
@ -611,9 +565,7 @@ public class WebRTCWrapper {
|
|||
throw new IllegalStateException("Local video track does not exist");
|
||||
}
|
||||
|
||||
synchronized ListenableFuture<SessionDescription> setLocalDescription(
|
||||
final boolean waitForCandidates) {
|
||||
this.setIsReadyToReceiveIceCandidates(false);
|
||||
synchronized ListenableFuture<SessionDescription> setLocalDescription() {
|
||||
return Futures.transformAsync(
|
||||
getPeerConnectionFuture(),
|
||||
peerConnection -> {
|
||||
|
@ -626,20 +578,11 @@ public class WebRTCWrapper {
|
|||
new SetSdpObserver() {
|
||||
@Override
|
||||
public void onSetSuccess() {
|
||||
if (waitForCandidates) {
|
||||
final var delay = getIceGatheringCompleteOrTimeout();
|
||||
final var delayedSessionDescription =
|
||||
Futures.transformAsync(
|
||||
delay,
|
||||
v -> {
|
||||
iceCandidates.clear();
|
||||
return getLocalDescriptionFuture();
|
||||
},
|
||||
MoreExecutors.directExecutor());
|
||||
future.setFuture(delayedSessionDescription);
|
||||
} else {
|
||||
future.setFuture(getLocalDescriptionFuture());
|
||||
}
|
||||
final SessionDescription description =
|
||||
peerConnection.getLocalDescription();
|
||||
Log.d(EXTENDED_LOGGING_TAG, "set local description:");
|
||||
logDescription(description);
|
||||
future.set(description);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -653,35 +596,6 @@ public class WebRTCWrapper {
|
|||
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) {
|
||||
for (final String line :
|
||||
sessionDescription.description.split(
|
||||
|
@ -740,15 +654,6 @@ public class WebRTCWrapper {
|
|||
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
|
||||
private PeerConnectionFactory requirePeerConnectionFactory() {
|
||||
final PeerConnectionFactory peerConnectionFactory = this.peerConnectionFactory;
|
||||
|
@ -799,7 +704,7 @@ public class WebRTCWrapper {
|
|||
}
|
||||
|
||||
void execute(final Runnable command) {
|
||||
this.executorService.execute(command);
|
||||
executorService.execute(command);
|
||||
}
|
||||
|
||||
public void switchSpeakerPhonePreference(AppRTCAudioManager.SpeakerPhonePreference preference) {
|
|
@ -1,21 +1,16 @@
|
|||
package eu.siacs.conversations.xmpp.jingle.stanzas;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.google.common.base.Preconditions;
|
||||
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.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 Content(final Creator creator, final Senders senders, final String name) {
|
||||
|
@ -100,7 +95,6 @@ public class Content extends Element {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
public void setTransport(GenericTransportInfo transportInfo) {
|
||||
this.addChild(transportInfo);
|
||||
}
|
||||
|
@ -141,17 +135,13 @@ public class Content extends Element {
|
|||
} else if (attributes.contains("recvonly")) {
|
||||
return initiator ? RESPONDER : INITIATOR;
|
||||
}
|
||||
Log.w(Config.LOGTAG,"assuming default value for senders");
|
||||
Log.w(Config.LOGTAG, "assuming default value for senders");
|
||||
// If none of the attributes "sendonly", "recvonly", "inactive", and "sendrecv" is
|
||||
// present, "sendrecv" SHOULD be assumed as the default
|
||||
// https://www.rfc-editor.org/rfc/rfc4566
|
||||
return BOTH;
|
||||
}
|
||||
|
||||
public static Set<Senders> receiveOnly(final boolean initiator) {
|
||||
return ImmutableSet.of(initiator ? RESPONDER : INITIATOR);
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNull
|
||||
public String toString() {
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,8 +1,7 @@
|
|||
package eu.siacs.conversations.xmpp.jingle.stanzas;
|
||||
|
||||
import com.google.common.base.Preconditions;
|
||||
|
||||
import eu.siacs.conversations.xml.Element;
|
||||
import im.conversations.android.xml.Element;
|
||||
|
||||
public class GenericDescription extends Element {
|
||||
|
||||
|
@ -12,7 +11,8 @@ public class GenericDescription extends Element {
|
|||
|
||||
public static GenericDescription upgrade(final Element element) {
|
||||
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.setChildren(element.getChildren());
|
||||
return description;
|
|
@ -1,8 +1,7 @@
|
|||
package eu.siacs.conversations.xmpp.jingle.stanzas;
|
||||
|
||||
import com.google.common.base.Preconditions;
|
||||
|
||||
import eu.siacs.conversations.xml.Element;
|
||||
import im.conversations.android.xml.Element;
|
||||
|
||||
public class GenericTransportInfo extends Element {
|
||||
|
||||
|
@ -12,7 +11,8 @@ public class GenericTransportInfo extends Element {
|
|||
|
||||
public static GenericTransportInfo upgrade(final Element element) {
|
||||
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.setChildren(element.getChildren());
|
||||
return transport;
|