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
|
|
1150
CHANGELOG.md
224
FAQ.md
|
@ -1,224 +0,0 @@
|
||||||
# FAQ
|
|
||||||
|
|
||||||
## General
|
|
||||||
|
|
||||||
### How do I install another.im?
|
|
||||||
|
|
||||||
another.im 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.
|
|
||||||
|
|
||||||
### 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 [another.im](https://another.im). If you don’t like to use *another.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 another.im 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.
|
|
||||||
|
|
||||||
#### 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 another.im.
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
### Where can I set up a custom hostname / port
|
|
||||||
another.im 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
|
|
||||||
another.im.
|
|
||||||
|
|
||||||
### I get 'Incompatible Server'
|
|
||||||
|
|
||||||
As regular user you should be picking a different server. The server you selected
|
|
||||||
is probably insecure and/or very old.
|
|
||||||
|
|
||||||
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 another.im is able to handle. another.im 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 another.im 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`. another.im doesn’t like that.
|
|
||||||
This can be fixed by creating a new account in another.im 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.
|
|
||||||
|
|
||||||
Maybe you attempted to use the Jabber ID `test@b.tld` because `a.tld` doesn’t point to the correct host. In that case you might have to enable the extended connection settings in the expert settings of another.im and set a host name.
|
|
||||||
|
|
||||||
### I get 'Stream opening error'. What does that mean?
|
|
||||||
|
|
||||||
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 another.im 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 another.im from F-Droid (all Android versions)
|
|
||||||
The foreground notification is still controlled over the expert settings within another.im 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 another.im (and other apps) using battery. Starting with Android 8.1 you can disable that notification again with the same method described above.
|
|
||||||
|
|
||||||
#### 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.
|
|
||||||
|
|
||||||
### another.im doesn’t work for me. Where can I get help?
|
|
||||||
|
|
||||||
You can join our conference room on [`xmppclient-dev@conference.another.im`](xmpp:xmppclient-dev@conference.another.im).
|
|
||||||
A lot of people in there are able to answer basic questions about the usage of
|
|
||||||
another.im 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.
|
|
||||||
|
|
||||||
### How does the address book integration work?
|
|
||||||
|
|
||||||
The address book integration was designed to protect your privacy. another.im
|
|
||||||
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 another.im 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 another.im. This will start an "add to address book" intent
|
|
||||||
with the JID as the payload. This doesn't require another.im to have write
|
|
||||||
permissions on your address book but also doesn't require you to copy/paste a
|
|
||||||
JID from one app to another.
|
|
||||||
|
|
||||||
### I get 'delivery failed' on my messages
|
|
||||||
|
|
||||||
If you get delivery failed on images it's probably because the recipient lost
|
|
||||||
network connectivity during reception. In that case you can try it again at a
|
|
||||||
later time.
|
|
||||||
|
|
||||||
For text messages the answer to your question is a little bit more complex.
|
|
||||||
When you see 'delivery failed' on text messages, it is always something that is
|
|
||||||
being reported by the server. The most common reason for this is that the
|
|
||||||
recipient failed to resume a connection. When a client loses connectivity for a
|
|
||||||
short time the client usually has a five minute window to pick up that
|
|
||||||
connection again. When the client fails to do so because the network
|
|
||||||
connectivity is out for longer than that all messages sent to that client will
|
|
||||||
be returned to the sender resulting in a delivery failed.
|
|
||||||
|
|
||||||
Instead of returning a message to the sender both ejabberd and prosody have the
|
|
||||||
ability to store messages in offline storage when the disconnecting client is
|
|
||||||
the only client. In prosody this is available via an extra module called
|
|
||||||
```mod_smacks_offline```. In ejabberd this is available via some configuration
|
|
||||||
settings.
|
|
||||||
|
|
||||||
Other less common reasons are that the message you sent didn't meet some
|
|
||||||
criteria enforced by the server (too large, too many). Another reason could be
|
|
||||||
that the recipient is offline and the server doesn't provide offline storage.
|
|
||||||
|
|
||||||
Usually you are able to distinguish between these two groups in the fact that
|
|
||||||
the first one happens always after some time and the second one happens almost
|
|
||||||
instantly.
|
|
||||||
|
|
||||||
### Where can I see the status of my contacts? How can I set a status or priority?
|
|
||||||
|
|
||||||
Statuses are a horrible metric. Setting them manually to a proper value rarely
|
|
||||||
works because users are either lazy or just forget about them. Setting them
|
|
||||||
automatically does not provide quality results either. Keyboard or mouse
|
|
||||||
activity as indicator for example fails when the user is just looking at
|
|
||||||
something (reading an article, watching a movie). Furthermore automatic setting
|
|
||||||
of status always implies an impact on your privacy (are you sure you want
|
|
||||||
everybody in your contact list to know that you have been using your computer at
|
|
||||||
4am‽).
|
|
||||||
|
|
||||||
In the past status has been used to judge the likelihood of whether or not your
|
|
||||||
messages are being read. This is no longer necessary. With Chat Markers
|
|
||||||
(XEP-0333, supported by Conversations since 0.4) we have the ability to **know**
|
|
||||||
whether or not your messages are being read. Similar things can be said for
|
|
||||||
priorities. In the past priorities have been used (by servers, not by clients!)
|
|
||||||
to route your messages to one specific client. With carbon messages (XEP-0280,
|
|
||||||
supported by Conversations since 0.1) this is no longer necessary. Using
|
|
||||||
priorities to route OTR messages isn't practical either because they are not
|
|
||||||
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 another.im should be an instant messenger for the future and
|
|
||||||
instead of making another.im 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
|
|
||||||
another.im is trying to get rid of old behaviours and set an example for
|
|
||||||
other clients.
|
|
||||||
|
|
||||||
|
|
||||||
### How do I backup / move another.im to a new device?
|
|
||||||
|
|
||||||
Use the Backup button in the Settings.
|
|
||||||
|
|
||||||
### another.im is missing a certain feature
|
|
||||||
|
|
||||||
Please report it to our XMPP conference [`xmppclient-dev@conference.another.im`](xmpp:xmppclient-dev@conference.another.im).
|
|
||||||
|
|
||||||
## Security
|
|
||||||
|
|
||||||
### Why are there two end-to-end encryption methods and which one should I choose?
|
|
||||||
|
|
||||||
* OMEMO works even when a contact is offline, and works with multiple devices. It also allows asynchronous file-transfer when the server has [HTTP File Upload](http://xmpp.org/extensions/xep-0363.html). However, OMEMO not widely support and is currently implemented only [by a handful of clients](https://omemo.top).
|
|
||||||
* OpenPGP (XEP-0027) is a very old encryption method that has some advantages over OMEMO but should only be used by people who know what they are doing.
|
|
||||||
|
|
||||||
### How do I use OpenPGP
|
|
||||||
|
|
||||||
Before you continue reading you should note that the OpenPGP support in
|
|
||||||
another.im 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
|
|
||||||
JID's. But of course no email or XMPP client out there implements these
|
|
||||||
concepts. Plus PGP in the context of instant messaging has a couple of
|
|
||||||
downsides: It is vulnerable to replay attacks and it is rather verbose.
|
|
||||||
|
|
||||||
To use OpenPGP you have to install the open source app
|
|
||||||
[OpenKeychain](http://www.openkeychain.org) and then long press on the account in
|
|
||||||
manage accounts and choose renew PGP announcement from the contextual menu.
|
|
||||||
|
|
||||||
### OMEMO is grayed out. What do I do?
|
|
||||||
OMEMO is only available in 1:1 chats and private (members-only, non-anonymous) group chats. Encrypting public group chats makes little to no sense since anyone (including a hypothetical attacker) can join and a user couldn’t possibily verify all participants anyway. Furthermore for a lot of public group chat it is desirable to give new comers access to the full history.
|
|
||||||
|
|
||||||
### OMEMO doesn’t work. I get a 'Something went wrong' message in the 'Trust OMEMO Fingerprints' screen.
|
|
||||||
OMEMO has two requirements: Your server and the server of your contact need to support PEP. Both of you can verify that individually by opening your account details and selecting ```Server info``` from the menu. The appearing table should list PEP as available. The second requirement is that the initial sender needs to have access to the published key material. This can either be achieved by having mutual presence subscription (you can verify that by opening the contact details and see if both check boxes *Send presence updates* and *Receive presence updates* are checked) or by using a server that makes the public key material accessible to anyone. In the [Compliance Tester](https://compliance.conversations.im) this is indicated by the 'OMEMO' feature. Since it is very common that the first messages are exchanged *before* adding each other to the contact list it is desirable to use servers that have 'OMEMO support'.
|
|
||||||
|
|
||||||
### How does the encryption for group chats work?
|
|
||||||
|
|
||||||
#### OMEMO
|
|
||||||
|
|
||||||
OMEMO encryption works only in private (members only) conferences that are non-anonymous. Non-anonymous (being able to discover the real JID of other participants) is a technical requirement to discover the key material. Members only is a sort of arbitrary requirement imposed by another.im. (see 'OMEMO is grayed out')
|
|
||||||
|
|
||||||
The server of all participants need to pass the OMEMO [Compliance Test](https://conversations.im/compliance/).
|
|
||||||
In other words they either need to run ejabberd 18.01+ or Prosody 0.11+.
|
|
||||||
|
|
||||||
(Alternatively it would also work if all participants had each other in their contact list; But that rarely is the case in larger group chats.)
|
|
||||||
|
|
||||||
The owner of a conference can make a public conference private by going into the conference
|
|
||||||
details and hit the settings button (the one with the gears) and select both *private* and
|
|
||||||
*members only*.
|
|
||||||
|
|
||||||
#### OpenPGP
|
|
||||||
|
|
||||||
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 another.im 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. another.im is the only client that uses
|
|
||||||
XEP-0027 with conferences. (The XEP neither specifically allows nor disallows
|
|
||||||
this.)
|
|
||||||
|
|
||||||
### What is Blind Trust Before Verification / why are messages marked with a red lock?
|
|
||||||
|
|
||||||
Read more about the concept on https://gultsch.de/trust.html
|
|
||||||
|
|
||||||
### I found a bug
|
|
||||||
|
|
||||||
Please report it to our XMPP conference [`xmppclient-dev@conference.narayana.im`](xmpp:xmppclient-dev@conference.narayana.im).
|
|
436
README.md
|
@ -1,44 +1,17 @@
|
||||||
<h1 align="center">another.im</h1>
|
<h1 align="center">Conversations</h1>
|
||||||
|
|
||||||
<h2><p align="center">another.im: the very last word in instant messaging</p></h2>
|
<p align="center">Conversations: the very last word in instant messaging</p>
|
||||||
|
|
||||||
<p align="center"><a href="https://f-droid.org/packages/im.narayana.another">
|
<p align="center">
|
||||||
<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png"
|
<a href="https://play.google.com/store/apps/details?id=eu.siacs.conversations&referrer=utm_source%3Dcodeberg">
|
||||||
alt="Get it on F-Droid"
|
<img src="https://conversations.im/images/get-it-on-play.png" alt="Get it on Google Play" height="80">
|
||||||
height="80">
|
</a>
|
||||||
</a></p>
|
<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>
|
||||||
|
|
||||||
## About app and us
|
![screenshots](https://codeberg.org/iNPUTmice/Conversations/raw/branch/master/screenshots.png)
|
||||||
|
|
||||||
the Another Instant Messenger is not a messenger,
|
|
||||||
> don't believe the words.
|
|
||||||
|
|
||||||
|
|
||||||
we just offer you a standardized xmpp-client with predicted features which work equally on all platforms.\
|
|
||||||
in our clients we have realized pure XMPP and you won't step on a hedgehog unexpectedly when you communicate with another xmpp client or server.
|
|
||||||
|
|
||||||
|
|
||||||
because you buy our open-sourced bitcoin,\
|
|
||||||
we decided to offer you to buy our open-sourced messenger.\
|
|
||||||
ha-ha joke.\
|
|
||||||
it's free, really. GPLv3, whatever.
|
|
||||||
|
|
||||||
|
|
||||||
for Android, it's just an improved fork of Conversations,\
|
|
||||||
for iOS, it's our development from scratch,\
|
|
||||||
for Desktops we're polako [looking](xmpp:xmppclient-dev@conference.another.im). for devs.
|
|
||||||
|
|
||||||
|
|
||||||
all necessary XEP's was realized by Conversations devs but we added a cherry on top of this.\
|
|
||||||
on the server side we are offering free to use Prosody server,\
|
|
||||||
just connect to [another.im](xmpp:xmppclient-dev@conference.another.im), but we are encouroge you to don't trust us, self-host.
|
|
||||||
|
|
||||||
OTR encryption is also supported as 'secret chats' due to otr has been designed for one-time sessions and the concept of 'secret chats' fully corresponds to the idea of the OTR.
|
|
||||||
|
|
||||||
also you can just download <a href="https://dev.narayana.im/narayana/anotherim">the source code</a>, compile it and install.
|
|
||||||
|
|
||||||
only those who seeks will realize the way to pay us,\
|
|
||||||
we can offer you additional server-side features and some telecommunication magic on our <a href="https://narayana.im">narayana.im</a>
|
|
||||||
|
|
||||||
## Design principles
|
## Design principles
|
||||||
|
|
||||||
|
@ -49,9 +22,9 @@ we can offer you additional server-side features and some telecommunication magi
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
* End-to-end encryption with [OMEMO](https://en.wikipedia.org/wiki/OMEMO) or [OpenPGP](https://openpgp.org/about/)
|
* End-to-end encryption with [OMEMO](http://conversations.im/omemo/) or [OpenPGP](http://openpgp.org/about/)
|
||||||
* Send and receive images as well as other kind of files
|
* Send and receive images as well as other kind of files
|
||||||
* Encrypted audio and video calls (DTLS-SRTP) with DTMF dialer support
|
* [Encrypted audio and video calls (DTLS-SRTP)](https://help.conversations.im)
|
||||||
* Share your location
|
* Share your location
|
||||||
* Send voice messages
|
* Send voice messages
|
||||||
* Indication when your contact has read your message
|
* Indication when your contact has read your message
|
||||||
|
@ -66,15 +39,14 @@ we can offer you additional server-side features and some telecommunication magi
|
||||||
|
|
||||||
### XMPP Features
|
### XMPP Features
|
||||||
|
|
||||||
another.im works with every XMPP server out there. However XMPP is an
|
Conversations works with every XMPP server out there. However XMPP is an
|
||||||
extensible protocol. These extensions are standardized as well in so called
|
extensible protocol. These extensions are standardized as well in so called
|
||||||
XEP's. another.im supports a couple of these to make the overall user
|
XEP's. Conversations supports a couple of these to make the overall user
|
||||||
experience better. There is a chance that your current XMPP server does not
|
experience better. There is a chance that your current XMPP server does not
|
||||||
support these extensions; therefore to get the most out of another.im you
|
support these extensions; therefore to get the most out of Conversations you
|
||||||
should consider either switching to an XMPP server that does or — even better —
|
should consider either switching to an XMPP server that does or — even better —
|
||||||
run your own XMPP server for you and your friends. These XEP's are:
|
run your own XMPP server for you and your friends. These XEP's are:
|
||||||
|
|
||||||
* [XEP-0050: Ad-Hoc Commands](http://xmpp.org/extensions/xep-0050.html) lets to interact with gateways
|
|
||||||
* [XEP-0065: SOCKS5 Bytestreams](http://xmpp.org/extensions/xep-0065.html) will be used to transfer
|
* [XEP-0065: SOCKS5 Bytestreams](http://xmpp.org/extensions/xep-0065.html) will be used to transfer
|
||||||
files if both parties are behind a firewall (NAT).
|
files if both parties are behind a firewall (NAT).
|
||||||
* [XEP-0163: Personal Eventing Protocol](http://xmpp.org/extensions/xep-0163.html) for avatars and OMEMO.
|
* [XEP-0163: Personal Eventing Protocol](http://xmpp.org/extensions/xep-0163.html) for avatars and OMEMO.
|
||||||
|
@ -83,24 +55,384 @@ run your own XMPP server for you and your friends. These XEP's are:
|
||||||
* [XEP-0198: Stream Management](http://xmpp.org/extensions/xep-0198.html) allows XMPP to survive small network outages and
|
* [XEP-0198: Stream Management](http://xmpp.org/extensions/xep-0198.html) allows XMPP to survive small network outages and
|
||||||
changes of the underlying TCP connection.
|
changes of the underlying TCP connection.
|
||||||
* [XEP-0215: External Service Discovery](https://xmpp.org/extensions/xep-0215.html) will be used to discover STUN and TURN servers which facilitate P2P A/V calls.
|
* [XEP-0215: External Service Discovery](https://xmpp.org/extensions/xep-0215.html) will be used to discover STUN and TURN servers which facilitate P2P A/V calls.
|
||||||
* [XEP-0237: Roster Versioning](http://xmpp.org/extensions/xep-0237.html) mainly to save bandwidth on poor mobile connections
|
|
||||||
* [XEP-0280: Message Carbons](http://xmpp.org/extensions/xep-0280.html) which automatically syncs the messages you send to
|
* [XEP-0280: Message Carbons](http://xmpp.org/extensions/xep-0280.html) which automatically syncs the messages you send to
|
||||||
your desktop client and thus allows you to switch seamlessly from your mobile
|
your desktop client and thus allows you to switch seamlessly from your mobile
|
||||||
client to your desktop client and back within one conversation.
|
client to your desktop client and back within one conversation.
|
||||||
* [XEP-0308: Last Message Correction](https://xmpp.org/extensions/xep-0308.html) allows you to edit last message as well as retract it
|
* [XEP-0237: Roster Versioning](http://xmpp.org/extensions/xep-0237.html) mainly to save bandwidth on poor mobile connections
|
||||||
* [XEP-0313: Message Archive Management](http://xmpp.org/extensions/xep-0313.html) synchronize message history with the
|
* [XEP-0313: Message Archive Management](http://xmpp.org/extensions/xep-0313.html) synchronize message history with the
|
||||||
server. Catch up with messages that were sent while another.im was
|
server. Catch up with messages that were sent while Conversations was
|
||||||
offline.
|
offline.
|
||||||
* [XEP-0352: Client State Indication](http://xmpp.org/extensions/xep-0352.html) lets the server know whether or not
|
* [XEP-0352: Client State Indication](http://xmpp.org/extensions/xep-0352.html) lets the server know whether or not
|
||||||
Conversations is in the background. Allows the server to save bandwidth by
|
Conversations is in the background. Allows the server to save bandwidth by
|
||||||
withholding unimportant packages.
|
withholding unimportant packages.
|
||||||
* [XEP-0363: HTTP File Upload](http://xmpp.org/extensions/xep-0363.html) allows you to share files in conferences
|
* [XEP-0363: HTTP File Upload](http://xmpp.org/extensions/xep-0363.html) allows you to share files in conferences
|
||||||
and with offline contacts.
|
and with offline contacts.
|
||||||
* [XEP-0461: Message Replies](https://xmpp.org/extensions/xep-0461.html) provides support of native replies, which also works in many transports (gateways) as well
|
|
||||||
* [XEP-0364: Current Off-the-Record Messaging Usage](https://xmpp.org/extensions/xep-0364.html) is also supported as 'secret chats' due to otr has been designed for one-time sessions and the concept of 'secret chats' fully corresponds to the idea of the OTR.
|
|
||||||
|
|
||||||
### FAQ
|
## FAQ
|
||||||
|
|
||||||
[*FAQ*](/FAQ.md) is located separately and may contain links to upstream.
|
### General
|
||||||
|
|
||||||
*In case of issues, bugs, suggestions please contact us directly [`xmppclient-dev@conference.another.im`](xmpp:xmppclient-dev@conference.another.im).*
|
#### How do I install Conversations?
|
||||||
|
|
||||||
|
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 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 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.
|
||||||
|
|
||||||
|
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 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.
|
||||||
|
|
||||||
|
#### I get 'Incompatible Server'
|
||||||
|
|
||||||
|
As regular user you should be picking a different server. The server you selected
|
||||||
|
is probably insecure and/or very old.
|
||||||
|
|
||||||
|
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 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 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.
|
||||||
|
|
||||||
|
Maybe you attempted to use the Jabber ID `test@b.tld` because `a.tld` doesn’t point to the correct host. In that case you might have to enable the extended connection settings in the expert settings of Conversations and set a host name.
|
||||||
|
|
||||||
|
#### I get 'Stream opening error'. What does that mean?
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
|
||||||
|
#### 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.
|
||||||
|
|
||||||
|
##### 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 [`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 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
|
||||||
|
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 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. 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.
|
||||||
|
|
||||||
|
#### I get 'delivery failed' on my messages
|
||||||
|
|
||||||
|
If you get delivery failed on images it's probably because the recipient lost
|
||||||
|
network connectivity during reception. In that case you can try it again at a
|
||||||
|
later time.
|
||||||
|
|
||||||
|
For text messages the answer to your question is a little bit more complex.
|
||||||
|
When you see 'delivery failed' on text messages, it is always something that is
|
||||||
|
being reported by the server. The most common reason for this is that the
|
||||||
|
recipient failed to resume a connection. When a client loses connectivity for a
|
||||||
|
short time the client usually has a five minute window to pick up that
|
||||||
|
connection again. When the client fails to do so because the network
|
||||||
|
connectivity is out for longer than that all messages sent to that client will
|
||||||
|
be returned to the sender resulting in a delivery failed.
|
||||||
|
|
||||||
|
Instead of returning a message to the sender both ejabberd and prosody have the
|
||||||
|
ability to store messages in offline storage when the disconnecting client is
|
||||||
|
the only client. In prosody this is available via an extra module called
|
||||||
|
```mod_smacks_offline```. In ejabberd this is available via some configuration
|
||||||
|
settings.
|
||||||
|
|
||||||
|
Other less common reasons are that the message you sent didn't meet some
|
||||||
|
criteria enforced by the server (too large, too many). Another reason could be
|
||||||
|
that the recipient is offline and the server doesn't provide offline storage.
|
||||||
|
|
||||||
|
Usually you are able to distinguish between these two groups in the fact that
|
||||||
|
the first one happens always after some time and the second one happens almost
|
||||||
|
instantly.
|
||||||
|
|
||||||
|
#### Where can I see the status of my contacts? How can I set a status or priority?
|
||||||
|
|
||||||
|
Statuses are a horrible metric. Setting them manually to a proper value rarely
|
||||||
|
works because users are either lazy or just forget about them. Setting them
|
||||||
|
automatically does not provide quality results either. Keyboard or mouse
|
||||||
|
activity as indicator for example fails when the user is just looking at
|
||||||
|
something (reading an article, watching a movie). Furthermore automatic setting
|
||||||
|
of status always implies an impact on your privacy (are you sure you want
|
||||||
|
everybody in your contact list to know that you have been using your computer at
|
||||||
|
4am‽).
|
||||||
|
|
||||||
|
In the past status has been used to judge the likelihood of whether or not your
|
||||||
|
messages are being read. This is no longer necessary. With Chat Markers
|
||||||
|
(XEP-0333, supported by Conversations since 0.4) we have the ability to **know**
|
||||||
|
whether or not your messages are being read. Similar things can be said for
|
||||||
|
priorities. In the past priorities have been used (by servers, not by clients!)
|
||||||
|
to route your messages to one specific client. With carbon messages (XEP-0280,
|
||||||
|
supported by Conversations since 0.1) this is no longer necessary. Using
|
||||||
|
priorities to route OTR messages isn't practical either because they are not
|
||||||
|
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 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 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/).
|
||||||
|
|
||||||
|
You can log in with your Codeberg account and start translating.
|
||||||
|
|
||||||
|
#### How do I backup / move Conversations to a new device?
|
||||||
|
|
||||||
|
See the dedicated guides for
|
||||||
|
- [backups](docs/user/backup.md)
|
||||||
|
- [migrations](docs/user/migrating_to_new_device.md)
|
||||||
|
|
||||||
|
#### 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
|
||||||
|
|
||||||
|
#### Why are there two end-to-end encryption methods and which one should I choose?
|
||||||
|
|
||||||
|
* OMEMO works even when a contact is offline, and works with multiple devices. It also allows asynchronous file-transfer when the server has [HTTP File Upload](http://xmpp.org/extensions/xep-0363.html). However, OMEMO not widely support and is currently implemented only [by a handful of clients](https://omemo.top).
|
||||||
|
* OpenPGP (XEP-0027) is a very old encryption method that has some advantages over OMEMO but should only be used by people who know what they are doing.
|
||||||
|
|
||||||
|
#### How do I use OpenPGP
|
||||||
|
|
||||||
|
Before you continue reading you should note that the OpenPGP support in
|
||||||
|
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
|
||||||
|
JID's. But of course no email or XMPP client out there implements these
|
||||||
|
concepts. Plus PGP in the context of instant messaging has a couple of
|
||||||
|
downsides: It is vulnerable to replay attacks and it is rather verbose.
|
||||||
|
|
||||||
|
To use OpenPGP you have to install the open source app
|
||||||
|
[OpenKeychain](http://www.openkeychain.org) and then long press on the account in
|
||||||
|
manage accounts and choose renew PGP announcement from the contextual menu.
|
||||||
|
|
||||||
|
#### OMEMO is grayed out. What do I do?
|
||||||
|
OMEMO is only available in 1:1 chats and private (members-only, non-anonymous) group chats. Encrypting public group chats makes little to no sense since anyone (including a hypothetical attacker) can join and a user couldn’t possibily verify all participants anyway. Furthermore for a lot of public group chat it is desirable to give new comers access to the full history.
|
||||||
|
|
||||||
|
#### OMEMO doesn’t work. I get a 'Something went wrong' message in the 'Trust OMEMO Fingerprints' screen.
|
||||||
|
OMEMO has two requirements: Your server and the server of your contact need to support PEP. Both of you can verify that individually by opening your account details and selecting ```Server info``` from the menu. The appearing table should list PEP as available. The second requirement is that the initial sender needs to have access to the published key material. This can either be achieved by having mutual presence subscription (you can verify that by opening the contact details and see if both check boxes *Send presence updates* and *Receive presence updates* are checked) or by using a server that makes the public key material accessible to anyone. In the [Compliance Tester](https://compliance.conversations.im) this is indicated by the 'OMEMO' feature. Since it is very common that the first messages are exchanged *before* adding each other to the contact list it is desirable to use servers that have 'OMEMO support'.
|
||||||
|
|
||||||
|
#### How does the encryption for group chats work?
|
||||||
|
|
||||||
|
##### OMEMO
|
||||||
|
|
||||||
|
OMEMO encryption works only in private (members only) conferences that are non-anonymous. Non-anonymous (being able to discover the real JID of other participants) is a technical requirement to discover the key material. Members only is a sort of arbitrary requirement imposed by Conversations. (see 'OMEMO is grayed out')
|
||||||
|
|
||||||
|
The server of all participants need to pass the OMEMO [Compliance Test](https://conversations.im/compliance/).
|
||||||
|
In other words they either need to run ejabberd 18.01+ or Prosody 0.11+.
|
||||||
|
|
||||||
|
(Alternatively it would also work if all participants had each other in their contact list; But that rarely is the case in larger group chats.)
|
||||||
|
|
||||||
|
The owner of a conference can make a public conference private by going into the conference
|
||||||
|
details and hit the settings button (the one with the gears) and select both *private* and
|
||||||
|
*members only*.
|
||||||
|
|
||||||
|
##### OpenPGP
|
||||||
|
|
||||||
|
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 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 is the only client that uses
|
||||||
|
XEP-0027 with conferences. (The XEP neither specifically allows nor disallows
|
||||||
|
this.)
|
||||||
|
|
||||||
|
#### What is Blind Trust Before Verification / why are messages marked with a red lock?
|
||||||
|
|
||||||
|
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 [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 |
BIN
app/src/conversations/res/mipmap-hdpi/new_launcher.png
Normal file
After Width: | Height: | Size: 2.4 KiB |
BIN
app/src/conversations/res/mipmap-hdpi/new_launcher_round.png
Normal file
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 |
BIN
app/src/conversations/res/mipmap-mdpi/new_launcher.png
Normal file
After Width: | Height: | Size: 1.7 KiB |
BIN
app/src/conversations/res/mipmap-mdpi/new_launcher_round.png
Normal file
After Width: | Height: | Size: 2.7 KiB |
After Width: | Height: | Size: 2.4 KiB |
BIN
app/src/conversations/res/mipmap-xhdpi/new_launcher.png
Normal file
After Width: | Height: | Size: 3.3 KiB |
BIN
app/src/conversations/res/mipmap-xhdpi/new_launcher_round.png
Normal file
After Width: | Height: | Size: 5.9 KiB |
After Width: | Height: | Size: 3.8 KiB |
BIN
app/src/conversations/res/mipmap-xxhdpi/new_launcher.png
Normal file
After Width: | Height: | Size: 4.7 KiB |
BIN
app/src/conversations/res/mipmap-xxhdpi/new_launcher_round.png
Normal file
After Width: | Height: | Size: 9.1 KiB |
After Width: | Height: | Size: 5.2 KiB |
BIN
app/src/conversations/res/mipmap-xxxhdpi/new_launcher.png
Normal file
After Width: | Height: | Size: 6.4 KiB |
BIN
app/src/conversations/res/mipmap-xxxhdpi/new_launcher_round.png
Normal file
After Width: | Height: | Size: 13 KiB |
20
app/src/conversations/res/values-ar/strings.xml
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
<?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ولكن من أجل راحتك ، فقد جعلنا من السهل إنشاء حساب على موقع chat. مزود مناسب بشكل خاص للاستخدام مع المحادثات.</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>
|
17
app/src/conversations/res/values-bg/strings.xml
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
<?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За Ваше удобство, обаче, ние предоставяме лесен начин да си създадете профил в 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-bn-rIN/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 প্রোগ্রাম বা অ্যাপ ব্যবহার করে থাকেন। এই মুহুর্তে আরেকটা অ্যকাউন্ট তৈরী করা সম্ভব না।\nHint: মাঝে মাঝে ইমেল অ্যকাউন্ট খুললেও এরকম অ্যকাউন্ট নিজে থেকেই তৈরী হয়ে যায়।</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-এ নিমন্ত্রণ করা হয়েছে। একটি username-ও আপনার জন্যে নির্দিষ্ট করে রাখা হয়েছে। অ্যকাউন্ট তৈরী করার সময় আপনাকে সাহায্য করা হবে।\nঅন্য XMPP সেবা প্রদানকারী সংস্থার ব্যবহারকারীদের সাথে আপনিও কথা বলতে পারবেন, আপনার সম্পূর্ণ XMPP অ্যড্রেস তাদেরকে বলে দিয়ে।</string>
|
||||||
|
<string name="your_server_invitation">আপনার নিমন্ত্রণপত্র, সার্ভার থেকে</string>
|
||||||
|
<string name="improperly_formatted_provisioning">Provisioning code-এ গরমিল আছে</string>
|
||||||
|
<string name="tap_share_button_send_invite">Share বোতামটা টিপে %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>
|
17
app/src/conversations/res/values-ca/strings.xml
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="pick_a_server">Triï el seu proveïdor de XMPP
|
||||||
|
</string>
|
||||||
|
<string name="use_conversations.im">Fer servir conversations.im</string>
|
||||||
|
<string name="create_new_account">Crear un compte nou</string>
|
||||||
|
<string name="do_you_have_an_account">Ja tens un compte XMPP? Aquest podria ser el cas si ja estàs usant un client XMPP diferent o has usat Converses abans. Si no, pots crear un nou compte XMPP ara mateix.\nPista: Alguns proveïdors de correu electrònic també proporcionen comptes XMPP.</string>
|
||||||
|
<string name="server_select_text">XMPP és una xarxa de missatgeria instantània independent del proveïdor. Pots usar aquest client amb qualsevol servidor XMPP que triïs. No obstant això, per a la teva conveniència, hem fet fàcil la creació d\'un compte en Conversaciones.im; un proveïdor especialment adequat per a l\'ús amb Conversations.</string>
|
||||||
|
<string name="magic_create_text_on_x">Has estat convidat a %1$s. Et guiarem a través del procés de creació d\'un compte.\nEn triar%1$s com a proveïdor podràs comunicar-se amb els usuaris d\'altres proveïdors donant-los la seva adreça XMPP completa.</string>
|
||||||
|
<string name="magic_create_text_fixed">Has estat convidat a %1$s . Ja s\'ha triat un nom d\'usuari per a tu. Et guiarem en el procés de creació d\'un compte. Podràs comunicar-te amb usuaris d\'altres proveïdors donant-los la teva adreça XMPP completa.</string>
|
||||||
|
<string name="your_server_invitation">La teva invitació al servidor</string>
|
||||||
|
<string name="improperly_formatted_provisioning">Codi d\'aprovisionament mal formatat</string>
|
||||||
|
<string name="tap_share_button_send_invite">Toca el botó de compartir per a enviar al teu contacte una invitació a %1$s .</string>
|
||||||
|
<string name="if_contact_is_nearby_use_qr">Si el teu contacte està a prop, també pot escanejar el codi de baix per a acceptar la teva invitació.</string>
|
||||||
|
<string name="easy_invite_share_text">Uneix-te %1$s i xerra amb mi: %2$s</string>
|
||||||
|
<string name="share_invite_with">Comparteix la invitació amb...</string>
|
||||||
|
</resources>
|
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-de/strings.xml
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="pick_a_server">Wähle deinen XMPP-Provider</string>
|
||||||
|
<string name="use_conversations.im">Benutze conversations.im</string>
|
||||||
|
<string name="create_new_account">Neues Konto erstellen</string>
|
||||||
|
<string name="do_you_have_an_account">Hast du bereits ein XMPP-Konto? Dies kann der Fall sein, wenn du bereits einen anderen XMPP-Client verwendest oder bereits Conversations verwendet hast. Wenn nicht, kannst du jetzt ein neues XMPP-Konto erstellen.\nTipp: Einige E-Mail-Anbieter bieten auch XMPP-Konten an.</string>
|
||||||
|
<string name="server_select_text">XMPP ist ein anbieterunabhängiges Instant Messaging Netzwerk. Du kannst diesen Client mit jedem beliebigen XMPP-Server nutzen.\nUm es dir leicht zu machen, haben wir die Möglichkeit geschaffen, ein Konto auf conversations.im anzulegen; ein Anbieter, der speziell für die Verwendung mit Conversations geeignet ist.</string>
|
||||||
|
<string name="magic_create_text_on_x">Du wurdest zu %1$s eingeladen. Wir führen dich durch den Prozess der Kontoerstellung.\nWenn du %1$s als Provider wählst, kannst du mit Nutzern anderer Anbieter kommunizieren, indem du ihnen deine vollständige XMPP-Adresse gibst.</string>
|
||||||
|
<string name="magic_create_text_fixed">Du wurdest zu %1$seingeladen. Ein Benutzername ist bereits für dich ausgewählt worden. Wir führen dich durch den Prozess der Kontoerstellung.\nDu kannst mit Nutzern anderer Anbieter kommunizieren, indem du ihnen deine vollständige XMPP-Adresse gibst.</string>
|
||||||
|
<string name="your_server_invitation">Deine Einladung für den Server</string>
|
||||||
|
<string name="improperly_formatted_provisioning">Falsch formatierter Provisionierungscode</string>
|
||||||
|
<string name="tap_share_button_send_invite">Tippe auf die \"Teilen\"-Schaltfläche, um deinem Kontakt eine Einladung an %1$s zu senden.</string>
|
||||||
|
<string name="if_contact_is_nearby_use_qr">Wenn dein Kontakt in der Nähe ist, kann er auch den untenstehenden Code einscannen, um deine Einladung anzunehmen.</string>
|
||||||
|
<string name="easy_invite_share_text">Komme zu %1$s und chatte mit mir: %2$s</string>
|
||||||
|
<string name="share_invite_with">Einladung teilen mit…</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>
|
17
app/src/conversations/res/values-es/strings.xml
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="pick_a_server">Elige tu proveedor XMPP</string>
|
||||||
|
<string name="use_conversations.im">Usa conversations.im</string>
|
||||||
|
<string name="create_new_account">Crear nueva cuenta</string>
|
||||||
|
<string name="do_you_have_an_account">¿Ya tienes una cuenta XMPP? Este puede ser el caso si ya estás usando un cliente XMPP diferente o has usado Conversations anteriormente. Si no es así, puedes crear una nueva cuenta XMPP ahora mismo.\nConsejo: Algunos proveedores de email también ofrecen una cuenta XMPP.</string>
|
||||||
|
<string name="server_select_text">XMPP es una red de mensajería instantánea independiente del proveedor. Puedes usar este cliente con cualquier servidor XMPP que elijas.
|
||||||
|
\nSin embargo, para tu conveniencia, hacemos de forma sencilla la creación de una cuenta en conversations.im; un proveedor especializado para el uso con Conversations.</string>
|
||||||
|
<string name="magic_create_text_on_x">Has sido invitado a %1$s. Te guiaremos durante el proceso de creación de la cuenta.\nCuando selecciones %1$s como proveedor podrás comunicarte con usuarios de otros servidores proporcionándoles tu dirección XMPP completa. </string>
|
||||||
|
<string name="magic_create_text_fixed">Has sido invitado a %1$s. Un nombre de usuario ya ha sido escogido para ti. Te guiaremos durante el proceso de creación de la cuenta.\nPodrás comunicarte con otros usuarios de otros servidores proporcionándoles tu dirección XMPP completa. </string>
|
||||||
|
<string name="your_server_invitation">Tu invitación al servidor</string>
|
||||||
|
<string name="improperly_formatted_provisioning">Código de abastecimiento formateado incorrectamente</string>
|
||||||
|
<string name="tap_share_button_send_invite">Pulsa el botón de compartir para enviar a tu contacto una invitación a %1$s.</string>
|
||||||
|
<string name="if_contact_is_nearby_use_qr">Si tu contacto está cerca, también puede escanear el código mostrado debajo para aceptar tu invitación.</string>
|
||||||
|
<string name="easy_invite_share_text">Únete a %1$s y chatea conmigo: %2$s</string>
|
||||||
|
<string name="share_invite_with">Comparte la invitación con…</string>
|
||||||
|
</resources>
|
8
app/src/conversations/res/values-eu/strings.xml
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="pick_a_server">Hautatu zure XMPP hornitzailea</string>
|
||||||
|
<string name="use_conversations.im">Erabili conversations.im</string>
|
||||||
|
<string name="create_new_account">Kontu berria sortu</string>
|
||||||
|
<string name="do_you_have_an_account">XMPP kontu bat badaukazu dagoeneko? Horrela izan daiteke beste XMPP aplikazio bat erabiltzen baduzu edo Conversations lehenago erabili baduzu. Bestela XMPP kontu berri bat sortu dezakezu oraintxe bertan.\nIradokizuna: email hornitzaile batzuek XMPP kontuak hornitzen dituzte ere.</string>
|
||||||
|
<string name="server_select_text">XMPP hornitzailez independientea den bat-bateko mezularitza sare bat da. Aplikazio hau nahi duzun XMPP zerbitzariarekin erabili dezakezu.\nHala ere zure erosotasunerako conversations.im-en, Conversationsekin bereziki erabiltzeko egokia den hornitzaile batean, kontu bat sortzea erraz egin dugu.</string>
|
||||||
|
</resources>
|
6
app/src/conversations/res/values-fa-rIR/strings.xml
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="pick_a_server">لطفا سرویس دهنده پیام خود را انتخاب نمائید. برای مثال artalk.im</string>
|
||||||
|
<string name="use_conversations.im">از Conversations.im استفاده کنید</string>
|
||||||
|
<string name="create_new_account">حساب کاربری جدیدی بسازید</string>
|
||||||
|
</resources>
|
14
app/src/conversations/res/values-fi/strings.xml
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="pick_a_server">Valitse XMPP-palveluntarjoaja</string>
|
||||||
|
<string name="use_conversations.im">Käytä conversations.im:ää</string>
|
||||||
|
<string name="create_new_account">Luo uusi tili</string>
|
||||||
|
<string name="do_you_have_an_account">Onko sinulla jo XMPP-tunnus? Jos käytät jo toista XMPP-sovellusta tai olet käyttänyt Conversationsia aiemmin, niin voi olla. Jos ei, voit tehdä uuden XMPP-tilin saman tien.\nVinkki: Jotkin sähköpostipalvelut tarjoavat myös XMPP-tilin.</string>
|
||||||
|
<string name="server_select_text">XMPP on tietystä palveluntarjoasta riippumaton pikaviestiverkosto. Voit käyttää tätä asiakasohjelmaa minkä tahansa haluamasi XMPP-palvelimen kanssa.\nHelppouden nimissä olemme kuitenkin helpottaneet tilin luomista conversations.im:iin.</string>
|
||||||
|
<string name="magic_create_text_on_x">Sinut on kutsuttu %1$s:iin. Opastamme sinua tilin luomisen kanssa.\nValitessasi palvelimen %1$s palveluntarjoajaksesi voit jutella muiden palveluntajoajien käyttäjien kanssa kertomalla heille koko XMPP-osoitteesi.</string>
|
||||||
|
<string name="magic_create_text_fixed">Sinut on kutsuttu palvelimelle %1$s. Käyttäjänimesi on valittu valmiiksi puolestasi. Opastamme sinua tilin luomisen kanssa.\nVoit jutella muiden palveluntarjoajien käyttäjien kanssa kertomalle heille koko XMPP-osoitteesi.</string>
|
||||||
|
<string name="your_server_invitation">Kutsusi palvelimelle</string>
|
||||||
|
<string name="improperly_formatted_provisioning">Virheellisesti muotoiltu koodi</string>
|
||||||
|
<string name="if_contact_is_nearby_use_qr">Jos henkilö on lähellä, hän voi myös hyväksyä kutsun lukemalla allaolevan koodin.</string>
|
||||||
|
<string name="share_invite_with">Jaa kutsu sovelluksella...</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>
|
16
app/src/conversations/res/values-gl/strings.xml
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="pick_a_server">Elixe o teu provedor XMPP</string>
|
||||||
|
<string name="use_conversations.im">Utilizar conversations.im</string>
|
||||||
|
<string name="create_new_account">Crear nova conta</string>
|
||||||
|
<string name="do_you_have_an_account">Xa posúes unha conta XMPP? Este pode ser o caso se xa estás a utilizar outro cliente XMPP ou utilizaches Conversations previamente. Se non é así podes crear unha nova conta agora mesmo.\nTruco: Algúns provedores de correo tamén proporcionan contas XMPP.</string>
|
||||||
|
<string name="server_select_text">XMPP é unha rede de mensaxería independente do provedor. Podes utilizar este cliente con calquera provedor XMPP da túa elección.\nMais para a tua conveniencia fixemos que fose doado crear unha conta en conversations.im; un provedor especialmente axeitado para utilizar con Conversations.</string>
|
||||||
|
<string name="magic_create_text_on_x">Convidáronte a %1$s. Guiarémoste no proceso para crear unha conta.\nAo elexir %1$s como provedor poderás comunicarte con usuarias doutros provedores cando lles deas o teu enderezo XMPP completo.</string>
|
||||||
|
<string name="magic_create_text_fixed">Convidáronte a %1$s. Xa eleximos un nome de usuaria para ti. Guiarémoste no proceso de crear unha conta.\nPoderás comunicarte con usuarias doutros provedores cando lles digas o teu enderezo XMPP completo.</string>
|
||||||
|
<string name="your_server_invitation">O convite do teu servidor</string>
|
||||||
|
<string name="improperly_formatted_provisioning">Código de aprovisionamento con formato non válido</string>
|
||||||
|
<string name="tap_share_button_send_invite">Toca no botón compartir para convidar ao teu contacto a %1$s.</string>
|
||||||
|
<string name="if_contact_is_nearby_use_qr">Se o contacto está preto de ti, pode escanear o código inferior para aceptar o teu convite.</string>
|
||||||
|
<string name="easy_invite_share_text">Únete a %1$s e conversa conmigo: %2$s</string>
|
||||||
|
<string name="share_invite_with">Enviar convite a…</string>
|
||||||
|
</resources>
|
16
app/src/conversations/res/values-hr/strings.xml
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="pick_a_server">Odaberite svog XMPP davatelja usluga.</string>
|
||||||
|
<string name="use_conversations.im">Koristite conversations.im</string>
|
||||||
|
<string name="create_new_account">Napravi novi račun</string>
|
||||||
|
<string name="do_you_have_an_account">Već imate XMPP račun? To može biti slučaj ako već koristite drugi XMPP klijent ili ste prije koristili Razgovore. Ako niste, možete odmah stvoriti novi XMPP račun.\nSavjet: Neki pružatelji usluga e-pošte također nude XMPP račune.</string>
|
||||||
|
<string name="server_select_text">XMPP je mreža za razmjenu izravnih poruka neovisna o pružatelju usluga. Možete koristiti ovaj klijent s bilo kojim XMPP poslužiteljem koji odaberete.\nMeđutim, radi vaše udobnosti olakšali smo kreiranje računa na conversations.im; pružatelj usluga posebno prilagođen za korištenje s Conversations.</string>
|
||||||
|
<string name="magic_create_text_on_x">Pozvani ste na %1$s. Vodit ćemo vas kroz postupak kreiranja računa.\nPrilikom odabira %1$s pružatelja moći ćete komunicirati s korisnicima drugih pružatelja dajući im svoju punu XMPP adresu.</string>
|
||||||
|
<string name="magic_create_text_fixed">Pozvani ste na %1$s. Korisničko ime je već odabrano za vas. Vodit ćemo vas kroz postupak kreiranja računa.\nMoći ćete komunicirati s korisnicima drugih pružatelja tako da im date svoju punu XMPP adresu.</string>
|
||||||
|
<string name="your_server_invitation">Vaša pozivnica za poslužitelj</string>
|
||||||
|
<string name="improperly_formatted_provisioning">Neispravno formatiran kod za dodjelu</string>
|
||||||
|
<string name="tap_share_button_send_invite">Dodirnite gumb za dijeljenje kako biste svom kontaktu poslali pozivnicu na %1$s.</string>
|
||||||
|
<string name="if_contact_is_nearby_use_qr">Ako je vaš kontakt u blizini, također može skenirati kod u nastavku kako bi prihvatio vašu pozivnicu.</string>
|
||||||
|
<string name="easy_invite_share_text">Pridružite se %1$s i razgovarajte sa mnom: %2$s</string>
|
||||||
|
<string name="share_invite_with">Podijelite pozivnicu s...</string>
|
||||||
|
</resources>
|
16
app/src/conversations/res/values-hu/strings.xml
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="pick_a_server">Válassza ki az XMPP szolgáltatóját</string>
|
||||||
|
<string name="use_conversations.im">A conversations.im használata</string>
|
||||||
|
<string name="create_new_account">Új fiók létrehozása</string>
|
||||||
|
<string name="do_you_have_an_account">Már rendelkezik XMPP-fiókkal? Ez az eset állhat fenn, ha már egy másik XMPP-klienst használ, vagy ha már korábban használta a Conversations alkalmazást. Ha nem, akkor most létrehozhat egy új XMPP-fiókot.\nTipp: egyes e-mail szolgáltatók is biztosítanak XMPP-fiókokat.</string>
|
||||||
|
<string name="server_select_text">Az XMPP egy szolgáltatófüggetlen, azonnali üzenetküldő hálózat. Ezt a kliensprogramot bármely XMPP-kiszolgálóhoz használhatja.\nAzonban a kényelem érdekében megkönnyítettük a conversations.im szolgáltatón való fióklétrehozást, ami kifejezetten a Conversations alkalmazással történő használatra lett tervezve.</string>
|
||||||
|
<string name="magic_create_text_on_x">Meghívást kapott a(z) %1$s kiszolgálóra. Végig fogjuk vezetni egy fiók létrehozásának folyamatán.\nHa a(z) %1$s kiszolgálót választja szolgáltatóként, akkor képes lesz más szolgáltatók felhasználóival is kommunikálni, ha megadja nekik a teljes XMPP-címét.</string>
|
||||||
|
<string name="magic_create_text_fixed">Meghívást kapott a(z) %1$s kiszolgálóra. Már kiválasztottak Önnek egy felhasználónevet. Végig fogjuk vezetni egy fiók létrehozásának folyamatán.\nKépes lesz más szolgáltatók felhasználóival is kommunikálni, ha megadja nekik a teljes XMPP-címét.</string>
|
||||||
|
<string name="your_server_invitation">Az Ön kiszolgálómeghívása</string>
|
||||||
|
<string name="improperly_formatted_provisioning">Helytelenül formázott kiépítési kód</string>
|
||||||
|
<string name="tap_share_button_send_invite">Koppintson a megosztás gombra, hogy meghívót küldjön a partnerének erre: %1$s.</string>
|
||||||
|
<string name="if_contact_is_nearby_use_qr">Ha a partnere a közelben van, akkor a meghívás elfogadásához leolvashatja a lenti kódot.</string>
|
||||||
|
<string name="easy_invite_share_text">Csatlakozzon ehhez: %1$s, és csevegjen velem: %2$s</string>
|
||||||
|
<string name="share_invite_with">Meghívás megosztása…</string>
|
||||||
|
</resources>
|
16
app/src/conversations/res/values-id/strings.xml
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="pick_a_server">Pilih XMPP server anda</string>
|
||||||
|
<string name="use_conversations.im">Gunakan conversations.im</string>
|
||||||
|
<string name="create_new_account">Buat akun baru</string>
|
||||||
|
<string name="do_you_have_an_account">Anda sudah memiliki akun XMPP\? Ini mungkin terjadi jika Anda sudah menggunakan aplikasi XMPP yang berbeda atau pernah menggunakan Conversations sebelumnya. Jika tidak, Anda dapat membuat akun XMPP baru. NPetunjuk: Beberapa penyedia layanan email juga menyediakan akun XMPP.</string>
|
||||||
|
<string name="server_select_text">XMPP adalah jaringan penyedia pesan instan independen. Anda dapat menggunakan aplikasi ini dengan server XMPP pilihan Anda. NNamun demi kenyamanan Anda, kami permudah untuk membuat akun di Conversations.im; provider yang sangat cocok digunakan dengan Conversations.</string>
|
||||||
|
<string name="magic_create_text_on_x">Anda telah diundang ke %1$s. Kami akan memandu Anda melalui proses pembuatan akun. \nSaat memilih %1$s sebagai penyedia, Anda akan dapat berkomunikasi dengan pengguna provider lain dengan memberikan alamat XMPP lengkap Anda kepada mereka.</string>
|
||||||
|
<string name="magic_create_text_fixed">Anda telah diundang ke%1$s. Username telah dipilihkan untuk Anda. Kami akan memandu Anda melalui proses pembuatan akun. \nAnda dapat berkomunikasi dengan pengguna provider lain dengan memberi mereka alamat XMPP lengkap Anda.</string>
|
||||||
|
<string name="your_server_invitation">Undangan server Anda</string>
|
||||||
|
<string name="improperly_formatted_provisioning">Kode provisioning tidak diformat dengan benar</string>
|
||||||
|
<string name="tap_share_button_send_invite">Klik tombol bagikan untuk mengirim undangan ke kontak Anda %1$s.</string>
|
||||||
|
<string name="if_contact_is_nearby_use_qr">Jika kontak Anda di dekat Anda, mereka juga dapat memindai kode di bawah ini untuk menerima undangan Anda</string>
|
||||||
|
<string name="easy_invite_share_text">Bergabung %1$s dan mengobrol dengan saya: %2$s</string>
|
||||||
|
<string name="share_invite_with">Bagikan undangan dengan...</string>
|
||||||
|
</resources>
|
18
app/src/conversations/res/values-it/strings.xml
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="pick_a_server">Scegli il tuo fornitore XMPP</string>
|
||||||
|
<string name="use_conversations.im">Usa conversations.im</string>
|
||||||
|
<string name="create_new_account">Crea un nuovo profilo</string>
|
||||||
|
<string name="do_you_have_an_account">Hai già un profilo XMPP\? Può accadere se stai già usando un client XMPP diverso o hai già usato prima Conversations. In caso negativo, puoi creare un profilo XMPP adesso.
|
||||||
|
\nNota: alcuni fornitori di email offrono anche account XMPP.</string>
|
||||||
|
<string name="server_select_text">XMPP è una rete di messaggistica istantanea indipendente dal fornitore. Puoi usare questo client con qualsiasi server XMPP.
|
||||||
|
\nTuttavia, per comodità, puoi creare facilmente un account su conversations.im; un fornitore pensato apposta per essere usato con Conversations.</string>
|
||||||
|
<string name="magic_create_text_on_x">Hai ricevuto un invito per %1$s. Ti guideremo nel procedimento per creare un profilo.\nQuando scegli %1$s come fornitore sarai in grado di comunicare con utenti di altri fornitori dando loro l\'indirizzo XMPP completo.</string>
|
||||||
|
<string name="magic_create_text_fixed">Hai ricevuto un invito per %1$s. È già stato scelto un nome utente per te. Ti guideremo nel procedimento per creare un profilo.\nSarai in grado di comunicare con utenti di altri fornitori dando loro l\'indirizzo XMPP completo.</string>
|
||||||
|
<string name="your_server_invitation">Il tuo invito al server</string>
|
||||||
|
<string name="improperly_formatted_provisioning">Codice di approvvigionamento formattato male</string>
|
||||||
|
<string name="tap_share_button_send_invite">Tocca il pulsante condividi per inviare al contatto un invito per %1$s.</string>
|
||||||
|
<string name="if_contact_is_nearby_use_qr">Se il contatto è vicino, può anche scansionare il codice sottostante per accettare il tuo invito.</string>
|
||||||
|
<string name="easy_invite_share_text">Unisciti a %1$s e chatta con me: %2$s</string>
|
||||||
|
<string name="share_invite_with">Condividi invito con…</string>
|
||||||
|
</resources>
|
16
app/src/conversations/res/values-ja/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 メールのプロバイダーが XMPP アカウントも提供している場合があります。</string>
|
||||||
|
<string name="server_select_text">XMPP は、プロバイダーに依存しないインスタントメッセージのプロトコルです。 XMPP サーバーならどこでも、このクライアントを使用することができます。\nよろしければ、 Conversations に最適化されたプロバイダー 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>
|
||||||
|
<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>
|
14
app/src/conversations/res/values-nl/strings.xml
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="pick_a_server">Kies je XMPP-dienst</string>
|
||||||
|
<string name="use_conversations.im">Conversations.im gebruiken</string>
|
||||||
|
<string name="create_new_account">Nieuwe account registreren</string>
|
||||||
|
<string name="do_you_have_an_account">Heb je al een XMPP-account? Als je al een andere XMPP-cliënt gebruikt, of Conversations vroeger al eens hebt gebruikt, is dit waarschijnlijk het geval. Zo niet, kan je nu een nieuwe XMPP-account aanmaken.\nTip: sommige e-mailproviders bieden ook XMPP-accounts aan.</string>
|
||||||
|
<string name="server_select_text">XMPP is een provider-onafhankelijk berichtennetwerk. Je kan deze cliënt gebruiken met eender welke XMPP-server.\nOm het je gemakkelijker te maken kun je simpelweg een account aanmaken op conversations.im; een provider speciaal geschikt voor Conversations.</string>
|
||||||
|
<string name="magic_create_text_on_x">Je ontving een uitnodiging voor %1$s. We zullen je helpen een account aan te maken.\nWanneer je %1$s als je provider kiest kan je met gebruikers van andere providers communiceren door hen je volledige XMPP-adres te geven.</string>
|
||||||
|
<string name="magic_create_text_fixed">Je ontving een uitnodiging voor %1$s. Er werd reeds een gebruikersnaam voor jou gekozen. We zullen je helpen een account aan te maken.\nJe zal met gebruikers van andere providers communiceren door hen je volledige XMPP-adres te geven.</string>
|
||||||
|
<string name="your_server_invitation">Je server uitnodiging</string>
|
||||||
|
<string name="tap_share_button_send_invite">Tik op de delen knop om een uitnodiging te versturen naar %1$s</string>
|
||||||
|
<string name="if_contact_is_nearby_use_qr">Als je contactpersoon in de buurt is, kan deze ook onderstaande code scannen om de uitnodiging te aanvaarden.</string>
|
||||||
|
<string name="share_invite_with">Deel de uitnodiging met ...</string>
|
||||||
|
</resources>
|
16
app/src/conversations/res/values-pl/strings.xml
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="pick_a_server">Wybierz dostawcę XMPP</string>
|
||||||
|
<string name="use_conversations.im">Użyj conversations.im</string>
|
||||||
|
<string name="create_new_account">Utwórz nowe konto</string>
|
||||||
|
<string name="do_you_have_an_account">Czy masz już konto XMPP? Tak może być jeśli używasz już innego klienta XMPP lub używałeś już Conversations. Jeśli nie możesz stworzyć nowe konto XMPP teraz.\nPodpowiedź: Niektórzy dostawcy poczty oferują również konta XMPP.</string>
|
||||||
|
<string name="server_select_text">XMPP to niezależna od dostawcy sieć komunikacji błyskawicznej. Możesz użyć tego klienta z dowolnym serwerem XMPP.\nDla twojej wygody jednak ułatwiliśmy stworzenie konta na conversations.im; dostawcy specjalnie dostosowanego do pracy z Conversations.</string>
|
||||||
|
<string name="magic_create_text_on_x">Zostałeś zaproszony do %1$s. Poprowadzimy ciebie przez proces tworzenia konta.\nWybierając %1$s jako dostawcę będziesz mógł komunikować się z innymi użytkownikami podając swój pełny adres XMPP.</string>
|
||||||
|
<string name="magic_create_text_fixed">Zostałeś zaproszony do %1$s. Nazwa użytkownika została już dla ciebie wybrana. Poprowadzimy ciebie przez proces tworzenia konta.\nBęziesz mógł komunikować się z innymi użytkownikami podając swój adres XMPP.</string>
|
||||||
|
<string name="your_server_invitation">Zaproszenie twojego serwera</string>
|
||||||
|
<string name="improperly_formatted_provisioning">Niepoprawnie sformatowany kod zaopatrywania</string>
|
||||||
|
<string name="tap_share_button_send_invite">Użyj przycisku udostępniania aby wysłać swojemu kontaktowi zaproszenie do %1$s.</string>
|
||||||
|
<string name="if_contact_is_nearby_use_qr">Jeśli twój kontakt jest blisko może przeskanować kod poniżej aby zaakceptować twoje zaproszenie.</string>
|
||||||
|
<string name="easy_invite_share_text">Dołącz do %1$s aby porozmawiać ze mną: %2$s</string>
|
||||||
|
<string name="share_invite_with">Udostępnij zaproszenie…</string>
|
||||||
|
</resources>
|
16
app/src/conversations/res/values-pt-rBR/strings.xml
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="pick_a_server">Selecione o seu provedor XMPP</string>
|
||||||
|
<string name="use_conversations.im">Usar o conversations.im</string>
|
||||||
|
<string name="create_new_account">Criar uma nova conta</string>
|
||||||
|
<string name="do_you_have_an_account">Você já possui uma conta XMPP? Esse pode ser o seu caso caso já esteja usando um outro cliente XMPP ou tenha usado o Conversations antes. Caso contrário, você pode criar uma nova conta XMPP agora.\nDica: alguns provedores de e-mail também fornecem contas XMPP.</string>
|
||||||
|
<string name="server_select_text">O XMPP é uma rede de mensageria instantânea independente de provedor. Você pode usar esse cliente com qualquer servidor XMPP que você escolher.\nEntretanto, para sua conveniência, nós simplificamos o processo de criação de uma conta em conversations.im, um provedor especialmente configurado para se usar com o Conversations.</string>
|
||||||
|
<string name="magic_create_text_on_x">Você foi convidado para %1$s. Nós iremos guiá-lo ao longo do processo de criação de uma conta.\nAo escolher %1$s como um provedor você conseguirá se comunicar com usuários de outros provedores dando a eles seu endereço XMPP completo.</string>
|
||||||
|
<string name="magic_create_text_fixed">Você foi convidado para %1$s. Um nome de usuário já foi escolhido para você. Nós iremos guiá-lo ao longo do processo de criação de uma conta.\nVocê conseguirá se comunicar com usuários de outros provedores dando a eles seu endereço XMPP completo.</string>
|
||||||
|
<string name="your_server_invitation">Seu convite do servidor</string>
|
||||||
|
<string name="improperly_formatted_provisioning">Código de provisionamento formatado de maneira imprópria</string>
|
||||||
|
<string name="tap_share_button_send_invite">Toque no botão compartilhar para enviar, para seu contato, um convite para %1$s.</string>
|
||||||
|
<string name="if_contact_is_nearby_use_qr">Se seu contato estiver por perto, ele também pode escanear o código abaixo para aceitar seu convite.</string>
|
||||||
|
<string name="easy_invite_share_text">Junte-se a %1$s e converse comigo: %2$s</string>
|
||||||
|
<string name="share_invite_with">Compartilhe o convite com...</string>
|
||||||
|
</resources>
|
2
app/src/conversations/res/values-pt/strings.xml
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources></resources>
|
16
app/src/conversations/res/values-ro-rRO/strings.xml
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="pick_a_server">Alegeți-vă furnizorul XMPP</string>
|
||||||
|
<string name="use_conversations.im">Folosește conversations.im</string>
|
||||||
|
<string name="create_new_account">Creează un cont nou</string>
|
||||||
|
<string name="do_you_have_an_account">Aveți deja un cont XMPP? S-ar putea să fie așa dacă deja utilizați un alt client XMPP sau dacă ați folosit Conversations în trecut. Dacă nu, puteți crea un cont nou XMPP chiar acum.\nIdee: Unii furnizori de e-mail oferă de asemenea și conturi XMPP.</string>
|
||||||
|
<string name="server_select_text">XMPP este o rețea de mesagerie instant ce nu depinde de un anumit furnizor. Aveți posibilitatea să utilizați acest client cu orice server XMPP doriți.\nTotuși, pentru confortul dumneavoastră, am facilitat crearea unui cont pe conversations.im; un furnizor potrivit pentru utilizarea cu aplicația Conversations.</string>
|
||||||
|
<string name="magic_create_text_on_x">Ați fost invitați la %1$s. Vă vom ghida prin procesul de creare al unui cont.\nCând alegeți %1$s ca furnizor veți putea comunica cu utilizatorii altor furnizori oferindu-le adresa dumneavoastră completă XMPP.</string>
|
||||||
|
<string name="magic_create_text_fixed">Ați fost invitați la %1$s. Un nume de utilizator a fost deja ales pentru dumneavoastră. Vă vom ghida prin procesul de creare al unui cont.\nVeți putea comunica cu utilizatorii altor furnizori oferindu-le adresa dumneavoastră completă XMPP.</string>
|
||||||
|
<string name="your_server_invitation">Invitația serverului dumneavoastră</string>
|
||||||
|
<string name="improperly_formatted_provisioning">Cod de acces formatat necorespunzător</string>
|
||||||
|
<string name="tap_share_button_send_invite">Atingeți butonul de partajare pentru a trimite contactului o invitație la %1$s.</string>
|
||||||
|
<string name="if_contact_is_nearby_use_qr">Dacă e în apropiere, contactul poate scana codul de mai jos pentru a vă accepta invitația.</string>
|
||||||
|
<string name="easy_invite_share_text">Alătură-te %1$s și discută cu mine: %2$s</string>
|
||||||
|
<string name="share_invite_with">Partajează invitația cu…</string>
|
||||||
|
</resources>
|
19
app/src/conversations/res/values-ru/strings.xml
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
<?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\? Если вы использовали Conversations или другой XMPP-клиент в прошлом, то скорее всего, он у вас есть. Если у вас нет аккаунта, вы можете создать его прямо сейчас.
|
||||||
|
\nПодсказка: Некоторые провайдеры электронной почты также регистрируют аккаунты XMPP.</string>
|
||||||
|
<string name="server_select_text">XMPP - это независимая сеть обмена сообщениями. Conversations позволяет вам подключиться к любому 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>
|
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>
|
20
app/src/conversations/res/values-sq/strings.xml
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="server_select_text">XMPP është një rrjet shkëmbimi mesazhesh të atypëratyshëm i pavarur nga shërbimet. Këtë klient mund ta përdorni me cilindo shërbyes XMPP që zgjidhni.
|
||||||
|
\nMegjithatë, për lehtësi, e kemi bërë të kollajshme të krijohet një llogari te conversations.im, një shërbim posaçërisht i përshtatshëm për përdorim me Conversations.</string>
|
||||||
|
<string name="magic_create_text_on_x">Jeni ftuar te %1$s. Do t’ju udhëheqim përmes procesit të krijimit të një llogarie.
|
||||||
|
\nKur zgjidhet %1$s si shërbim, do të jeni në gjendje të komunikoni me përdorues nga shërbime të tjera duke u dhënë adresën tuaj të plotë XMPP.</string>
|
||||||
|
<string name="magic_create_text_fixed">Jeni ftuar te %1$s. Për ju është zgjedhur tashmë një emër përdoruesi. Do t’ju udhëheqim përmes procesit të krijimit të një llogarie.
|
||||||
|
\nDo të jeni në gjendje të komunikoni me përdorues nga shërbime të tjera duke u dhënë adresën tuaj të plotë XMPP.</string>
|
||||||
|
<string name="tap_share_button_send_invite">Prekni butonin e ndarjes me të tjerë që t’i dërgoni kontaktit tuaj një ftesë për te %1$s.</string>
|
||||||
|
<string name="if_contact_is_nearby_use_qr">Nëse kontakti juaj është atypari, mund të skanojë gjithashtu kodin më poshtë, që të pranojë ftesën tuaj.</string>
|
||||||
|
<string name="easy_invite_share_text">Bëhuni pjesë e %1$s dhe bisedoni me: %2$s</string>
|
||||||
|
<string name="share_invite_with">Ndajeni ftesën me…</string>
|
||||||
|
<string name="create_new_account">Krijoni llogari të re</string>
|
||||||
|
<string name="pick_a_server">Zgjidhni shërbimin tuaj XMPP</string>
|
||||||
|
<string name="use_conversations.im">Përdor conversations.im</string>
|
||||||
|
<string name="do_you_have_an_account">Keni tashmë një llogari XMPP\? Mund të jetë kështu nëse përdorni tashmë një klient tjetër XMPP, ose e keni përdorur Conversations më parë. Nëse jo, mund të krijoni një llogari të re XMPP që tani.
|
||||||
|
\nNdihmëz: Disa shërbime email-i ofrojnë gjithashtu llogari XMPP.</string>
|
||||||
|
<string name="your_server_invitation">Ftesë nga shërbyesi juaj</string>
|
||||||
|
<string name="improperly_formatted_provisioning">Kod i formatuar jo saktësisht</string>
|
||||||
|
</resources>
|
9
app/src/conversations/res/values-sr/strings.xml
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="pick_a_server">Одаберите вашег ИксМПП провајдера</string>
|
||||||
|
<string name="use_conversations.im">Користи conversations.im</string>
|
||||||
|
<string name="create_new_account">Направи нови налог</string>
|
||||||
|
<string name="do_you_have_an_account">Да ли већ имате ИксМПП налог? Извесно је да га имате ако користите неки ИксМПП клијент или сте раније користили Конверзацију. Ако немате, сада можете направити нови ИксМПП налог.\nСавет: неки поштански провајдери такође омогућавају и ИксМПП налоге.</string>
|
||||||
|
<string name="server_select_text">ИксМПП је мрежа брзих порука, независна од провајдера. Овај клијент можете користити уз било који сервер по вашем избору.\nДа бисмо вам олакшали, омогућили смо креирање налога на conversations.im; провајдеру специјално прилаг.ођеном за коришћење уз Конверзацију</string>
|
||||||
|
<string name="your_server_invitation">Ваша серверска позивница</string>
|
||||||
|
</resources>
|
19
app/src/conversations/res/values-sv/strings.xml
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="pick_a_server">Välj din XMPP-leverantör</string>
|
||||||
|
<string name="use_conversations.im">Använd conversations.im</string>
|
||||||
|
<string name="create_new_account">Skapa ett nytt konto</string>
|
||||||
|
<string name="do_you_have_an_account">Har du redan ett XMPP-konto? Detta kan vara fallet om du redan använder en annan XMPP-klient eller om du har använt Conversations tidigare. Om inte, kan du skapa ett nytt XMPP-konto på en gång.\nTips: Vissa e-postleverantörer tillhandahåller även XMPP-konton.</string>
|
||||||
|
<string name="your_server_invitation">Din serverinbjudan</string>
|
||||||
|
<string name="improperly_formatted_provisioning">Felaktigt formaterad provisioneringskod</string>
|
||||||
|
<string name="tap_share_button_send_invite">Tryck på dela-knappen för att skicka en inbjudan till din kontakt till %1$s.</string>
|
||||||
|
<string name="if_contact_is_nearby_use_qr">Om din kontakt är i närheten, kan de också skanna koden nedan för att acceptera din inbjudan.</string>
|
||||||
|
<string name="easy_invite_share_text">Gå med %1$s och chatta med mig: %2$s</string>
|
||||||
|
<string name="share_invite_with">Dela inbjudan med…</string>
|
||||||
|
<string name="magic_create_text_fixed">Du har blivit inbjuden till %1$s. Ett användarnamn har redan valts åt dig. Vi guidar dig genom processen för att skapa ett konto.
|
||||||
|
\nDu kommer att kunna kommunicera med användare av andra leverantörer genom att ge dem din fullständiga XMPP-adress.</string>
|
||||||
|
<string name="server_select_text">XMPP är ett leverantörsoberoende snabbmeddelandenätverk. Du kan använda den här klienten med vilken XMPP-server du än väljer.
|
||||||
|
\nMen för din bekvämlighet har vi gjort det enkelt att skapa ett konto på conversations.im; en leverantör som är speciellt lämpad för användning med Conversations.</string>
|
||||||
|
<string name="magic_create_text_on_x">Du har blivit inbjuden till %1$s. Vi guidar dig genom processen för att skapa ett konto.
|
||||||
|
\nNär du väljer %1$s som leverantör kommer du att kunna kommunicera med användare av andra leverantörer genom att ge dem din fullständiga XMPP-adress.</string>
|
||||||
|
</resources>
|
16
app/src/conversations/res/values-szl/strings.xml
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="pick_a_server">Wybier liferanta XMPP</string>
|
||||||
|
<string name="use_conversations.im">Użyj conversations.im</string>
|
||||||
|
<string name="create_new_account">Stwōrz nowe kōnto</string>
|
||||||
|
<string name="do_you_have_an_account">Mosz już kōnto XMPP? Tak może być, jeźli już używosz inkszego klijynta XMPP aboś używoł abo używała wcześnij Conversations. Jak niy, to możesz stworzić teroz nowe kōnto XMPP.\nDorada: Niykerzi liferańcio emaili dowajōm tyż kōnta XMPP.</string>
|
||||||
|
<string name="server_select_text">XMPP to je nec wartkich wiadōmości niyzależny ôd liferanta. Możesz używać tego klijynta ze serwerym XMPP, jaki sie wybieresz.\nAle dlo twojij wygody ułacniyli my tworzynie kōnt na conversations.im; liferańcie ekstra dopasowanym do używanio ze Conversations.</string>
|
||||||
|
<string name="magic_create_text_on_x">Mosz zaproszynie na %1$s. Pokludzymy cie bez proces tworzynio kōnta.\nPo wybraniu %1$s za liferanta, poradzisz kōmunikować sie ze używoczami ôd inkszych liferantōw bez danie im swojij połnyj adresy XMPP.</string>
|
||||||
|
<string name="magic_create_text_fixed">Mosz zaproszynie na %1$s. Miano ôd używocza już je do ciebie wybrane. Pokludzymy cie bez proces tworzynio kōnta.\nBydzie szło kōmunikować sie ze używoczami ôd inkszych liferantōw bez danie im swojij połnyj adresy XMPP.</string>
|
||||||
|
<string name="your_server_invitation">Twoje zaproszynie na serwer</string>
|
||||||
|
<string name="improperly_formatted_provisioning">Niynoleżnie sformatowany kod lifrowanio</string>
|
||||||
|
<string name="tap_share_button_send_invite">Tyknij knefla dzielynio sie, żeby posłać kōntaktowi zaproszynie na %1$s.</string>
|
||||||
|
<string name="if_contact_is_nearby_use_qr">Jeźli kōntakt je blisko, to może tyż zeskanować kod niżyj, żeby zaakceptować twoje zaproszynie.</string>
|
||||||
|
<string name="easy_invite_share_text">Pōdź na %1$s i pogodej zy mnōm: %2$s</string>
|
||||||
|
<string name="share_invite_with">Poślij zaproszynie do…</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>
|
16
app/src/conversations/res/values/strings.xml
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="pick_a_server">Pick your XMPP provider</string>
|
||||||
|
<string name="use_conversations.im">Use conversations.im</string>
|
||||||
|
<string name="create_new_account">Create new account</string>
|
||||||
|
<string name="do_you_have_an_account">Do you already have an XMPP account? This might be the case if you are already using a different XMPP client or have used Conversations before. If not you can create a new XMPP account right now.\nHint: Some email providers also provide XMPP accounts.</string>
|
||||||
|
<string name="server_select_text">XMPP is a provider independent instant messaging network. You can use this client with what ever XMPP server you choose.\nHowever for your convenience we made it easy to create an account on conversations.im; a provider specially suited for the use with Conversations.</string>
|
||||||
|
<string name="magic_create_text_on_x">You have been invited to %1$s. We will guide you through the process of creating an account.\nWhen picking %1$s as a provider you will be able to communicate with users of other providers by giving them your full XMPP address.</string>
|
||||||
|
<string name="magic_create_text_fixed">You have been invited to %1$s. A username has already been picked for you. We will guide you through the process of creating an account.\nYou will be able to communicate with users of other providers by giving them your full XMPP address.</string>
|
||||||
|
<string name="your_server_invitation">Your server invitation</string>
|
||||||
|
<string name="improperly_formatted_provisioning">Improperly formatted provisioning code</string>
|
||||||
|
<string name="tap_share_button_send_invite">Tap the share button to send your contact an invitation to %1$s.</string>
|
||||||
|
<string name="if_contact_is_nearby_use_qr">If your contact is nearby, they can also scan the code below to accept your invitation.</string>
|
||||||
|
<string name="easy_invite_share_text">Join %1$s and chat with me: %2$s</string>
|
||||||
|
<string name="share_invite_with">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.Handler;
|
||||||
import android.os.Looper;
|
import android.os.Looper;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
import androidx.core.app.ActivityCompat;
|
import androidx.core.app.ActivityCompat;
|
||||||
|
|
||||||
import com.google.common.collect.ImmutableList;
|
import com.google.common.collect.ImmutableList;
|
||||||
|
|
||||||
import org.webrtc.ThreadUtils;
|
|
||||||
|
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import eu.siacs.conversations.Config;
|
import eu.siacs.conversations.Config;
|
||||||
import eu.siacs.conversations.utils.AppRTCUtils;
|
import eu.siacs.conversations.utils.AppRTCUtils;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import org.webrtc.ThreadUtils;
|
||||||
|
|
||||||
/** AppRTCProximitySensor manages functions related to Bluetoth devices in the AppRTC demo. */
|
/** AppRTCProximitySensor manages functions related to Bluetoth devices in the AppRTC demo. */
|
||||||
public class AppRTCBluetoothManager {
|
public class AppRTCBluetoothManager {
|
|
@ -16,22 +16,17 @@ import android.hardware.SensorEventListener;
|
||||||
import android.hardware.SensorManager;
|
import android.hardware.SensorManager;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
import org.webrtc.ThreadUtils;
|
|
||||||
|
|
||||||
import eu.siacs.conversations.Config;
|
import eu.siacs.conversations.Config;
|
||||||
import eu.siacs.conversations.utils.AppRTCUtils;
|
import eu.siacs.conversations.utils.AppRTCUtils;
|
||||||
|
import org.webrtc.ThreadUtils;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AppRTCProximitySensor manages functions related to the proximity sensor in
|
* AppRTCProximitySensor manages functions related to the proximity sensor in the AppRTC demo. On
|
||||||
* the AppRTC demo.
|
* most device, the proximity sensor is implemented as a boolean-sensor. It returns just two values
|
||||||
* On most device, the proximity sensor is implemented as a boolean-sensor.
|
* "NEAR" or "FAR". Thresholding is done on the LUX value i.e. the LUX value of the light sensor is
|
||||||
* It returns just two values "NEAR" or "FAR". Thresholding is done on the LUX
|
* compared with a threshold. A LUX-value more than the threshold means the proximity sensor returns
|
||||||
* value i.e. the LUX value of the light sensor is compared with a threshold.
|
* "FAR". Anything less than the threshold value and the sensor returns "NEAR".
|
||||||
* A LUX-value more than the threshold means the proximity sensor returns "FAR".
|
|
||||||
* Anything less than the threshold value and the sensor returns "NEAR".
|
|
||||||
*/
|
*/
|
||||||
public class AppRTCProximitySensor implements SensorEventListener {
|
public class AppRTCProximitySensor implements SensorEventListener {
|
||||||
// This class should be created, started and stopped on one thread
|
// This class should be created, started and stopped on one thread
|
||||||
|
@ -40,8 +35,7 @@ public class AppRTCProximitySensor implements SensorEventListener {
|
||||||
private final ThreadUtils.ThreadChecker threadChecker = new ThreadUtils.ThreadChecker();
|
private final ThreadUtils.ThreadChecker threadChecker = new ThreadUtils.ThreadChecker();
|
||||||
private final Runnable onSensorStateListener;
|
private final Runnable onSensorStateListener;
|
||||||
private final SensorManager sensorManager;
|
private final SensorManager sensorManager;
|
||||||
@Nullable
|
@Nullable private Sensor proximitySensor;
|
||||||
private Sensor proximitySensor;
|
|
||||||
private boolean lastStateReportIsNear;
|
private boolean lastStateReportIsNear;
|
||||||
|
|
||||||
private AppRTCProximitySensor(Context context, Runnable sensorStateListener) {
|
private AppRTCProximitySensor(Context context, Runnable sensorStateListener) {
|
||||||
|
@ -50,17 +44,12 @@ public class AppRTCProximitySensor implements SensorEventListener {
|
||||||
sensorManager = ((SensorManager) context.getSystemService(Context.SENSOR_SERVICE));
|
sensorManager = ((SensorManager) context.getSystemService(Context.SENSOR_SERVICE));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Construction */
|
||||||
* Construction
|
|
||||||
*/
|
|
||||||
static AppRTCProximitySensor create(Context context, Runnable sensorStateListener) {
|
static AppRTCProximitySensor create(Context context, Runnable sensorStateListener) {
|
||||||
return new AppRTCProximitySensor(context, sensorStateListener);
|
return new AppRTCProximitySensor(context, sensorStateListener);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Activate the proximity sensor. Also do initialization if called for the first time. */
|
||||||
* Activate the proximity sensor. Also do initialization if called for the
|
|
||||||
* first time.
|
|
||||||
*/
|
|
||||||
public boolean start() {
|
public boolean start() {
|
||||||
threadChecker.checkIsOnValidThread();
|
threadChecker.checkIsOnValidThread();
|
||||||
Log.d(Config.LOGTAG, "start" + AppRTCUtils.getThreadInfo());
|
Log.d(Config.LOGTAG, "start" + AppRTCUtils.getThreadInfo());
|
||||||
|
@ -72,9 +61,7 @@ public class AppRTCProximitySensor implements SensorEventListener {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Deactivate the proximity sensor. */
|
||||||
* Deactivate the proximity sensor.
|
|
||||||
*/
|
|
||||||
public void stop() {
|
public void stop() {
|
||||||
threadChecker.checkIsOnValidThread();
|
threadChecker.checkIsOnValidThread();
|
||||||
Log.d(Config.LOGTAG, "stop" + AppRTCUtils.getThreadInfo());
|
Log.d(Config.LOGTAG, "stop" + AppRTCUtils.getThreadInfo());
|
||||||
|
@ -84,9 +71,7 @@ public class AppRTCProximitySensor implements SensorEventListener {
|
||||||
sensorManager.unregisterListener(this, proximitySensor);
|
sensorManager.unregisterListener(this, proximitySensor);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Getter for last reported state. Set to true if "near" is reported. */
|
||||||
* Getter for last reported state. Set to true if "near" is reported.
|
|
||||||
*/
|
|
||||||
public boolean sensorReportsNearState() {
|
public boolean sensorReportsNearState() {
|
||||||
threadChecker.checkIsOnValidThread();
|
threadChecker.checkIsOnValidThread();
|
||||||
return lastStateReportIsNear;
|
return lastStateReportIsNear;
|
||||||
|
@ -120,15 +105,22 @@ public class AppRTCProximitySensor implements SensorEventListener {
|
||||||
if (onSensorStateListener != null) {
|
if (onSensorStateListener != null) {
|
||||||
onSensorStateListener.run();
|
onSensorStateListener.run();
|
||||||
}
|
}
|
||||||
Log.d(Config.LOGTAG, "onSensorChanged" + AppRTCUtils.getThreadInfo() + ": "
|
Log.d(
|
||||||
+ "accuracy=" + event.accuracy + ", timestamp=" + event.timestamp + ", distance="
|
Config.LOGTAG,
|
||||||
|
"onSensorChanged"
|
||||||
|
+ AppRTCUtils.getThreadInfo()
|
||||||
|
+ ": "
|
||||||
|
+ "accuracy="
|
||||||
|
+ event.accuracy
|
||||||
|
+ ", timestamp="
|
||||||
|
+ event.timestamp
|
||||||
|
+ ", distance="
|
||||||
+ event.values[0]);
|
+ event.values[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get default proximity sensor if it exists. Tablet devices (e.g. Nexus 7)
|
* Get default proximity sensor if it exists. Tablet devices (e.g. Nexus 7) does not support
|
||||||
* does not support this type of sensor and false will be returned in such
|
* this type of sensor and false will be returned in such cases.
|
||||||
* cases.
|
|
||||||
*/
|
*/
|
||||||
private boolean initDefaultSensor() {
|
private boolean initDefaultSensor() {
|
||||||
if (proximitySensor != null) {
|
if (proximitySensor != null) {
|
||||||
|
@ -142,9 +134,7 @@ public class AppRTCProximitySensor implements SensorEventListener {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Helper method for logging information about the proximity sensor. */
|
||||||
* Helper method for logging information about the proximity sensor.
|
|
||||||
*/
|
|
||||||
private void logProximitySensorInfo() {
|
private void logProximitySensorInfo() {
|
||||||
if (proximitySensor == null) {
|
if (proximitySensor == null) {
|
||||||
return;
|
return;
|
|
@ -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.base.Objects;
|
||||||
import com.google.common.collect.Collections2;
|
import com.google.common.collect.Collections2;
|
||||||
import com.google.common.collect.ImmutableSet;
|
import com.google.common.collect.ImmutableSet;
|
||||||
|
|
||||||
import java.util.Set;
|
|
||||||
|
|
||||||
import eu.siacs.conversations.xmpp.jingle.stanzas.Content;
|
import eu.siacs.conversations.xmpp.jingle.stanzas.Content;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
public final class ContentAddition {
|
public final class ContentAddition {
|
||||||
|
|
|
@ -10,8 +10,7 @@ import java.util.ArrayList;
|
||||||
import java.util.Enumeration;
|
import java.util.Enumeration;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import org.jxmpp.jid.Jid;
|
||||||
import eu.siacs.conversations.xmpp.Jid;
|
|
||||||
|
|
||||||
public class DirectConnectionUtils {
|
public class DirectConnectionUtils {
|
||||||
|
|
||||||
|
@ -25,7 +24,8 @@ public class DirectConnectionUtils {
|
||||||
}
|
}
|
||||||
while (interfaces.hasMoreElements()) {
|
while (interfaces.hasMoreElements()) {
|
||||||
NetworkInterface networkInterface = interfaces.nextElement();
|
NetworkInterface networkInterface = interfaces.nextElement();
|
||||||
final Enumeration<InetAddress> inetAddressEnumeration = networkInterface.getInetAddresses();
|
final Enumeration<InetAddress> inetAddressEnumeration =
|
||||||
|
networkInterface.getInetAddresses();
|
||||||
while (inetAddressEnumeration.hasMoreElements()) {
|
while (inetAddressEnumeration.hasMoreElements()) {
|
||||||
final InetAddress inetAddress = inetAddressEnumeration.nextElement();
|
final InetAddress inetAddress = inetAddressEnumeration.nextElement();
|
||||||
if (inetAddress.isLoopbackAddress() || inetAddress.isLinkLocalAddress()) {
|
if (inetAddress.isLoopbackAddress() || inetAddress.isLinkLocalAddress()) {
|
||||||
|
@ -50,7 +50,8 @@ public class DirectConnectionUtils {
|
||||||
SecureRandom random = new SecureRandom();
|
SecureRandom random = new SecureRandom();
|
||||||
ArrayList<JingleCandidate> candidates = new ArrayList<>();
|
ArrayList<JingleCandidate> candidates = new ArrayList<>();
|
||||||
for (InetAddress inetAddress : getLocalAddresses()) {
|
for (InetAddress inetAddress : getLocalAddresses()) {
|
||||||
final JingleCandidate candidate = new JingleCandidate(UUID.randomUUID().toString(), true);
|
final JingleCandidate candidate =
|
||||||
|
new JingleCandidate(UUID.randomUUID().toString(), true);
|
||||||
candidate.setHost(inetAddress.getHostAddress());
|
candidate.setHost(inetAddress.getHostAddress());
|
||||||
candidate.setPort(random.nextInt(60000) + 1024);
|
candidate.setPort(random.nextInt(60000) + 1024);
|
||||||
candidate.setType(JingleCandidate.TYPE_DIRECT);
|
candidate.setType(JingleCandidate.TYPE_DIRECT);
|
||||||
|
@ -60,5 +61,4 @@ public class DirectConnectionUtils {
|
||||||
}
|
}
|
||||||
return candidates;
|
return candidates;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
|
@ -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;
|
package eu.siacs.conversations.xmpp.jingle;
|
||||||
|
|
||||||
import com.google.common.collect.ImmutableSet;
|
import com.google.common.collect.ImmutableSet;
|
||||||
|
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
import javax.annotation.Nonnull;
|
import javax.annotation.Nonnull;
|
||||||
|
|
||||||
public enum Media {
|
public enum Media {
|
||||||
|
VIDEO,
|
||||||
VIDEO, AUDIO, UNKNOWN;
|
AUDIO,
|
||||||
|
UNKNOWN;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Nonnull
|
@Nonnull
|
|
@ -1,7 +1,6 @@
|
||||||
package eu.siacs.conversations.xmpp.jingle;
|
package eu.siacs.conversations.xmpp.jingle;
|
||||||
|
|
||||||
import com.google.common.collect.ArrayListMultimap;
|
import com.google.common.collect.ArrayListMultimap;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public class MediaBuilder {
|
public class MediaBuilder {
|
||||||
|
@ -43,6 +42,7 @@ public class MediaBuilder {
|
||||||
}
|
}
|
||||||
|
|
||||||
public SessionDescription.Media createMedia() {
|
public SessionDescription.Media createMedia() {
|
||||||
return new SessionDescription.Media(media, port, protocol, formats, connectionData, attributes);
|
return new SessionDescription.Media(
|
||||||
|
media, port, protocol, formats, connectionData, attributes);
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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.Collections2;
|
||||||
import com.google.common.collect.ImmutableList;
|
import com.google.common.collect.ImmutableList;
|
||||||
import com.google.common.collect.ImmutableMap;
|
import com.google.common.collect.ImmutableMap;
|
||||||
import com.google.common.collect.ImmutableMultimap;
|
|
||||||
import com.google.common.collect.ImmutableSet;
|
import com.google.common.collect.ImmutableSet;
|
||||||
import com.google.common.collect.Iterables;
|
import com.google.common.collect.Iterables;
|
||||||
import com.google.common.collect.Maps;
|
import com.google.common.collect.Maps;
|
||||||
import com.google.common.collect.Sets;
|
import com.google.common.collect.Sets;
|
||||||
|
|
||||||
import eu.siacs.conversations.xmpp.jingle.stanzas.Content;
|
import eu.siacs.conversations.xmpp.jingle.stanzas.Content;
|
||||||
import eu.siacs.conversations.xmpp.jingle.stanzas.GenericDescription;
|
import eu.siacs.conversations.xmpp.jingle.stanzas.GenericDescription;
|
||||||
import eu.siacs.conversations.xmpp.jingle.stanzas.GenericTransportInfo;
|
import eu.siacs.conversations.xmpp.jingle.stanzas.GenericTransportInfo;
|
||||||
|
@ -22,13 +20,11 @@ import eu.siacs.conversations.xmpp.jingle.stanzas.IceUdpTransportInfo;
|
||||||
import eu.siacs.conversations.xmpp.jingle.stanzas.JinglePacket;
|
import eu.siacs.conversations.xmpp.jingle.stanzas.JinglePacket;
|
||||||
import eu.siacs.conversations.xmpp.jingle.stanzas.OmemoVerifiedIceUdpTransportInfo;
|
import eu.siacs.conversations.xmpp.jingle.stanzas.OmemoVerifiedIceUdpTransportInfo;
|
||||||
import eu.siacs.conversations.xmpp.jingle.stanzas.RtpDescription;
|
import eu.siacs.conversations.xmpp.jingle.stanzas.RtpDescription;
|
||||||
|
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
import javax.annotation.Nonnull;
|
import javax.annotation.Nonnull;
|
||||||
|
|
||||||
public class RtpContentMap {
|
public class RtpContentMap {
|
||||||
|
@ -137,7 +133,8 @@ public class RtpContentMap {
|
||||||
if (setup == null) {
|
if (setup == null) {
|
||||||
throw new SecurityException(
|
throw new SecurityException(
|
||||||
String.format(
|
String.format(
|
||||||
"Use of DTLS-SRTP (XEP-0320) is required for content %s but missing setup attribute",
|
"Use of DTLS-SRTP (XEP-0320) is required for content %s but"
|
||||||
|
+ " missing setup attribute",
|
||||||
entry.getKey()));
|
entry.getKey()));
|
||||||
}
|
}
|
||||||
if (requireActPass && setup != IceUdpTransportInfo.Setup.ACTPASS) {
|
if (requireActPass && setup != IceUdpTransportInfo.Setup.ACTPASS) {
|
||||||
|
@ -197,24 +194,6 @@ public class RtpContentMap {
|
||||||
dt.senders, null, dt.transport.cloneWrapper())));
|
dt.senders, null, dt.transport.cloneWrapper())));
|
||||||
}
|
}
|
||||||
|
|
||||||
RtpContentMap withCandidates(
|
|
||||||
ImmutableMultimap<String, IceUdpTransportInfo.Candidate> candidates) {
|
|
||||||
final ImmutableMap.Builder<String, DescriptionTransport> contentBuilder =
|
|
||||||
new ImmutableMap.Builder<>();
|
|
||||||
for (final Map.Entry<String, DescriptionTransport> entry : this.contents.entrySet()) {
|
|
||||||
final String name = entry.getKey();
|
|
||||||
final DescriptionTransport descriptionTransport = entry.getValue();
|
|
||||||
final var transport = descriptionTransport.transport;
|
|
||||||
contentBuilder.put(
|
|
||||||
name,
|
|
||||||
new DescriptionTransport(
|
|
||||||
descriptionTransport.senders,
|
|
||||||
descriptionTransport.description,
|
|
||||||
transport.withCandidates(candidates.get(name))));
|
|
||||||
}
|
|
||||||
return new RtpContentMap(group, contentBuilder.build());
|
|
||||||
}
|
|
||||||
|
|
||||||
public IceUdpTransportInfo.Credentials getDistinctCredentials() {
|
public IceUdpTransportInfo.Credentials getDistinctCredentials() {
|
||||||
final Set<IceUdpTransportInfo.Credentials> allCredentials = getCredentials();
|
final Set<IceUdpTransportInfo.Credentials> allCredentials = getCredentials();
|
||||||
final IceUdpTransportInfo.Credentials credentials =
|
final IceUdpTransportInfo.Credentials credentials =
|
||||||
|
@ -229,12 +208,6 @@ public class RtpContentMap {
|
||||||
throw new IllegalStateException("Content map does not have distinct credentials");
|
throw new IllegalStateException("Content map does not have distinct credentials");
|
||||||
}
|
}
|
||||||
|
|
||||||
private Set<String> getCombinedIceOptions() {
|
|
||||||
final Collection<List<String>> combinedIceOptions =
|
|
||||||
Collections2.transform(contents.values(), dt -> dt.transport.getIceOptions());
|
|
||||||
return ImmutableSet.copyOf(Iterables.concat(combinedIceOptions));
|
|
||||||
}
|
|
||||||
|
|
||||||
public Set<IceUdpTransportInfo.Credentials> getCredentials() {
|
public Set<IceUdpTransportInfo.Credentials> getCredentials() {
|
||||||
final Set<IceUdpTransportInfo.Credentials> credentials =
|
final Set<IceUdpTransportInfo.Credentials> credentials =
|
||||||
ImmutableSet.copyOf(
|
ImmutableSet.copyOf(
|
||||||
|
@ -293,11 +266,6 @@ public class RtpContentMap {
|
||||||
return count == 0;
|
return count == 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean hasFullTransportInfo() {
|
|
||||||
return Collections2.transform(this.contents.values(), dt -> dt.transport.isStub())
|
|
||||||
.contains(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
public RtpContentMap modifiedCredentials(
|
public RtpContentMap modifiedCredentials(
|
||||||
IceUdpTransportInfo.Credentials credentials, final IceUdpTransportInfo.Setup setup) {
|
IceUdpTransportInfo.Credentials credentials, final IceUdpTransportInfo.Setup setup) {
|
||||||
final ImmutableMap.Builder<String, DescriptionTransport> contentMapBuilder =
|
final ImmutableMap.Builder<String, DescriptionTransport> contentMapBuilder =
|
||||||
|
@ -324,60 +292,14 @@ public class RtpContentMap {
|
||||||
dt -> new DescriptionTransport(senders, dt.description, dt.transport)));
|
dt -> new DescriptionTransport(senders, dt.description, dt.transport)));
|
||||||
}
|
}
|
||||||
|
|
||||||
public RtpContentMap modifiedSendersChecked(
|
|
||||||
final boolean isInitiator, final Map<String, Content.Senders> modification) {
|
|
||||||
final ImmutableMap.Builder<String, DescriptionTransport> contentMapBuilder =
|
|
||||||
new ImmutableMap.Builder<>();
|
|
||||||
for (final Map.Entry<String, DescriptionTransport> content : contents.entrySet()) {
|
|
||||||
final String id = content.getKey();
|
|
||||||
final DescriptionTransport descriptionTransport = content.getValue();
|
|
||||||
final Content.Senders currentSenders = descriptionTransport.senders;
|
|
||||||
final Content.Senders targetSenders = modification.get(id);
|
|
||||||
if (targetSenders == null || currentSenders == targetSenders) {
|
|
||||||
contentMapBuilder.put(id, descriptionTransport);
|
|
||||||
} else {
|
|
||||||
checkSenderModification(isInitiator, currentSenders, targetSenders);
|
|
||||||
contentMapBuilder.put(
|
|
||||||
id,
|
|
||||||
new DescriptionTransport(
|
|
||||||
targetSenders,
|
|
||||||
descriptionTransport.description,
|
|
||||||
descriptionTransport.transport));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return new RtpContentMap(this.group, contentMapBuilder.build());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void checkSenderModification(
|
|
||||||
final boolean isInitiator,
|
|
||||||
final Content.Senders current,
|
|
||||||
final Content.Senders target) {
|
|
||||||
if (isInitiator) {
|
|
||||||
// we were both sending and now other party only wants to receive
|
|
||||||
if (current == Content.Senders.BOTH && target == Content.Senders.INITIATOR) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// only we were sending but now other party wants to send too
|
|
||||||
if (current == Content.Senders.INITIATOR && target == Content.Senders.BOTH) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// we were both sending and now other party only wants to receive
|
|
||||||
if (current == Content.Senders.BOTH && target == Content.Senders.RESPONDER) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// only we were sending but now other party wants to send too
|
|
||||||
if (current == Content.Senders.RESPONDER && target == Content.Senders.BOTH) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new IllegalArgumentException(
|
|
||||||
String.format("Unsupported senders modification %s -> %s", current, target));
|
|
||||||
}
|
|
||||||
|
|
||||||
public RtpContentMap toContentModification(final Collection<String> modifications) {
|
public RtpContentMap toContentModification(final Collection<String> modifications) {
|
||||||
return new RtpContentMap(
|
return new RtpContentMap(
|
||||||
this.group, Maps.filterKeys(contents, Predicates.in(modifications)));
|
this.group,
|
||||||
|
Maps.transformValues(
|
||||||
|
Maps.filterKeys(contents, Predicates.in(modifications)),
|
||||||
|
dt ->
|
||||||
|
new DescriptionTransport(
|
||||||
|
dt.senders, dt.description, IceUdpTransportInfo.STUB)));
|
||||||
}
|
}
|
||||||
|
|
||||||
public RtpContentMap toStub() {
|
public RtpContentMap toStub() {
|
||||||
|
@ -414,48 +336,28 @@ public class RtpContentMap {
|
||||||
}
|
}
|
||||||
|
|
||||||
public RtpContentMap addContent(
|
public RtpContentMap addContent(
|
||||||
final RtpContentMap modification, final IceUdpTransportInfo.Setup setupOverwrite) {
|
final RtpContentMap modification, final IceUdpTransportInfo.Setup setup) {
|
||||||
|
final IceUdpTransportInfo.Credentials credentials = getDistinctCredentials();
|
||||||
|
final DTLS dtls = getDistinctDtls();
|
||||||
|
final IceUdpTransportInfo iceUdpTransportInfo =
|
||||||
|
IceUdpTransportInfo.of(credentials, setup, dtls.hash, dtls.fingerprint);
|
||||||
final Map<String, DescriptionTransport> combined = merge(contents, modification.contents);
|
final Map<String, DescriptionTransport> combined = merge(contents, modification.contents);
|
||||||
|
/*new ImmutableMap.Builder<String, DescriptionTransport>()
|
||||||
|
.putAll(contents)
|
||||||
|
.putAll(modification.contents)
|
||||||
|
.build();*/
|
||||||
final Map<String, DescriptionTransport> combinedFixedTransport =
|
final Map<String, DescriptionTransport> combinedFixedTransport =
|
||||||
Maps.transformValues(
|
Maps.transformValues(
|
||||||
combined,
|
combined,
|
||||||
dt -> {
|
dt ->
|
||||||
final IceUdpTransportInfo iceUdpTransportInfo;
|
new DescriptionTransport(
|
||||||
if (dt.transport.isStub()) {
|
dt.senders, dt.description, iceUdpTransportInfo));
|
||||||
final IceUdpTransportInfo.Credentials credentials =
|
return new RtpContentMap(modification.group, combinedFixedTransport);
|
||||||
getDistinctCredentials();
|
|
||||||
final Collection<String> iceOptions = getCombinedIceOptions();
|
|
||||||
final DTLS dtls = getDistinctDtls();
|
|
||||||
iceUdpTransportInfo =
|
|
||||||
IceUdpTransportInfo.of(
|
|
||||||
credentials,
|
|
||||||
iceOptions,
|
|
||||||
setupOverwrite,
|
|
||||||
dtls.hash,
|
|
||||||
dtls.fingerprint);
|
|
||||||
} else {
|
|
||||||
final IceUdpTransportInfo.Fingerprint fp =
|
|
||||||
dt.transport.getFingerprint();
|
|
||||||
final IceUdpTransportInfo.Setup setup = fp.getSetup();
|
|
||||||
iceUdpTransportInfo =
|
|
||||||
IceUdpTransportInfo.of(
|
|
||||||
dt.transport.getCredentials(),
|
|
||||||
dt.transport.getIceOptions(),
|
|
||||||
setup == IceUdpTransportInfo.Setup.ACTPASS
|
|
||||||
? setupOverwrite
|
|
||||||
: setup,
|
|
||||||
fp.getHash(),
|
|
||||||
fp.getContent());
|
|
||||||
}
|
|
||||||
return new DescriptionTransport(
|
|
||||||
dt.senders, dt.description, iceUdpTransportInfo);
|
|
||||||
});
|
|
||||||
return new RtpContentMap(modification.group, ImmutableMap.copyOf(combinedFixedTransport));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Map<String, DescriptionTransport> merge(
|
private static Map<String, DescriptionTransport> merge(
|
||||||
final Map<String, DescriptionTransport> a, final Map<String, DescriptionTransport> b) {
|
final Map<String, DescriptionTransport> a, final Map<String, DescriptionTransport> b) {
|
||||||
final Map<String, DescriptionTransport> combined = new LinkedHashMap<>();
|
final Map<String, DescriptionTransport> combined = new HashMap<>();
|
||||||
combined.putAll(a);
|
combined.putAll(a);
|
||||||
combined.putAll(b);
|
combined.putAll(b);
|
||||||
return ImmutableMap.copyOf(combined);
|
return ImmutableMap.copyOf(combined);
|
|
@ -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.Log;
|
||||||
import android.util.Pair;
|
import android.util.Pair;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
|
|
||||||
import com.google.common.base.CharMatcher;
|
import com.google.common.base.CharMatcher;
|
||||||
import com.google.common.base.Joiner;
|
import com.google.common.base.Joiner;
|
||||||
import com.google.common.base.Strings;
|
import com.google.common.base.Strings;
|
||||||
import com.google.common.collect.ArrayListMultimap;
|
import com.google.common.collect.ArrayListMultimap;
|
||||||
import com.google.common.collect.ImmutableList;
|
import com.google.common.collect.ImmutableList;
|
||||||
|
|
||||||
import eu.siacs.conversations.Config;
|
import eu.siacs.conversations.Config;
|
||||||
import eu.siacs.conversations.xml.Namespace;
|
|
||||||
import eu.siacs.conversations.xmpp.jingle.stanzas.Group;
|
import eu.siacs.conversations.xmpp.jingle.stanzas.Group;
|
||||||
import eu.siacs.conversations.xmpp.jingle.stanzas.IceUdpTransportInfo;
|
import eu.siacs.conversations.xmpp.jingle.stanzas.IceUdpTransportInfo;
|
||||||
import eu.siacs.conversations.xmpp.jingle.stanzas.RtpDescription;
|
import eu.siacs.conversations.xmpp.jingle.stanzas.RtpDescription;
|
||||||
|
import im.conversations.android.xml.Namespace;
|
||||||
import java.util.Collection;
|
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
@ -29,8 +23,7 @@ public class SessionDescription {
|
||||||
private static final String HARDCODED_MEDIA_PROTOCOL =
|
private static final String HARDCODED_MEDIA_PROTOCOL =
|
||||||
"UDP/TLS/RTP/SAVPF"; // probably only true for DTLS-SRTP aka when we have a fingerprint
|
"UDP/TLS/RTP/SAVPF"; // probably only true for DTLS-SRTP aka when we have a fingerprint
|
||||||
private static final int HARDCODED_MEDIA_PORT = 9;
|
private static final int HARDCODED_MEDIA_PORT = 9;
|
||||||
private static final Collection<String> HARDCODED_ICE_OPTIONS =
|
private static final String HARDCODED_ICE_OPTIONS = "trickle";
|
||||||
Collections.singleton("trickle");
|
|
||||||
private static final String HARDCODED_CONNECTION = "IN IP4 0.0.0.0";
|
private static final String HARDCODED_CONNECTION = "IN IP4 0.0.0.0";
|
||||||
|
|
||||||
public final int version;
|
public final int version;
|
||||||
|
@ -170,21 +163,11 @@ public class SessionDescription {
|
||||||
}
|
}
|
||||||
checkNoWhitespace(pwd, "pwd value must not contain any whitespaces");
|
checkNoWhitespace(pwd, "pwd value must not contain any whitespaces");
|
||||||
mediaAttributes.put("ice-pwd", pwd);
|
mediaAttributes.put("ice-pwd", pwd);
|
||||||
final List<String> negotiatedIceOptions = transport.getIceOptions();
|
mediaAttributes.put("ice-options", HARDCODED_ICE_OPTIONS);
|
||||||
final Collection<String> iceOptions =
|
|
||||||
negotiatedIceOptions.isEmpty() ? HARDCODED_ICE_OPTIONS : negotiatedIceOptions;
|
|
||||||
mediaAttributes.put("ice-options", Joiner.on(' ').join(iceOptions));
|
|
||||||
final IceUdpTransportInfo.Fingerprint fingerprint = transport.getFingerprint();
|
final IceUdpTransportInfo.Fingerprint fingerprint = transport.getFingerprint();
|
||||||
if (fingerprint != null) {
|
if (fingerprint != null) {
|
||||||
final String hashFunction = fingerprint.getHash();
|
mediaAttributes.put(
|
||||||
final String hash = fingerprint.getContent();
|
"fingerprint", fingerprint.getHash() + " " + fingerprint.getContent());
|
||||||
if (Strings.isNullOrEmpty(hashFunction) || Strings.isNullOrEmpty(hash)) {
|
|
||||||
throw new IllegalArgumentException("DTLS-SRTP missing hash");
|
|
||||||
}
|
|
||||||
checkNoWhitespace(
|
|
||||||
hashFunction, "DTLS-SRTP hash function must not contain whitespace");
|
|
||||||
checkNoWhitespace(hash, "DTLS-SRTP hash must not contain whitespace");
|
|
||||||
mediaAttributes.put("fingerprint", hashFunction + " " + hash);
|
|
||||||
final IceUdpTransportInfo.Setup setup = fingerprint.getSetup();
|
final IceUdpTransportInfo.Setup setup = fingerprint.getSetup();
|
||||||
if (setup != null) {
|
if (setup != null) {
|
||||||
mediaAttributes.put("setup", setup.toString().toLowerCase(Locale.ROOT));
|
mediaAttributes.put("setup", setup.toString().toLowerCase(Locale.ROOT));
|
||||||
|
@ -221,14 +204,12 @@ public class SessionDescription {
|
||||||
}
|
}
|
||||||
checkNoWhitespace(
|
checkNoWhitespace(
|
||||||
type, "feedback negotiation type must not contain whitespace");
|
type, "feedback negotiation type must not contain whitespace");
|
||||||
if (Strings.isNullOrEmpty(subtype)) {
|
mediaAttributes.put(
|
||||||
mediaAttributes.put("rtcp-fb", id + " " + type);
|
"rtcp-fb",
|
||||||
} else {
|
id
|
||||||
checkNoWhitespace(
|
+ " "
|
||||||
subtype,
|
+ type
|
||||||
"feedback negotiation subtype must not contain whitespace");
|
+ (Strings.isNullOrEmpty(subtype) ? "" : " " + subtype));
|
||||||
mediaAttributes.put("rtcp-fb", id + " " + type + " " + subtype);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
for (RtpDescription.FeedbackNegotiationTrrInt feedbackNegotiationTrrInt :
|
for (RtpDescription.FeedbackNegotiationTrrInt feedbackNegotiationTrrInt :
|
||||||
payloadType.feedbackNegotiationTrrInts()) {
|
payloadType.feedbackNegotiationTrrInts()) {
|
||||||
|
@ -245,13 +226,9 @@ public class SessionDescription {
|
||||||
throw new IllegalArgumentException("a feedback negotiation is missing type");
|
throw new IllegalArgumentException("a feedback negotiation is missing type");
|
||||||
}
|
}
|
||||||
checkNoWhitespace(type, "feedback negotiation type must not contain whitespace");
|
checkNoWhitespace(type, "feedback negotiation type must not contain whitespace");
|
||||||
if (Strings.isNullOrEmpty(subtype)) {
|
mediaAttributes.put(
|
||||||
mediaAttributes.put("rtcp-fb", "* " + type);
|
"rtcp-fb",
|
||||||
} else {
|
"* " + type + (Strings.isNullOrEmpty(subtype) ? "" : " " + subtype));
|
||||||
checkNoWhitespace(
|
|
||||||
subtype, "feedback negotiation subtype must not contain whitespace");
|
|
||||||
mediaAttributes.put("rtcp-fb", "* " + type + " " + subtype); /**/
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
for (final RtpDescription.FeedbackNegotiationTrrInt feedbackNegotiationTrrInt :
|
for (final RtpDescription.FeedbackNegotiationTrrInt feedbackNegotiationTrrInt :
|
||||||
description.feedbackNegotiationTrrInts()) {
|
description.feedbackNegotiationTrrInts()) {
|
||||||
|
@ -288,9 +265,6 @@ public class SessionDescription {
|
||||||
if (groups.size() == 0) {
|
if (groups.size() == 0) {
|
||||||
throw new IllegalArgumentException("A SSRC group is missing SSRC ids");
|
throw new IllegalArgumentException("A SSRC group is missing SSRC ids");
|
||||||
}
|
}
|
||||||
for (final String source : groups) {
|
|
||||||
checkNoWhitespace(source, "Sources must not contain whitespace");
|
|
||||||
}
|
|
||||||
mediaAttributes.put(
|
mediaAttributes.put(
|
||||||
"ssrc-group",
|
"ssrc-group",
|
||||||
String.format("%s %s", semantics, Joiner.on(' ').join(groups)));
|
String.format("%s %s", semantics, Joiner.on(' ').join(groups)));
|
||||||
|
@ -314,14 +288,7 @@ public class SessionDescription {
|
||||||
throw new IllegalArgumentException(
|
throw new IllegalArgumentException(
|
||||||
"A source specific media attribute is missing its value");
|
"A source specific media attribute is missing its value");
|
||||||
}
|
}
|
||||||
checkNoWhitespace(
|
mediaAttributes.put("ssrc", id + " " + parameterName + ":" + parameterValue);
|
||||||
parameterName,
|
|
||||||
"A source specific media attribute name not not contain whitespace");
|
|
||||||
checkNoNewline(
|
|
||||||
parameterValue,
|
|
||||||
"A source specific media attribute value must not contain new lines");
|
|
||||||
mediaAttributes.put(
|
|
||||||
"ssrc", id + " " + parameterName + ":" + parameterValue.trim());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -360,13 +327,6 @@ public class SessionDescription {
|
||||||
return input;
|
return input;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static String checkNoNewline(final String input, final String message) {
|
|
||||||
if (CharMatcher.anyOf("\r\n").matchesAnyOf(message)) {
|
|
||||||
throw new IllegalArgumentException(message);
|
|
||||||
}
|
|
||||||
return input;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static int ignorantIntParser(final String input) {
|
public static int ignorantIntParser(final String input) {
|
||||||
try {
|
try {
|
||||||
return Integer.parseInt(input);
|
return Integer.parseInt(input);
|
|
@ -1,7 +1,6 @@
|
||||||
package eu.siacs.conversations.xmpp.jingle;
|
package eu.siacs.conversations.xmpp.jingle;
|
||||||
|
|
||||||
import com.google.common.collect.ArrayListMultimap;
|
import com.google.common.collect.ArrayListMultimap;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public class SessionDescriptionBuilder {
|
public class SessionDescriptionBuilder {
|
|
@ -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;
|
package eu.siacs.conversations.xmpp.jingle;
|
||||||
|
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
|
||||||
import com.google.common.base.CaseFormat;
|
import com.google.common.base.CaseFormat;
|
||||||
import com.google.common.base.Optional;
|
import com.google.common.base.Optional;
|
||||||
import com.google.common.base.Preconditions;
|
import com.google.common.base.Preconditions;
|
||||||
|
|
||||||
import eu.siacs.conversations.Config;
|
import eu.siacs.conversations.Config;
|
||||||
|
import java.util.UUID;
|
||||||
|
import javax.annotation.Nonnull;
|
||||||
|
import javax.annotation.Nullable;
|
||||||
import org.webrtc.MediaStreamTrack;
|
import org.webrtc.MediaStreamTrack;
|
||||||
import org.webrtc.PeerConnection;
|
import org.webrtc.PeerConnection;
|
||||||
import org.webrtc.RtpSender;
|
import org.webrtc.RtpSender;
|
||||||
import org.webrtc.RtpTransceiver;
|
import org.webrtc.RtpTransceiver;
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import javax.annotation.Nonnull;
|
|
||||||
import javax.annotation.Nullable;
|
|
||||||
|
|
||||||
class TrackWrapper<T extends MediaStreamTrack> {
|
class TrackWrapper<T extends MediaStreamTrack> {
|
||||||
public final T track;
|
public final T track;
|
||||||
public final RtpSender rtpSender;
|
public final RtpSender rtpSender;
|
||||||
|
@ -43,13 +38,9 @@ class TrackWrapper<T extends MediaStreamTrack> {
|
||||||
final RtpTransceiver transceiver =
|
final RtpTransceiver transceiver =
|
||||||
peerConnection == null ? null : getTransceiver(peerConnection, trackWrapper);
|
peerConnection == null ? null : getTransceiver(peerConnection, trackWrapper);
|
||||||
if (transceiver == null) {
|
if (transceiver == null) {
|
||||||
final String id;
|
Log.w(
|
||||||
try {
|
Config.LOGTAG,
|
||||||
id = trackWrapper.rtpSender.id();
|
"unable to detect transceiver for " + trackWrapper.getRtpSenderId());
|
||||||
} catch (final IllegalStateException e) {
|
|
||||||
return Optional.absent();
|
|
||||||
}
|
|
||||||
Log.w(Config.LOGTAG, "unable to detect transceiver for " + id);
|
|
||||||
return Optional.of(trackWrapper.track);
|
return Optional.of(trackWrapper.track);
|
||||||
}
|
}
|
||||||
final RtpTransceiver.RtpTransceiverDirection direction = transceiver.getDirection();
|
final RtpTransceiver.RtpTransceiverDirection direction = transceiver.getDirection();
|
||||||
|
@ -62,11 +53,22 @@ class TrackWrapper<T extends MediaStreamTrack> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getRtpSenderId() {
|
||||||
|
try {
|
||||||
|
return track.id();
|
||||||
|
} catch (final IllegalStateException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static <T extends MediaStreamTrack> RtpTransceiver getTransceiver(
|
public static <T extends MediaStreamTrack> RtpTransceiver getTransceiver(
|
||||||
@Nonnull final PeerConnection peerConnection, final TrackWrapper<T> trackWrapper) {
|
@Nonnull final PeerConnection peerConnection, final TrackWrapper<T> trackWrapper) {
|
||||||
final RtpSender rtpSender = trackWrapper.rtpSender;
|
final String rtpSenderId = trackWrapper.getRtpSenderId();
|
||||||
|
if (rtpSenderId == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
for (final RtpTransceiver transceiver : peerConnection.getTransceivers()) {
|
for (final RtpTransceiver transceiver : peerConnection.getTransceivers()) {
|
||||||
if (transceiver.getSender().id().equals(rtpSender.id())) {
|
if (transceiver.getSender().id().equals(rtpSenderId)) {
|
||||||
return transceiver;
|
return transceiver;
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -2,12 +2,15 @@ package eu.siacs.conversations.xmpp.jingle;
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
|
||||||
import com.google.common.collect.ImmutableSet;
|
import com.google.common.collect.ImmutableSet;
|
||||||
import com.google.common.collect.Iterables;
|
import com.google.common.collect.Iterables;
|
||||||
import com.google.common.util.concurrent.ListenableFuture;
|
import com.google.common.util.concurrent.ListenableFuture;
|
||||||
import com.google.common.util.concurrent.SettableFuture;
|
import com.google.common.util.concurrent.SettableFuture;
|
||||||
|
import eu.siacs.conversations.Config;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Set;
|
||||||
|
import javax.annotation.Nullable;
|
||||||
import org.webrtc.Camera2Enumerator;
|
import org.webrtc.Camera2Enumerator;
|
||||||
import org.webrtc.CameraEnumerationAndroid;
|
import org.webrtc.CameraEnumerationAndroid;
|
||||||
import org.webrtc.CameraEnumerator;
|
import org.webrtc.CameraEnumerator;
|
||||||
|
@ -17,14 +20,6 @@ import org.webrtc.PeerConnectionFactory;
|
||||||
import org.webrtc.SurfaceTextureHelper;
|
import org.webrtc.SurfaceTextureHelper;
|
||||||
import org.webrtc.VideoSource;
|
import org.webrtc.VideoSource;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.Set;
|
|
||||||
|
|
||||||
import javax.annotation.Nullable;
|
|
||||||
|
|
||||||
import eu.siacs.conversations.Config;
|
|
||||||
|
|
||||||
class VideoSourceWrapper {
|
class VideoSourceWrapper {
|
||||||
|
|
||||||
private static final int CAPTURING_RESOLUTION = 1920;
|
private static final int CAPTURING_RESOLUTION = 1920;
|
|
@ -1,32 +1,34 @@
|
||||||
package eu.siacs.conversations.xmpp.jingle;
|
package eu.siacs.conversations.xmpp.jingle;
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.media.ToneGenerator;
|
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
import android.os.Handler;
|
import android.os.Handler;
|
||||||
import android.os.Looper;
|
import android.os.Looper;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
|
||||||
import com.google.common.base.Optional;
|
import com.google.common.base.Optional;
|
||||||
import com.google.common.base.Preconditions;
|
import com.google.common.base.Preconditions;
|
||||||
import com.google.common.collect.ImmutableMap;
|
|
||||||
import com.google.common.collect.ImmutableSet;
|
import com.google.common.collect.ImmutableSet;
|
||||||
import com.google.common.util.concurrent.Futures;
|
import com.google.common.util.concurrent.Futures;
|
||||||
import com.google.common.util.concurrent.ListenableFuture;
|
import com.google.common.util.concurrent.ListenableFuture;
|
||||||
import com.google.common.util.concurrent.MoreExecutors;
|
import com.google.common.util.concurrent.MoreExecutors;
|
||||||
import com.google.common.util.concurrent.SettableFuture;
|
import com.google.common.util.concurrent.SettableFuture;
|
||||||
|
|
||||||
import eu.siacs.conversations.Config;
|
import eu.siacs.conversations.Config;
|
||||||
import eu.siacs.conversations.services.AppRTCAudioManager;
|
import eu.siacs.conversations.services.AppRTCAudioManager;
|
||||||
import eu.siacs.conversations.services.XmppConnectionService;
|
import java.util.LinkedList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Queue;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import javax.annotation.Nonnull;
|
||||||
|
import javax.annotation.Nullable;
|
||||||
import org.webrtc.AudioSource;
|
import org.webrtc.AudioSource;
|
||||||
import org.webrtc.AudioTrack;
|
import org.webrtc.AudioTrack;
|
||||||
import org.webrtc.CandidatePairChangeEvent;
|
import org.webrtc.CandidatePairChangeEvent;
|
||||||
import org.webrtc.DataChannel;
|
import org.webrtc.DataChannel;
|
||||||
import org.webrtc.DefaultVideoDecoderFactory;
|
import org.webrtc.DefaultVideoDecoderFactory;
|
||||||
import org.webrtc.DefaultVideoEncoderFactory;
|
import org.webrtc.DefaultVideoEncoderFactory;
|
||||||
import org.webrtc.DtmfSender;
|
|
||||||
import org.webrtc.EglBase;
|
import org.webrtc.EglBase;
|
||||||
import org.webrtc.IceCandidate;
|
import org.webrtc.IceCandidate;
|
||||||
import org.webrtc.MediaConstraints;
|
import org.webrtc.MediaConstraints;
|
||||||
|
@ -40,20 +42,7 @@ import org.webrtc.SdpObserver;
|
||||||
import org.webrtc.SessionDescription;
|
import org.webrtc.SessionDescription;
|
||||||
import org.webrtc.VideoTrack;
|
import org.webrtc.VideoTrack;
|
||||||
import org.webrtc.audio.JavaAudioDeviceModule;
|
import org.webrtc.audio.JavaAudioDeviceModule;
|
||||||
|
import org.webrtc.voiceengine.WebRtcAudioEffects;
|
||||||
import java.util.LinkedList;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Queue;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Set;
|
|
||||||
import java.util.concurrent.ExecutorService;
|
|
||||||
import java.util.concurrent.Executors;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
import java.util.concurrent.TimeoutException;
|
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
|
||||||
|
|
||||||
import javax.annotation.Nonnull;
|
|
||||||
import javax.annotation.Nullable;
|
|
||||||
|
|
||||||
@SuppressWarnings("UnstableApiUsage")
|
@SuppressWarnings("UnstableApiUsage")
|
||||||
public class WebRTCWrapper {
|
public class WebRTCWrapper {
|
||||||
|
@ -61,27 +50,6 @@ public class WebRTCWrapper {
|
||||||
private static final String EXTENDED_LOGGING_TAG = WebRTCWrapper.class.getSimpleName();
|
private static final String EXTENDED_LOGGING_TAG = WebRTCWrapper.class.getSimpleName();
|
||||||
|
|
||||||
private final ExecutorService executorService = Executors.newSingleThreadExecutor();
|
private final ExecutorService executorService = Executors.newSingleThreadExecutor();
|
||||||
private final ExecutorService localDescriptionExecutorService =
|
|
||||||
Executors.newSingleThreadExecutor();
|
|
||||||
|
|
||||||
private static final int TONE_DURATION = 500;
|
|
||||||
private static final Map<String,Integer> TONE_CODES;
|
|
||||||
static {
|
|
||||||
ImmutableMap.Builder<String,Integer> builder = new ImmutableMap.Builder<>();
|
|
||||||
builder.put("0", ToneGenerator.TONE_DTMF_0);
|
|
||||||
builder.put("1", ToneGenerator.TONE_DTMF_1);
|
|
||||||
builder.put("2", ToneGenerator.TONE_DTMF_2);
|
|
||||||
builder.put("3", ToneGenerator.TONE_DTMF_3);
|
|
||||||
builder.put("4", ToneGenerator.TONE_DTMF_4);
|
|
||||||
builder.put("5", ToneGenerator.TONE_DTMF_5);
|
|
||||||
builder.put("6", ToneGenerator.TONE_DTMF_6);
|
|
||||||
builder.put("7", ToneGenerator.TONE_DTMF_7);
|
|
||||||
builder.put("8", ToneGenerator.TONE_DTMF_8);
|
|
||||||
builder.put("9", ToneGenerator.TONE_DTMF_9);
|
|
||||||
builder.put("*", ToneGenerator.TONE_DTMF_S);
|
|
||||||
builder.put("#", ToneGenerator.TONE_DTMF_P);
|
|
||||||
TONE_CODES = builder.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final Set<String> HARDWARE_AEC_BLACKLIST =
|
private static final Set<String> HARDWARE_AEC_BLACKLIST =
|
||||||
new ImmutableSet.Builder<String>()
|
new ImmutableSet.Builder<String>()
|
||||||
|
@ -96,7 +64,6 @@ public class WebRTCWrapper {
|
||||||
.add("E5823") // Sony z5 compact
|
.add("E5823") // Sony z5 compact
|
||||||
.add("Redmi Note 5")
|
.add("Redmi Note 5")
|
||||||
.add("FP2") // Fairphone FP2
|
.add("FP2") // Fairphone FP2
|
||||||
.add("FP4") // Fairphone FP4
|
|
||||||
.add("MI 5")
|
.add("MI 5")
|
||||||
.add("GT-I9515") // Samsung Galaxy S4 Value Edition (jfvelte)
|
.add("GT-I9515") // Samsung Galaxy S4 Value Edition (jfvelte)
|
||||||
.add("GT-I9515L") // Samsung Galaxy S4 Value Edition (jfvelte)
|
.add("GT-I9515L") // Samsung Galaxy S4 Value Edition (jfvelte)
|
||||||
|
@ -119,8 +86,6 @@ public class WebRTCWrapper {
|
||||||
private TrackWrapper<AudioTrack> localAudioTrack = null;
|
private TrackWrapper<AudioTrack> localAudioTrack = null;
|
||||||
private TrackWrapper<VideoTrack> localVideoTrack = null;
|
private TrackWrapper<VideoTrack> localVideoTrack = null;
|
||||||
private VideoTrack remoteVideoTrack = null;
|
private VideoTrack remoteVideoTrack = null;
|
||||||
|
|
||||||
private final SettableFuture<Void> iceGatheringComplete = SettableFuture.create();
|
|
||||||
private final PeerConnection.Observer peerConnectionObserver =
|
private final PeerConnection.Observer peerConnectionObserver =
|
||||||
new PeerConnection.Observer() {
|
new PeerConnection.Observer() {
|
||||||
@Override
|
@Override
|
||||||
|
@ -155,11 +120,8 @@ public class WebRTCWrapper {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onIceGatheringChange(
|
public void onIceGatheringChange(
|
||||||
final PeerConnection.IceGatheringState iceGatheringState) {
|
PeerConnection.IceGatheringState iceGatheringState) {
|
||||||
Log.d(EXTENDED_LOGGING_TAG, "onIceGatheringChange(" + iceGatheringState + ")");
|
Log.d(EXTENDED_LOGGING_TAG, "onIceGatheringChange(" + iceGatheringState + ")");
|
||||||
if (iceGatheringState == PeerConnection.IceGatheringState.COMPLETE) {
|
|
||||||
iceGatheringComplete.set(null);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -238,7 +200,6 @@ public class WebRTCWrapper {
|
||||||
@Nullable private PeerConnectionFactory peerConnectionFactory = null;
|
@Nullable private PeerConnectionFactory peerConnectionFactory = null;
|
||||||
@Nullable private PeerConnection peerConnection = null;
|
@Nullable private PeerConnection peerConnection = null;
|
||||||
private AppRTCAudioManager appRTCAudioManager = null;
|
private AppRTCAudioManager appRTCAudioManager = null;
|
||||||
private ToneManager toneManager = null;
|
|
||||||
private Context context = null;
|
private Context context = null;
|
||||||
private EglBase eglBase = null;
|
private EglBase eglBase = null;
|
||||||
private VideoSourceWrapper videoSourceWrapper;
|
private VideoSourceWrapper videoSourceWrapper;
|
||||||
|
@ -255,8 +216,16 @@ public class WebRTCWrapper {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void dispose(final VideoTrack videoTrack) {
|
||||||
|
try {
|
||||||
|
videoTrack.dispose();
|
||||||
|
} catch (final IllegalStateException e) {
|
||||||
|
Log.e(Config.LOGTAG, "unable to dispose of video track", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void setup(
|
public void setup(
|
||||||
final XmppConnectionService service,
|
final Context service,
|
||||||
@Nonnull final AppRTCAudioManager.SpeakerPhonePreference speakerPhonePreference)
|
@Nonnull final AppRTCAudioManager.SpeakerPhonePreference speakerPhonePreference)
|
||||||
throws InitializationException {
|
throws InitializationException {
|
||||||
try {
|
try {
|
||||||
|
@ -273,11 +242,10 @@ public class WebRTCWrapper {
|
||||||
throw new InitializationException("Unable to create EGL base", e);
|
throw new InitializationException("Unable to create EGL base", e);
|
||||||
}
|
}
|
||||||
this.context = service;
|
this.context = service;
|
||||||
this.toneManager = service.getJingleConnectionManager().toneManager;
|
|
||||||
mainHandler.post(
|
mainHandler.post(
|
||||||
() -> {
|
() -> {
|
||||||
appRTCAudioManager = AppRTCAudioManager.create(service, speakerPhonePreference);
|
appRTCAudioManager = AppRTCAudioManager.create(service, speakerPhonePreference);
|
||||||
toneManager.setAppRtcAudioManagerHasControl(true);
|
ToneManager.getInstance(context).setAppRtcAudioManagerHasControl(true);
|
||||||
appRTCAudioManager.start(audioManagerEvents);
|
appRTCAudioManager.start(audioManagerEvents);
|
||||||
eventCallback.onAudioDeviceChanged(
|
eventCallback.onAudioDeviceChanged(
|
||||||
appRTCAudioManager.getSelectedAudioDevice(),
|
appRTCAudioManager.getSelectedAudioDevice(),
|
||||||
|
@ -286,16 +254,15 @@ public class WebRTCWrapper {
|
||||||
}
|
}
|
||||||
|
|
||||||
synchronized void initializePeerConnection(
|
synchronized void initializePeerConnection(
|
||||||
final Set<Media> media,
|
final Set<Media> media, final List<PeerConnection.IceServer> iceServers)
|
||||||
final List<PeerConnection.IceServer> iceServers,
|
|
||||||
final boolean trickle)
|
|
||||||
throws InitializationException {
|
throws InitializationException {
|
||||||
Preconditions.checkState(this.eglBase != null);
|
Preconditions.checkState(this.eglBase != null);
|
||||||
Preconditions.checkNotNull(media);
|
Preconditions.checkNotNull(media);
|
||||||
Preconditions.checkArgument(
|
Preconditions.checkArgument(
|
||||||
media.size() > 0, "media can not be empty when initializing peer connection");
|
media.size() > 0, "media can not be empty when initializing peer connection");
|
||||||
final boolean setUseHardwareAcousticEchoCanceler =
|
final boolean setUseHardwareAcousticEchoCanceler =
|
||||||
!HARDWARE_AEC_BLACKLIST.contains(Build.MODEL);
|
WebRtcAudioEffects.canUseAcousticEchoCanceler()
|
||||||
|
&& !HARDWARE_AEC_BLACKLIST.contains(Build.MODEL);
|
||||||
Log.d(
|
Log.d(
|
||||||
Config.LOGTAG,
|
Config.LOGTAG,
|
||||||
String.format(
|
String.format(
|
||||||
|
@ -315,7 +282,7 @@ public class WebRTCWrapper {
|
||||||
.createAudioDeviceModule())
|
.createAudioDeviceModule())
|
||||||
.createPeerConnectionFactory();
|
.createPeerConnectionFactory();
|
||||||
|
|
||||||
final PeerConnection.RTCConfiguration rtcConfig = buildConfiguration(iceServers, trickle);
|
final PeerConnection.RTCConfiguration rtcConfig = buildConfiguration(iceServers);
|
||||||
final PeerConnection peerConnection =
|
final PeerConnection peerConnection =
|
||||||
requirePeerConnectionFactory()
|
requirePeerConnectionFactory()
|
||||||
.createPeerConnection(rtcConfig, peerConnectionObserver);
|
.createPeerConnection(rtcConfig, peerConnectionObserver);
|
||||||
|
@ -430,43 +397,38 @@ public class WebRTCWrapper {
|
||||||
}
|
}
|
||||||
|
|
||||||
private static PeerConnection.RTCConfiguration buildConfiguration(
|
private static PeerConnection.RTCConfiguration buildConfiguration(
|
||||||
final List<PeerConnection.IceServer> iceServers, final boolean trickle) {
|
final List<PeerConnection.IceServer> iceServers) {
|
||||||
final PeerConnection.RTCConfiguration rtcConfig =
|
final PeerConnection.RTCConfiguration rtcConfig =
|
||||||
new PeerConnection.RTCConfiguration(iceServers);
|
new PeerConnection.RTCConfiguration(iceServers);
|
||||||
rtcConfig.tcpCandidatePolicy =
|
rtcConfig.tcpCandidatePolicy =
|
||||||
PeerConnection.TcpCandidatePolicy.DISABLED; // XEP-0176 doesn't support tcp
|
PeerConnection.TcpCandidatePolicy.DISABLED; // XEP-0176 doesn't support tcp
|
||||||
if (trickle) {
|
|
||||||
rtcConfig.continualGatheringPolicy =
|
rtcConfig.continualGatheringPolicy =
|
||||||
PeerConnection.ContinualGatheringPolicy.GATHER_CONTINUALLY;
|
PeerConnection.ContinualGatheringPolicy.GATHER_CONTINUALLY;
|
||||||
} else {
|
|
||||||
rtcConfig.continualGatheringPolicy =
|
|
||||||
PeerConnection.ContinualGatheringPolicy.GATHER_ONCE;
|
|
||||||
}
|
|
||||||
rtcConfig.sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN;
|
rtcConfig.sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN;
|
||||||
rtcConfig.rtcpMuxPolicy = PeerConnection.RtcpMuxPolicy.NEGOTIATE;
|
rtcConfig.rtcpMuxPolicy = PeerConnection.RtcpMuxPolicy.NEGOTIATE;
|
||||||
rtcConfig.enableImplicitRollback = true;
|
rtcConfig.enableImplicitRollback = true;
|
||||||
return rtcConfig;
|
return rtcConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
void reconfigurePeerConnection(
|
void reconfigurePeerConnection(final List<PeerConnection.IceServer> iceServers) {
|
||||||
final List<PeerConnection.IceServer> iceServers, final boolean trickle) {
|
requirePeerConnection().setConfiguration(buildConfiguration(iceServers));
|
||||||
requirePeerConnection().setConfiguration(buildConfiguration(iceServers, trickle));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void restartIceAsync() {
|
void restartIce() {
|
||||||
this.execute(this::restartIce);
|
executorService.execute(
|
||||||
}
|
() -> {
|
||||||
|
|
||||||
private void restartIce() {
|
|
||||||
final PeerConnection peerConnection;
|
final PeerConnection peerConnection;
|
||||||
try {
|
try {
|
||||||
peerConnection = requirePeerConnection();
|
peerConnection = requirePeerConnection();
|
||||||
} catch (final PeerConnectionNotInitialized e) {
|
} catch (final PeerConnectionNotInitialized e) {
|
||||||
Log.w(EXTENDED_LOGGING_TAG, "PeerConnection vanished before we could execute restart");
|
Log.w(
|
||||||
|
EXTENDED_LOGGING_TAG,
|
||||||
|
"PeerConnection vanished before we could execute restart");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setIsReadyToReceiveIceCandidates(false);
|
setIsReadyToReceiveIceCandidates(false);
|
||||||
peerConnection.restartIce();
|
peerConnection.restartIce();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setIsReadyToReceiveIceCandidates(final boolean ready) {
|
public void setIsReadyToReceiveIceCandidates(final boolean ready) {
|
||||||
|
@ -487,15 +449,19 @@ public class WebRTCWrapper {
|
||||||
final VideoSourceWrapper videoSourceWrapper = this.videoSourceWrapper;
|
final VideoSourceWrapper videoSourceWrapper = this.videoSourceWrapper;
|
||||||
final AppRTCAudioManager audioManager = this.appRTCAudioManager;
|
final AppRTCAudioManager audioManager = this.appRTCAudioManager;
|
||||||
final EglBase eglBase = this.eglBase;
|
final EglBase eglBase = this.eglBase;
|
||||||
|
final var localVideoTrack = this.localVideoTrack;
|
||||||
if (peerConnection != null) {
|
if (peerConnection != null) {
|
||||||
this.peerConnection = null;
|
this.peerConnection = null;
|
||||||
dispose(peerConnection);
|
dispose(peerConnection);
|
||||||
}
|
}
|
||||||
if (audioManager != null) {
|
if (audioManager != null) {
|
||||||
toneManager.setAppRtcAudioManagerHasControl(false);
|
ToneManager.getInstance(context).setAppRtcAudioManagerHasControl(false);
|
||||||
mainHandler.post(audioManager::stop);
|
mainHandler.post(audioManager::stop);
|
||||||
}
|
}
|
||||||
|
if (localVideoTrack != null) {
|
||||||
this.localVideoTrack = null;
|
this.localVideoTrack = null;
|
||||||
|
dispose(localVideoTrack.track);
|
||||||
|
}
|
||||||
this.remoteVideoTrack = null;
|
this.remoteVideoTrack = null;
|
||||||
if (videoSourceWrapper != null) {
|
if (videoSourceWrapper != null) {
|
||||||
this.videoSourceWrapper = null;
|
this.videoSourceWrapper = null;
|
||||||
|
@ -507,8 +473,8 @@ public class WebRTCWrapper {
|
||||||
videoSourceWrapper.dispose();
|
videoSourceWrapper.dispose();
|
||||||
}
|
}
|
||||||
if (eglBase != null) {
|
if (eglBase != null) {
|
||||||
eglBase.release();
|
|
||||||
this.eglBase = null;
|
this.eglBase = null;
|
||||||
|
eglBase.release();
|
||||||
}
|
}
|
||||||
if (peerConnectionFactory != null) {
|
if (peerConnectionFactory != null) {
|
||||||
this.peerConnectionFactory = null;
|
this.peerConnectionFactory = null;
|
||||||
|
@ -548,14 +514,8 @@ public class WebRTCWrapper {
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean isMicrophoneEnabled() {
|
boolean isMicrophoneEnabled() {
|
||||||
Optional<AudioTrack> audioTrack = null;
|
final Optional<AudioTrack> audioTrack =
|
||||||
try {
|
TrackWrapper.get(peerConnection, this.localAudioTrack);
|
||||||
audioTrack = TrackWrapper.get(peerConnection, this.localAudioTrack);
|
|
||||||
} catch (final IllegalStateException e) {
|
|
||||||
Log.d(Config.LOGTAG, "unable to check microphone", e);
|
|
||||||
// ignoring race condition in case sender has been disposed
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (audioTrack.isPresent()) {
|
if (audioTrack.isPresent()) {
|
||||||
try {
|
try {
|
||||||
return audioTrack.get().enabled();
|
return audioTrack.get().enabled();
|
||||||
|
@ -565,19 +525,13 @@ public class WebRTCWrapper {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return false;
|
throw new IllegalStateException("Local audio track does not exist (yet)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean setMicrophoneEnabled(final boolean enabled) {
|
boolean setMicrophoneEnabled(final boolean enabled) {
|
||||||
Optional<AudioTrack> audioTrack = null;
|
final Optional<AudioTrack> audioTrack =
|
||||||
try {
|
TrackWrapper.get(peerConnection, this.localAudioTrack);
|
||||||
audioTrack = TrackWrapper.get(peerConnection, this.localAudioTrack);
|
|
||||||
} catch (final IllegalStateException e) {
|
|
||||||
Log.d(Config.LOGTAG, "unable to toggle microphone", e);
|
|
||||||
// ignoring race condition in case sender has been disposed
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (audioTrack.isPresent()) {
|
if (audioTrack.isPresent()) {
|
||||||
try {
|
try {
|
||||||
audioTrack.get().setEnabled(enabled);
|
audioTrack.get().setEnabled(enabled);
|
||||||
|
@ -611,9 +565,7 @@ public class WebRTCWrapper {
|
||||||
throw new IllegalStateException("Local video track does not exist");
|
throw new IllegalStateException("Local video track does not exist");
|
||||||
}
|
}
|
||||||
|
|
||||||
synchronized ListenableFuture<SessionDescription> setLocalDescription(
|
synchronized ListenableFuture<SessionDescription> setLocalDescription() {
|
||||||
final boolean waitForCandidates) {
|
|
||||||
this.setIsReadyToReceiveIceCandidates(false);
|
|
||||||
return Futures.transformAsync(
|
return Futures.transformAsync(
|
||||||
getPeerConnectionFuture(),
|
getPeerConnectionFuture(),
|
||||||
peerConnection -> {
|
peerConnection -> {
|
||||||
|
@ -626,20 +578,11 @@ public class WebRTCWrapper {
|
||||||
new SetSdpObserver() {
|
new SetSdpObserver() {
|
||||||
@Override
|
@Override
|
||||||
public void onSetSuccess() {
|
public void onSetSuccess() {
|
||||||
if (waitForCandidates) {
|
final SessionDescription description =
|
||||||
final var delay = getIceGatheringCompleteOrTimeout();
|
peerConnection.getLocalDescription();
|
||||||
final var delayedSessionDescription =
|
Log.d(EXTENDED_LOGGING_TAG, "set local description:");
|
||||||
Futures.transformAsync(
|
logDescription(description);
|
||||||
delay,
|
future.set(description);
|
||||||
v -> {
|
|
||||||
iceCandidates.clear();
|
|
||||||
return getLocalDescriptionFuture();
|
|
||||||
},
|
|
||||||
MoreExecutors.directExecutor());
|
|
||||||
future.setFuture(delayedSessionDescription);
|
|
||||||
} else {
|
|
||||||
future.setFuture(getLocalDescriptionFuture());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -653,35 +596,6 @@ public class WebRTCWrapper {
|
||||||
MoreExecutors.directExecutor());
|
MoreExecutors.directExecutor());
|
||||||
}
|
}
|
||||||
|
|
||||||
private ListenableFuture<Void> getIceGatheringCompleteOrTimeout() {
|
|
||||||
return Futures.catching(
|
|
||||||
Futures.withTimeout(
|
|
||||||
iceGatheringComplete,
|
|
||||||
2,
|
|
||||||
TimeUnit.SECONDS,
|
|
||||||
JingleConnectionManager.SCHEDULED_EXECUTOR_SERVICE),
|
|
||||||
TimeoutException.class,
|
|
||||||
ex -> {
|
|
||||||
Log.d(
|
|
||||||
EXTENDED_LOGGING_TAG,
|
|
||||||
"timeout while waiting for ICE gathering to complete");
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
MoreExecutors.directExecutor());
|
|
||||||
}
|
|
||||||
|
|
||||||
private ListenableFuture<SessionDescription> getLocalDescriptionFuture() {
|
|
||||||
return Futures.submit(
|
|
||||||
() -> {
|
|
||||||
final SessionDescription description =
|
|
||||||
requirePeerConnection().getLocalDescription();
|
|
||||||
Log.d(EXTENDED_LOGGING_TAG, "local description:");
|
|
||||||
logDescription(description);
|
|
||||||
return description;
|
|
||||||
},
|
|
||||||
localDescriptionExecutorService);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void logDescription(final SessionDescription sessionDescription) {
|
public static void logDescription(final SessionDescription sessionDescription) {
|
||||||
for (final String line :
|
for (final String line :
|
||||||
sessionDescription.description.split(
|
sessionDescription.description.split(
|
||||||
|
@ -740,15 +654,6 @@ public class WebRTCWrapper {
|
||||||
return peerConnection;
|
return peerConnection;
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean applyDtmfTone(String tone) {
|
|
||||||
if (toneManager == null || peerConnection == null || localAudioTrack == null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
localAudioTrack.rtpSender.dtmf().insertDtmf(tone, TONE_DURATION, 100);
|
|
||||||
toneManager.startTone(TONE_CODES.get(tone), TONE_DURATION);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nonnull
|
@Nonnull
|
||||||
private PeerConnectionFactory requirePeerConnectionFactory() {
|
private PeerConnectionFactory requirePeerConnectionFactory() {
|
||||||
final PeerConnectionFactory peerConnectionFactory = this.peerConnectionFactory;
|
final PeerConnectionFactory peerConnectionFactory = this.peerConnectionFactory;
|
||||||
|
@ -799,7 +704,7 @@ public class WebRTCWrapper {
|
||||||
}
|
}
|
||||||
|
|
||||||
void execute(final Runnable command) {
|
void execute(final Runnable command) {
|
||||||
this.executorService.execute(command);
|
executorService.execute(command);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void switchSpeakerPhonePreference(AppRTCAudioManager.SpeakerPhonePreference preference) {
|
public void switchSpeakerPhonePreference(AppRTCAudioManager.SpeakerPhonePreference preference) {
|
|
@ -1,21 +1,16 @@
|
||||||
package eu.siacs.conversations.xmpp.jingle.stanzas;
|
package eu.siacs.conversations.xmpp.jingle.stanzas;
|
||||||
|
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
|
|
||||||
import com.google.common.base.Preconditions;
|
import com.google.common.base.Preconditions;
|
||||||
import com.google.common.base.Strings;
|
import com.google.common.base.Strings;
|
||||||
import com.google.common.collect.ImmutableSet;
|
import eu.siacs.conversations.Config;
|
||||||
|
import eu.siacs.conversations.xmpp.jingle.SessionDescription;
|
||||||
|
import im.conversations.android.xml.Element;
|
||||||
|
import im.conversations.android.xml.Namespace;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
import eu.siacs.conversations.Config;
|
|
||||||
import eu.siacs.conversations.xml.Element;
|
|
||||||
import eu.siacs.conversations.xml.Namespace;
|
|
||||||
import eu.siacs.conversations.xmpp.jingle.SessionDescription;
|
|
||||||
|
|
||||||
public class Content extends Element {
|
public class Content extends Element {
|
||||||
|
|
||||||
public Content(final Creator creator, final Senders senders, final String name) {
|
public Content(final Creator creator, final Senders senders, final String name) {
|
||||||
|
@ -100,7 +95,6 @@ public class Content extends Element {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public void setTransport(GenericTransportInfo transportInfo) {
|
public void setTransport(GenericTransportInfo transportInfo) {
|
||||||
this.addChild(transportInfo);
|
this.addChild(transportInfo);
|
||||||
}
|
}
|
||||||
|
@ -148,10 +142,6 @@ public class Content extends Element {
|
||||||
return BOTH;
|
return BOTH;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Set<Senders> receiveOnly(final boolean initiator) {
|
|
||||||
return ImmutableSet.of(initiator ? RESPONDER : INITIATOR);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@NonNull
|
@NonNull
|
||||||
public String toString() {
|
public String toString() {
|
|
@ -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;
|
package eu.siacs.conversations.xmpp.jingle.stanzas;
|
||||||
|
|
||||||
import com.google.common.base.Preconditions;
|
import com.google.common.base.Preconditions;
|
||||||
|
import im.conversations.android.xml.Element;
|
||||||
import eu.siacs.conversations.xml.Element;
|
|
||||||
|
|
||||||
public class GenericDescription extends Element {
|
public class GenericDescription extends Element {
|
||||||
|
|
||||||
|
@ -12,7 +11,8 @@ public class GenericDescription extends Element {
|
||||||
|
|
||||||
public static GenericDescription upgrade(final Element element) {
|
public static GenericDescription upgrade(final Element element) {
|
||||||
Preconditions.checkArgument("description".equals(element.getName()));
|
Preconditions.checkArgument("description".equals(element.getName()));
|
||||||
final GenericDescription description = new GenericDescription("description", element.getNamespace());
|
final GenericDescription description =
|
||||||
|
new GenericDescription("description", element.getNamespace());
|
||||||
description.setAttributes(element.getAttributes());
|
description.setAttributes(element.getAttributes());
|
||||||
description.setChildren(element.getChildren());
|
description.setChildren(element.getChildren());
|
||||||
return description;
|
return description;
|