Merge tag '2.6.4' into develop

This commit is contained in:
genofire 2020-01-20 22:03:41 +01:00
commit cf6323cc00
No known key found for this signature in database
GPG Key ID: 9D7D3C6BFF600C6A
45 changed files with 2618 additions and 2496 deletions

View File

@ -1,5 +1,9 @@
# Changelog # Changelog
### Version 2.6.4
* Support automatic theme switching on Android 10
### Version 2.6.3 ### Version 2.6.3
* Support for ?register and ?register;preauth XMPP uri parameters * Support for ?register and ?register;preauth XMPP uri parameters

View File

@ -85,13 +85,13 @@ ext {
} }
android { android {
compileSdkVersion 28 compileSdkVersion 29
defaultConfig { defaultConfig {
minSdkVersion 16 minSdkVersion 16
targetSdkVersion 25 targetSdkVersion 25
versionCode 360 versionCode 362
versionName "2.6.3" versionName "2.6.4"
archivesBaseName += "-$versionName" archivesBaseName += "-$versionName"
applicationId "eu.sum7.conversations" applicationId "eu.sum7.conversations"
resValue "string", "applicationId", applicationId resValue "string", "applicationId", applicationId

View File

@ -0,0 +1,4 @@
* Introduce expert setting to perform channel discovery on local server instead of search.jabber.network
* Enable delivery check marks by default and remove setting
* Enable Send button indicates status by default and remove setting
* Move Backup and Foreground Service settings to main screen

View File

@ -0,0 +1,3 @@
* fixes for Jingle IBB file transfer
* fixes for repeated corrections filling up the database
* switched to Last Message Correction v1.1

View File

@ -0,0 +1,4 @@
* let users set their own nick name
* resume download of OMEMO encrypted files
* Channels now use '#' as symbol in avatar
* Quicksy uses 'always' as OMEMO encryption default (hides lock icon)

View File

@ -0,0 +1 @@
* Support for ?register and ?register;preauth XMPP uri parameters

View File

@ -0,0 +1,47 @@
Easy to use, reliable, battery friendly. With built-in support for images, group chats and e2e encryption.
Design principles:
* Be as beautiful and easy to use as possible without sacrificing security or privacy
* Rely on existing, well established protocols
* Do not require a Google Account or specifically Google Cloud Messaging (GCM)
* Require as few permissions as possible
Features:
* End-to-end encryption with either <a href="http://conversations.im/omemo/">OMEMO</a> or <a href="http://openpgp.org/about/">OpenPGP</a>
* Sending and receiving images
* Intuitive UI that follows Android Design guidelines
* Pictures / Avatars for your Contacts
* Syncs with desktop client
* Conferences (with support for bookmarks)
* Address book integration
* Multiple accounts / unified inbox
* Very low impact on battery life
Conversations makes it very easy to create an account on the conversations.im
server. Using that server comes with an annual fee of 8 Euro after a 6 month
trial period. However Conversations will work with any other XMPP server as
well. A lot of XMPP servers are run by volunteers and are free of charge.
XMPP Features:
Conversations works with every XMPP server out there. However XMPP is an
extensible protocol. These extensions are standardized as well in so called
XEPs. Conversations supports a couple of those to make the overall user
experience better. There is a chance that your current XMPP server does not
support these extensions. Therefore to get the most out of Conversations you
should consider either switching to an XMPP server that does or - even better -
run your own XMPP server for you and your friends.
These XEPs are - as of now:
* XEP-0065: SOCKS5 Bytestreams (or mod_proxy65). Will be used to transfer files if both parties are behind a firewall (NAT).
* XEP-0163: Personal Eventing Protocol for avatars
* XEP-0191: Blocking command lets you blacklist spammers or block contacts without removing them from your roster.
* XEP-0198: Stream Management allows XMPP to survive small network outages and changes of the underlying TCP connection.
* XEP-0280: Message Carbons which automatically syncs the messages you send to your desktop client and thus allows you to switch seamlessly from your mobile client to your desktop client and back within one conversation.
* XEP-0237: Roster Versioning mainly to save bandwidth on poor mobile connections
* XEP-0313: Message Archive Management synchronize message history with the server. Catch up with messages that were sent while Conversations was offline.
* XEP-0352: Client State Indication lets the server know whether or not Conversations is in the background. Allows the server to save bandwidth by withholding unimportant packages.
* XEP-0363: HTTP File Upload allows you to share files in conferences and with offline contacts. Requires an additional component on your server.

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 449 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 475 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 339 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 391 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 KiB

View File

@ -0,0 +1 @@
An encrypted, user friendly XMPP instant messaging client optimized for mobile

BIN
screenshots.xcf Normal file

Binary file not shown.

View File

@ -52,12 +52,12 @@ public class WelcomeActivity extends XmppActivity {
private boolean processXmppUri(final XmppUri xmppUri) { private boolean processXmppUri(final XmppUri xmppUri) {
if (xmppUri.isValidJid()) { if (xmppUri.isValidJid()) {
final String preauth = xmppUri.getParamater("preauth"); final String preauth = xmppUri.getParameter("preauth");
final Jid jid = xmppUri.getJid(); final Jid jid = xmppUri.getJid();
final Intent intent; final Intent intent;
if (xmppUri.isAction(XmppUri.ACTION_REGISTER)) { if (xmppUri.isAction(XmppUri.ACTION_REGISTER)) {
intent = SignupUtils.getTokenRegistrationIntent(this, jid, preauth); intent = SignupUtils.getTokenRegistrationIntent(this, jid, preauth);
} else if (xmppUri.isAction(XmppUri.ACTION_ROSTER) && "y".equals(xmppUri.getParamater("ibr"))) { } else if (xmppUri.isAction(XmppUri.ACTION_ROSTER) && "y".equals(xmppUri.getParameter("ibr"))) {
intent = SignupUtils.getTokenRegistrationIntent(this, Jid.ofDomain(jid.getDomain()), preauth); intent = SignupUtils.getTokenRegistrationIntent(this, Jid.ofDomain(jid.getDomain()), preauth);
intent.putExtra(StartConversationActivity.EXTRA_INVITE_URI, xmppUri.toString()); intent.putExtra(StartConversationActivity.EXTRA_INVITE_URI, xmppUri.toString());
} else { } else {

View File

@ -5,4 +5,7 @@
<string name="create_new_account">Új fiók létrehozása</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="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 chat.sum7.eu szolgáltatón való fióklétrehozást, ami kifejezetten a Conversations alkalmazással történő használatra lett tervezve.</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 chat.sum7.eu 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>
</resources> </resources>

View File

@ -5,4 +5,7 @@
<string name="create_new_account">Stwórz nowe konto</string> <string name="create_new_account">Stwó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="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 chat.sum7.eu; dostawcy specjalnie dostosowanego do pracy z Conversations.</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 chat.sum7.eu; 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>
</resources> </resources>

View File

@ -125,7 +125,7 @@ public final class Config {
public static final long MAM_MAX_CATCHUP = MILLISECONDS_IN_DAY * 5; public static final long MAM_MAX_CATCHUP = MILLISECONDS_IN_DAY * 5;
public static final int MAM_MAX_MESSAGES = 750; public static final int MAM_MAX_MESSAGES = 750;
public static final ChatState DEFAULT_CHATSTATE = ChatState.ACTIVE; public static final ChatState DEFAULT_CHAT_STATE = ChatState.ACTIVE;
public static final int TYPING_TIMEOUT = 8; public static final int TYPING_TIMEOUT = 8;
public static final int EXPIRY_INTERVAL = 30 * 60 * 1000; // 30 minutes public static final int EXPIRY_INTERVAL = 30 * 60 * 1000; // 30 minutes

View File

@ -0,0 +1,8 @@
package eu.siacs.conversations.crypto.axolotl;
public class OutdatedSenderException extends CryptoFailedException {
public OutdatedSenderException(final String msg) {
super(msg);
}
}

View File

@ -27,294 +27,293 @@ import eu.siacs.conversations.xml.Element;
import rocks.xmpp.addr.Jid; import rocks.xmpp.addr.Jid;
public class XmppAxolotlMessage { public class XmppAxolotlMessage {
public static final String CONTAINERTAG = "encrypted"; public static final String CONTAINERTAG = "encrypted";
private static final String HEADER = "header"; private static final String HEADER = "header";
private static final String SOURCEID = "sid"; private static final String SOURCEID = "sid";
private static final String KEYTAG = "key"; private static final String KEYTAG = "key";
private static final String REMOTEID = "rid"; private static final String REMOTEID = "rid";
private static final String IVTAG = "iv"; private static final String IVTAG = "iv";
private static final String PAYLOAD = "payload"; private static final String PAYLOAD = "payload";
private static final String KEYTYPE = "AES"; private static final String KEYTYPE = "AES";
private static final String CIPHERMODE = "AES/GCM/NoPadding"; private static final String CIPHERMODE = "AES/GCM/NoPadding";
private static final String PROVIDER = "BC"; private static final String PROVIDER = "BC";
private final List<XmppAxolotlSession.AxolotlKey> keys;
private final Jid from;
private final int sourceDeviceId;
private byte[] innerKey;
private byte[] ciphertext = null;
private byte[] authtagPlusInnerKey = null;
private byte[] iv = null;
private byte[] innerKey; private XmppAxolotlMessage(final Element axolotlMessage, final Jid from) throws IllegalArgumentException {
private byte[] ciphertext = null; this.from = from;
private byte[] authtagPlusInnerKey = null; Element header = axolotlMessage.findChild(HEADER);
private byte[] iv = null; try {
private final List<XmppAxolotlSession.AxolotlKey> keys; this.sourceDeviceId = Integer.parseInt(header.getAttribute(SOURCEID));
private final Jid from; } catch (NumberFormatException e) {
private final int sourceDeviceId; throw new IllegalArgumentException("invalid source id");
}
List<Element> keyElements = header.getChildren();
this.keys = new ArrayList<>();
for (Element keyElement : keyElements) {
switch (keyElement.getName()) {
case KEYTAG:
try {
Integer recipientId = Integer.parseInt(keyElement.getAttribute(REMOTEID));
byte[] key = Base64.decode(keyElement.getContent().trim(), Base64.DEFAULT);
boolean isPreKey = keyElement.getAttributeAsBoolean("prekey");
this.keys.add(new XmppAxolotlSession.AxolotlKey(recipientId, key, isPreKey));
} catch (NumberFormatException e) {
throw new IllegalArgumentException("invalid remote id");
}
break;
case IVTAG:
if (this.iv != null) {
throw new IllegalArgumentException("Duplicate iv entry");
}
iv = Base64.decode(keyElement.getContent().trim(), Base64.DEFAULT);
break;
default:
Log.w(Config.LOGTAG, "Unexpected element in header: " + keyElement.toString());
break;
}
}
final Element payloadElement = axolotlMessage.findChildEnsureSingle(PAYLOAD, AxolotlService.PEP_PREFIX);
if (payloadElement != null) {
ciphertext = Base64.decode(payloadElement.getContent().trim(), Base64.DEFAULT);
}
}
public static class XmppAxolotlPlaintextMessage { XmppAxolotlMessage(Jid from, int sourceDeviceId) {
private final String plaintext; this.from = from;
private final String fingerprint; this.sourceDeviceId = sourceDeviceId;
this.keys = new ArrayList<>();
this.iv = generateIv();
this.innerKey = generateKey();
}
XmppAxolotlPlaintextMessage(String plaintext, String fingerprint) { public static int parseSourceId(final Element axolotlMessage) throws IllegalArgumentException {
this.plaintext = plaintext; final Element header = axolotlMessage.findChild(HEADER);
this.fingerprint = fingerprint; if (header == null) {
} throw new IllegalArgumentException("No header found");
}
try {
return Integer.parseInt(header.getAttribute(SOURCEID));
} catch (NumberFormatException e) {
throw new IllegalArgumentException("invalid source id");
}
}
public String getPlaintext() { public static XmppAxolotlMessage fromElement(Element element, Jid from) {
return plaintext; return new XmppAxolotlMessage(element, from);
} }
private static byte[] generateKey() {
try {
KeyGenerator generator = KeyGenerator.getInstance(KEYTYPE);
generator.init(128);
return generator.generateKey().getEncoded();
} catch (NoSuchAlgorithmException e) {
Log.e(Config.LOGTAG, e.getMessage());
return null;
}
}
private static byte[] generateIv() {
final SecureRandom random = new SecureRandom();
byte[] iv = new byte[Config.TWELVE_BYTE_IV ? 12 : 16];
random.nextBytes(iv);
return iv;
}
private static byte[] getPaddedBytes(String plaintext) {
int plainLength = plaintext.getBytes().length;
int pad = Math.max(64, (plainLength / 32 + 1) * 32) - plainLength;
SecureRandom random = new SecureRandom();
int left = random.nextInt(pad);
int right = pad - left;
StringBuilder builder = new StringBuilder(plaintext);
for (int i = 0; i < left; ++i) {
builder.insert(0, random.nextBoolean() ? "\t" : " ");
}
for (int i = 0; i < right; ++i) {
builder.append(random.nextBoolean() ? "\t" : " ");
}
return builder.toString().getBytes();
}
public boolean hasPayload() {
return ciphertext != null;
}
void encrypt(String plaintext) throws CryptoFailedException {
try {
SecretKey secretKey = new SecretKeySpec(innerKey, KEYTYPE);
IvParameterSpec ivSpec = new IvParameterSpec(iv);
Cipher cipher = Compatibility.twentyEight() ? Cipher.getInstance(CIPHERMODE) : Cipher.getInstance(CIPHERMODE, PROVIDER);
cipher.init(Cipher.ENCRYPT_MODE, secretKey, ivSpec);
this.ciphertext = cipher.doFinal(Config.OMEMO_PADDING ? getPaddedBytes(plaintext) : plaintext.getBytes());
if (Config.PUT_AUTH_TAG_INTO_KEY && this.ciphertext != null) {
this.authtagPlusInnerKey = new byte[16 + 16];
byte[] ciphertext = new byte[this.ciphertext.length - 16];
System.arraycopy(this.ciphertext, 0, ciphertext, 0, ciphertext.length);
System.arraycopy(this.ciphertext, ciphertext.length, authtagPlusInnerKey, 16, 16);
System.arraycopy(this.innerKey, 0, authtagPlusInnerKey, 0, this.innerKey.length);
this.ciphertext = ciphertext;
}
} catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException
| IllegalBlockSizeException | BadPaddingException | NoSuchProviderException
| InvalidAlgorithmParameterException e) {
throw new CryptoFailedException(e);
}
}
public Jid getFrom() {
return this.from;
}
int getSenderDeviceId() {
return sourceDeviceId;
}
void addDevice(XmppAxolotlSession session) {
addDevice(session, false);
}
void addDevice(XmppAxolotlSession session, boolean ignoreSessionTrust) {
XmppAxolotlSession.AxolotlKey key;
if (authtagPlusInnerKey != null) {
key = session.processSending(authtagPlusInnerKey, ignoreSessionTrust);
} else {
key = session.processSending(innerKey, ignoreSessionTrust);
}
if (key != null) {
keys.add(key);
}
}
public byte[] getInnerKey() {
return innerKey;
}
public byte[] getIV() {
return this.iv;
}
public Element toElement() {
Element encryptionElement = new Element(CONTAINERTAG, AxolotlService.PEP_PREFIX);
Element headerElement = encryptionElement.addChild(HEADER);
headerElement.setAttribute(SOURCEID, sourceDeviceId);
for (XmppAxolotlSession.AxolotlKey key : keys) {
Element keyElement = new Element(KEYTAG);
keyElement.setAttribute(REMOTEID, key.deviceId);
if (key.prekey) {
keyElement.setAttribute("prekey", "true");
}
keyElement.setContent(Base64.encodeToString(key.key, Base64.NO_WRAP));
headerElement.addChild(keyElement);
}
headerElement.addChild(IVTAG).setContent(Base64.encodeToString(iv, Base64.NO_WRAP));
if (ciphertext != null) {
Element payload = encryptionElement.addChild(PAYLOAD);
payload.setContent(Base64.encodeToString(ciphertext, Base64.NO_WRAP));
}
return encryptionElement;
}
private byte[] unpackKey(XmppAxolotlSession session, Integer sourceDeviceId) throws CryptoFailedException {
ArrayList<XmppAxolotlSession.AxolotlKey> possibleKeys = new ArrayList<>();
for (XmppAxolotlSession.AxolotlKey key : keys) {
if (key.deviceId == sourceDeviceId) {
possibleKeys.add(key);
}
}
if (possibleKeys.size() == 0) {
throw new NotEncryptedForThisDeviceException();
}
return session.processReceiving(possibleKeys);
}
XmppAxolotlKeyTransportMessage getParameters(XmppAxolotlSession session, Integer sourceDeviceId) throws CryptoFailedException {
return new XmppAxolotlKeyTransportMessage(session.getFingerprint(), unpackKey(session, sourceDeviceId), getIV());
}
public XmppAxolotlPlaintextMessage decrypt(XmppAxolotlSession session, Integer sourceDeviceId) throws CryptoFailedException {
XmppAxolotlPlaintextMessage plaintextMessage = null;
byte[] key = unpackKey(session, sourceDeviceId);
if (key != null) {
try {
if (key.length < 32) {
throw new OutdatedSenderException("Key did not contain auth tag. Sender needs to update their OMEMO client");
}
final int authTagLength = key.length - 16;
byte[] newCipherText = new byte[key.length - 16 + ciphertext.length];
byte[] newKey = new byte[16];
System.arraycopy(ciphertext, 0, newCipherText, 0, ciphertext.length);
System.arraycopy(key, 16, newCipherText, ciphertext.length, authTagLength);
System.arraycopy(key, 0, newKey, 0, newKey.length);
ciphertext = newCipherText;
key = newKey;
final Cipher cipher = Compatibility.twentyEight() ? Cipher.getInstance(CIPHERMODE) : Cipher.getInstance(CIPHERMODE, PROVIDER);
SecretKeySpec keySpec = new SecretKeySpec(key, KEYTYPE);
IvParameterSpec ivSpec = new IvParameterSpec(iv);
cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec);
String plaintext = new String(cipher.doFinal(ciphertext));
plaintextMessage = new XmppAxolotlPlaintextMessage(Config.OMEMO_PADDING ? plaintext.trim() : plaintext, session.getFingerprint());
} catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException
| InvalidAlgorithmParameterException | IllegalBlockSizeException
| BadPaddingException | NoSuchProviderException e) {
throw new CryptoFailedException(e);
}
}
return plaintextMessage;
}
public static class XmppAxolotlPlaintextMessage {
private final String plaintext;
private final String fingerprint;
XmppAxolotlPlaintextMessage(String plaintext, String fingerprint) {
this.plaintext = plaintext;
this.fingerprint = fingerprint;
}
public String getPlaintext() {
return plaintext;
}
public String getFingerprint() { public String getFingerprint() {
return fingerprint; return fingerprint;
} }
} }
public static class XmppAxolotlKeyTransportMessage { public static class XmppAxolotlKeyTransportMessage {
private final String fingerprint; private final String fingerprint;
private final byte[] key; private final byte[] key;
private final byte[] iv; private final byte[] iv;
XmppAxolotlKeyTransportMessage(String fingerprint, byte[] key, byte[] iv) { XmppAxolotlKeyTransportMessage(String fingerprint, byte[] key, byte[] iv) {
this.fingerprint = fingerprint; this.fingerprint = fingerprint;
this.key = key; this.key = key;
this.iv = iv; this.iv = iv;
} }
public String getFingerprint() { public String getFingerprint() {
return fingerprint; return fingerprint;
} }
public byte[] getKey() { public byte[] getKey() {
return key; return key;
} }
public byte[] getIv() { public byte[] getIv() {
return iv; return iv;
} }
} }
public static int parseSourceId(final Element axolotlMessage) throws IllegalArgumentException {
final Element header = axolotlMessage.findChild(HEADER);
if (header == null) {
throw new IllegalArgumentException("No header found");
}
try {
return Integer.parseInt(header.getAttribute(SOURCEID));
} catch (NumberFormatException e) {
throw new IllegalArgumentException("invalid source id");
}
}
private XmppAxolotlMessage(final Element axolotlMessage, final Jid from) throws IllegalArgumentException {
this.from = from;
Element header = axolotlMessage.findChild(HEADER);
try {
this.sourceDeviceId = Integer.parseInt(header.getAttribute(SOURCEID));
} catch (NumberFormatException e) {
throw new IllegalArgumentException("invalid source id");
}
List<Element> keyElements = header.getChildren();
this.keys = new ArrayList<>();
for (Element keyElement : keyElements) {
switch (keyElement.getName()) {
case KEYTAG:
try {
Integer recipientId = Integer.parseInt(keyElement.getAttribute(REMOTEID));
byte[] key = Base64.decode(keyElement.getContent().trim(), Base64.DEFAULT);
boolean isPreKey =keyElement.getAttributeAsBoolean("prekey");
this.keys.add(new XmppAxolotlSession.AxolotlKey(recipientId, key,isPreKey));
} catch (NumberFormatException e) {
throw new IllegalArgumentException("invalid remote id");
}
break;
case IVTAG:
if (this.iv != null) {
throw new IllegalArgumentException("Duplicate iv entry");
}
iv = Base64.decode(keyElement.getContent().trim(), Base64.DEFAULT);
break;
default:
Log.w(Config.LOGTAG, "Unexpected element in header: " + keyElement.toString());
break;
}
}
final Element payloadElement = axolotlMessage.findChildEnsureSingle(PAYLOAD, AxolotlService.PEP_PREFIX);
if (payloadElement != null) {
ciphertext = Base64.decode(payloadElement.getContent().trim(), Base64.DEFAULT);
}
}
XmppAxolotlMessage(Jid from, int sourceDeviceId) {
this.from = from;
this.sourceDeviceId = sourceDeviceId;
this.keys = new ArrayList<>();
this.iv = generateIv();
this.innerKey = generateKey();
}
public static XmppAxolotlMessage fromElement(Element element, Jid from) {
return new XmppAxolotlMessage(element, from);
}
private static byte[] generateKey() {
try {
KeyGenerator generator = KeyGenerator.getInstance(KEYTYPE);
generator.init(128);
return generator.generateKey().getEncoded();
} catch (NoSuchAlgorithmException e) {
Log.e(Config.LOGTAG, e.getMessage());
return null;
}
}
private static byte[] generateIv() {
final SecureRandom random = new SecureRandom();
byte[] iv = new byte[Config.TWELVE_BYTE_IV ? 12 : 16];
random.nextBytes(iv);
return iv;
}
public boolean hasPayload() {
return ciphertext != null;
}
void encrypt(String plaintext) throws CryptoFailedException {
try {
SecretKey secretKey = new SecretKeySpec(innerKey, KEYTYPE);
IvParameterSpec ivSpec = new IvParameterSpec(iv);
Cipher cipher = Compatibility.twentyEight() ? Cipher.getInstance(CIPHERMODE) : Cipher.getInstance(CIPHERMODE, PROVIDER);
cipher.init(Cipher.ENCRYPT_MODE, secretKey, ivSpec);
this.ciphertext = cipher.doFinal(Config.OMEMO_PADDING ? getPaddedBytes(plaintext) : plaintext.getBytes());
if (Config.PUT_AUTH_TAG_INTO_KEY && this.ciphertext != null) {
this.authtagPlusInnerKey = new byte[16+16];
byte[] ciphertext = new byte[this.ciphertext.length - 16];
System.arraycopy(this.ciphertext,0,ciphertext,0,ciphertext.length);
System.arraycopy(this.ciphertext,ciphertext.length,authtagPlusInnerKey,16,16);
System.arraycopy(this.innerKey,0,authtagPlusInnerKey,0,this.innerKey.length);
this.ciphertext = ciphertext;
}
} catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException
| IllegalBlockSizeException | BadPaddingException | NoSuchProviderException
| InvalidAlgorithmParameterException e) {
throw new CryptoFailedException(e);
}
}
private static byte[] getPaddedBytes(String plaintext) {
int plainLength = plaintext.getBytes().length;
int pad = Math.max(64,(plainLength / 32 + 1) * 32) - plainLength;
SecureRandom random = new SecureRandom();
int left = random.nextInt(pad);
int right = pad - left;
StringBuilder builder = new StringBuilder(plaintext);
for(int i = 0; i < left; ++i) {
builder.insert(0,random.nextBoolean() ? "\t" : " ");
}
for(int i = 0; i < right; ++i) {
builder.append(random.nextBoolean() ? "\t" : " ");
}
return builder.toString().getBytes();
}
public Jid getFrom() {
return this.from;
}
int getSenderDeviceId() {
return sourceDeviceId;
}
void addDevice(XmppAxolotlSession session) {
addDevice(session, false);
}
void addDevice(XmppAxolotlSession session, boolean ignoreSessionTrust) {
XmppAxolotlSession.AxolotlKey key;
if (authtagPlusInnerKey != null) {
key = session.processSending(authtagPlusInnerKey, ignoreSessionTrust);
} else {
key = session.processSending(innerKey, ignoreSessionTrust);
}
if (key != null) {
keys.add(key);
}
}
public byte[] getInnerKey() {
return innerKey;
}
public byte[] getIV() {
return this.iv;
}
public Element toElement() {
Element encryptionElement = new Element(CONTAINERTAG, AxolotlService.PEP_PREFIX);
Element headerElement = encryptionElement.addChild(HEADER);
headerElement.setAttribute(SOURCEID, sourceDeviceId);
for(XmppAxolotlSession.AxolotlKey key : keys) {
Element keyElement = new Element(KEYTAG);
keyElement.setAttribute(REMOTEID, key.deviceId);
if (key.prekey) {
keyElement.setAttribute("prekey","true");
}
keyElement.setContent(Base64.encodeToString(key.key, Base64.NO_WRAP));
headerElement.addChild(keyElement);
}
headerElement.addChild(IVTAG).setContent(Base64.encodeToString(iv, Base64.NO_WRAP));
if (ciphertext != null) {
Element payload = encryptionElement.addChild(PAYLOAD);
payload.setContent(Base64.encodeToString(ciphertext, Base64.NO_WRAP));
}
return encryptionElement;
}
private byte[] unpackKey(XmppAxolotlSession session, Integer sourceDeviceId) throws CryptoFailedException {
ArrayList<XmppAxolotlSession.AxolotlKey> possibleKeys = new ArrayList<>();
for(XmppAxolotlSession.AxolotlKey key : keys) {
if (key.deviceId == sourceDeviceId) {
possibleKeys.add(key);
}
}
if (possibleKeys.size() == 0) {
throw new NotEncryptedForThisDeviceException();
}
return session.processReceiving(possibleKeys);
}
XmppAxolotlKeyTransportMessage getParameters(XmppAxolotlSession session, Integer sourceDeviceId) throws CryptoFailedException {
return new XmppAxolotlKeyTransportMessage(session.getFingerprint(), unpackKey(session, sourceDeviceId), getIV());
}
public XmppAxolotlPlaintextMessage decrypt(XmppAxolotlSession session, Integer sourceDeviceId) throws CryptoFailedException {
XmppAxolotlPlaintextMessage plaintextMessage = null;
byte[] key = unpackKey(session, sourceDeviceId);
if (key != null) {
try {
if (key.length >= 32) {
int authtaglength = key.length - 16;
Log.d(Config.LOGTAG,"found auth tag as part of omemo key");
byte[] newCipherText = new byte[key.length - 16 + ciphertext.length];
byte[] newKey = new byte[16];
System.arraycopy(ciphertext, 0, newCipherText, 0, ciphertext.length);
System.arraycopy(key, 16, newCipherText, ciphertext.length, authtaglength);
System.arraycopy(key,0,newKey,0,newKey.length);
ciphertext = newCipherText;
key = newKey;
}
Cipher cipher = Compatibility.twentyEight() ? Cipher.getInstance(CIPHERMODE) : Cipher.getInstance(CIPHERMODE, PROVIDER);
SecretKeySpec keySpec = new SecretKeySpec(key, KEYTYPE);
IvParameterSpec ivSpec = new IvParameterSpec(iv);
cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec);
String plaintext = new String(cipher.doFinal(ciphertext));
plaintextMessage = new XmppAxolotlPlaintextMessage(Config.OMEMO_PADDING ? plaintext.trim() : plaintext, session.getFingerprint());
} catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException
| InvalidAlgorithmParameterException | IllegalBlockSizeException
| BadPaddingException | NoSuchProviderException e) {
throw new CryptoFailedException(e);
}
}
return plaintextMessage;
}
} }

View File

@ -76,8 +76,8 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
private Jid nextCounterpart; private Jid nextCounterpart;
private transient MucOptions mucOptions = null; private transient MucOptions mucOptions = null;
private boolean messagesLeftOnServer = true; private boolean messagesLeftOnServer = true;
private ChatState mOutgoingChatState = Config.DEFAULT_CHATSTATE; private ChatState mOutgoingChatState = Config.DEFAULT_CHAT_STATE;
private ChatState mIncomingChatState = Config.DEFAULT_CHATSTATE; private ChatState mIncomingChatState = Config.DEFAULT_CHAT_STATE;
private String mFirstMamReference = null; private String mFirstMamReference = null;
public Conversation(final String name, final Account account, final Jid contactJid, public Conversation(final String name, final Account account, final Jid contactJid,

View File

@ -94,7 +94,7 @@ public class MucOptions {
public void resetChatState() { public void resetChatState() {
synchronized (users) { synchronized (users) {
for (User user : users) { for (User user : users) {
user.chatState = Config.DEFAULT_CHATSTATE; user.chatState = Config.DEFAULT_CHAT_STATE;
} }
} }
} }
@ -746,7 +746,7 @@ public class MucOptions {
private long pgpKeyId = 0; private long pgpKeyId = 0;
private Avatar avatar; private Avatar avatar;
private MucOptions options; private MucOptions options;
private ChatState chatState = Config.DEFAULT_CHATSTATE; private ChatState chatState = Config.DEFAULT_CHAT_STATE;
public User(MucOptions options, Jid fullJid) { public User(MucOptions options, Jid fullJid) {
this.options = options; this.options = options;

View File

@ -31,493 +31,487 @@ import eu.siacs.conversations.services.XmppConnectionService;
import eu.siacs.conversations.utils.CryptoHelper; import eu.siacs.conversations.utils.CryptoHelper;
import eu.siacs.conversations.utils.FileWriterException; import eu.siacs.conversations.utils.FileWriterException;
import eu.siacs.conversations.utils.WakeLockHelper; import eu.siacs.conversations.utils.WakeLockHelper;
import eu.siacs.conversations.xmpp.OnIqPacketReceived;
import eu.siacs.conversations.xmpp.stanzas.IqPacket; import eu.siacs.conversations.xmpp.stanzas.IqPacket;
import rocks.xmpp.addr.Jid; import rocks.xmpp.addr.Jid;
public class HttpDownloadConnection implements Transferable { public class HttpDownloadConnection implements Transferable {
private HttpConnectionManager mHttpConnectionManager; private final Message message;
private XmppConnectionService mXmppConnectionService; private final boolean mUseTor;
private HttpConnectionManager mHttpConnectionManager;
private XmppConnectionService mXmppConnectionService;
private URL mUrl;
private DownloadableFile file;
private int mStatus = Transferable.STATUS_UNKNOWN;
private boolean acceptedAutomatically = false;
private int mProgress = 0;
private boolean canceled = false;
private Method method = Method.HTTP_UPLOAD;
private URL mUrl; HttpDownloadConnection(Message message, HttpConnectionManager manager) {
private final Message message; this.message = message;
private DownloadableFile file; this.mHttpConnectionManager = manager;
private int mStatus = Transferable.STATUS_UNKNOWN; this.mXmppConnectionService = manager.getXmppConnectionService();
private boolean acceptedAutomatically = false; this.mUseTor = mXmppConnectionService.useTorToConnect();
private int mProgress = 0; }
private final boolean mUseTor;
private boolean canceled = false;
private Method method = Method.HTTP_UPLOAD;
HttpDownloadConnection(Message message, HttpConnectionManager manager) { @Override
this.message = message; public boolean start() {
this.mHttpConnectionManager = manager; if (mXmppConnectionService.hasInternetConnection()) {
this.mXmppConnectionService = manager.getXmppConnectionService(); if (this.mStatus == STATUS_OFFER_CHECK_FILESIZE) {
this.mUseTor = mXmppConnectionService.useTorToConnect(); checkFileSize(true);
} } else {
download(true);
}
return true;
} else {
return false;
}
}
@Override public void init(boolean interactive) {
public boolean start() { if (message.isDeleted()) {
if (mXmppConnectionService.hasInternetConnection()) { if (message.getType() == Message.TYPE_PRIVATE_FILE) {
if (this.mStatus == STATUS_OFFER_CHECK_FILESIZE) { message.setType(Message.TYPE_PRIVATE);
checkFileSize(true); } else if (message.isFileOrImage()) {
} else { message.setType(Message.TYPE_TEXT);
download(true); }
} message.setOob(true);
return true; message.setDeleted(false);
} else { mXmppConnectionService.updateMessage(message);
return false; }
} this.message.setTransferable(this);
} try {
final Message.FileParams fileParams = message.getFileParams();
public void init(boolean interactive) { if (message.hasFileOnRemoteHost()) {
if (message.isDeleted()) { mUrl = CryptoHelper.toHttpsUrl(fileParams.url);
if (message.getType() == Message.TYPE_PRIVATE_FILE) { } else if (message.isOOb() && fileParams.url != null && fileParams.size > 0) {
message.setType(Message.TYPE_PRIVATE); mUrl = fileParams.url;
} else if (message.isFileOrImage()) { } else {
message.setType(Message.TYPE_TEXT); mUrl = CryptoHelper.toHttpsUrl(new URL(message.getBody().split("\n")[0]));
} }
message.setDeleted(false); final AbstractConnectionManager.Extension extension = AbstractConnectionManager.Extension.of(mUrl.getPath());
mXmppConnectionService.updateMessage(message); if (VALID_CRYPTO_EXTENSIONS.contains(extension.main)) {
} this.message.setEncryption(Message.ENCRYPTION_PGP);
this.message.setTransferable(this); } else if (message.getEncryption() != Message.ENCRYPTION_OTR
try { && message.getEncryption() != Message.ENCRYPTION_AXOLOTL) {
final Message.FileParams fileParams = message.getFileParams(); this.message.setEncryption(Message.ENCRYPTION_NONE);
if (message.hasFileOnRemoteHost()) { }
mUrl = CryptoHelper.toHttpsUrl(fileParams.url); final String ext;
} else if (message.isOOb() && fileParams.url != null && fileParams.size > 0) { if (VALID_CRYPTO_EXTENSIONS.contains(extension.main)) {
mUrl = fileParams.url; ext = extension.secondary;
} else { } else {
mUrl = CryptoHelper.toHttpsUrl(new URL(message.getBody().split("\n")[0])); ext = extension.main;
} }
final AbstractConnectionManager.Extension extension = AbstractConnectionManager.Extension.of(mUrl.getPath()); message.setRelativeFilePath(message.getUuid() + (ext != null ? ("." + ext) : ""));
if (VALID_CRYPTO_EXTENSIONS.contains(extension.main)) { final String reference = mUrl.getRef();
this.message.setEncryption(Message.ENCRYPTION_PGP); if (reference != null && AesGcmURLStreamHandler.IV_KEY.matcher(reference).matches()) {
} else if (message.getEncryption() != Message.ENCRYPTION_OTR this.file = new DownloadableFile(mXmppConnectionService.getCacheDir().getAbsolutePath() + "/" + message.getUuid());
&& message.getEncryption() != Message.ENCRYPTION_AXOLOTL) { this.file.setKeyAndIv(CryptoHelper.hexToBytes(reference));
this.message.setEncryption(Message.ENCRYPTION_NONE); Log.d(Config.LOGTAG, "create temporary OMEMO encrypted file: " + this.file.getAbsolutePath() + "(" + message.getMimeType() + ")");
} } else {
final String ext; this.file = mXmppConnectionService.getFileBackend().getFile(message, false);
if (VALID_CRYPTO_EXTENSIONS.contains(extension.main)) { }
ext = extension.secondary;
} else {
ext = extension.main;
}
message.setRelativeFilePath(message.getUuid() + (ext != null ? ("." + ext) : ""));
if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL) {
this.file = new DownloadableFile(mXmppConnectionService.getCacheDir().getAbsolutePath() + "/" + message.getUuid());
Log.d(Config.LOGTAG, "create temporary OMEMO encrypted file: " + this.file.getAbsolutePath() + "(" + message.getMimeType() + ")");
} else {
this.file = mXmppConnectionService.getFileBackend().getFile(message, false);
}
final String reference = mUrl.getRef();
if (reference != null && AesGcmURLStreamHandler.IV_KEY.matcher(reference).matches()) {
this.file.setKeyAndIv(CryptoHelper.hexToBytes(reference));
}
if (this.message.getEncryption() == Message.ENCRYPTION_AXOLOTL && this.file.getKey() == null) {
this.message.setEncryption(Message.ENCRYPTION_NONE);
}
method = mUrl.getProtocol().equalsIgnoreCase(P1S3UrlStreamHandler.PROTOCOL_NAME) ? Method.P1_S3 : Method.HTTP_UPLOAD;
long knownFileSize = message.getFileParams().size;
if (knownFileSize > 0 && interactive && method != Method.P1_S3) {
this.file.setExpectedSize(knownFileSize);
download(true);
} else {
checkFileSize(interactive);
}
} catch (MalformedURLException e) {
this.cancel();
}
}
private void download(boolean interactive) {
new Thread(new FileDownloader(interactive)).start();
}
private void checkFileSize(boolean interactive) {
new Thread(new FileSizeChecker(interactive)).start();
}
@Override
public void cancel() {
this.canceled = true;
mHttpConnectionManager.finishConnection(this);
message.setTransferable(null);
if (message.isFileOrImage()) {
message.setDeleted(true);
}
mHttpConnectionManager.updateConversationUi(true);
}
private void decryptOmemoFile() {
final DownloadableFile outputFile = mXmppConnectionService.getFileBackend().getFile(message, true);
if (outputFile.getParentFile().mkdirs()) {
Log.d(Config.LOGTAG, "created parent directories for " + outputFile.getAbsolutePath());
}
try {
outputFile.createNewFile();
final InputStream is = new FileInputStream(this.file);
outputFile.setKey(this.file.getKey());
outputFile.setIv(this.file.getIv());
final OutputStream os = AbstractConnectionManager.createOutputStream(outputFile, false, true);
ByteStreams.copy(is, os);
FileBackend.close(is);
FileBackend.close(os);
if (!file.delete()) {
Log.w(Config.LOGTAG,"unable to delete temporary OMEMO encrypted file " + file.getAbsolutePath());
}
message.setRelativeFilePath(outputFile.getPath());
} catch (IOException e) {
message.setEncryption(Message.ENCRYPTION_DECRYPTION_FAILED);
mXmppConnectionService.updateMessage(message);
}
}
private void finish() {
message.setTransferable(null);
mHttpConnectionManager.finishConnection(this);
boolean notify = acceptedAutomatically && !message.isRead();
if (message.getEncryption() == Message.ENCRYPTION_PGP) {
notify = message.getConversation().getAccount().getPgpDecryptionService().decrypt(message, notify);
}
mHttpConnectionManager.updateConversationUi(true);
final boolean notifyAfterScan = notify;
final DownloadableFile file = mXmppConnectionService.getFileBackend().getFile(message, true);
mXmppConnectionService.getFileBackend().updateMediaScanner(file, () -> {
if (notifyAfterScan) {
mXmppConnectionService.getNotificationService().push(message);
}
});
}
private void decryptIfNeeded() {
if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL) {
decryptOmemoFile();
}
}
private void changeStatus(int status) {
this.mStatus = status;
mHttpConnectionManager.updateConversationUi(true);
}
private void showToastForException(Exception e) {
if (e instanceof java.net.UnknownHostException) {
mXmppConnectionService.showErrorToastInUi(R.string.download_failed_server_not_found);
} else if (e instanceof java.net.ConnectException) {
mXmppConnectionService.showErrorToastInUi(R.string.download_failed_could_not_connect);
} else if (e instanceof FileWriterException) {
mXmppConnectionService.showErrorToastInUi(R.string.download_failed_could_not_write_file);
} else if (!(e instanceof CancellationException)) {
mXmppConnectionService.showErrorToastInUi(R.string.download_failed_file_not_found);
}
}
private void updateProgress(long i) {
this.mProgress = (int) i;
mHttpConnectionManager.updateConversationUi(false);
}
@Override
public int getStatus() {
return this.mStatus;
}
@Override
public long getFileSize() {
if (this.file != null) {
return this.file.getExpectedSize();
} else {
return 0;
}
}
@Override
public int getProgress() {
return this.mProgress;
}
public Message getMessage() {
return message;
}
private class FileSizeChecker implements Runnable {
private final boolean interactive;
FileSizeChecker(boolean interactive) {
this.interactive = interactive;
}
@Override if (this.message.getEncryption() == Message.ENCRYPTION_AXOLOTL && this.file.getKey() == null) {
public void run() { this.message.setEncryption(Message.ENCRYPTION_NONE);
if (mUrl.getProtocol().equalsIgnoreCase(P1S3UrlStreamHandler.PROTOCOL_NAME)) { }
retrieveUrl(); method = mUrl.getProtocol().equalsIgnoreCase(P1S3UrlStreamHandler.PROTOCOL_NAME) ? Method.P1_S3 : Method.HTTP_UPLOAD;
} else { long knownFileSize = message.getFileParams().size;
check(); if (knownFileSize > 0 && interactive && method != Method.P1_S3) {
} this.file.setExpectedSize(knownFileSize);
} download(true);
} else {
checkFileSize(interactive);
}
} catch (MalformedURLException e) {
this.cancel();
}
}
private void retrieveUrl() { private void download(boolean interactive) {
changeStatus(STATUS_CHECKING); new Thread(new FileDownloader(interactive)).start();
final Account account = message.getConversation().getAccount(); }
IqPacket request = mXmppConnectionService.getIqGenerator().requestP1S3Url(Jid.of(account.getJid().getDomain()), mUrl.getHost());
mXmppConnectionService.sendIqPacket(message.getConversation().getAccount(), request, (a, packet) -> {
if (packet.getType() == IqPacket.TYPE.RESULT) {
String download = packet.query().getAttribute("download");
if (download != null) {
try {
mUrl = new URL(download);
check();
return;
} catch (MalformedURLException e) {
//fallthrough
}
}
}
Log.d(Config.LOGTAG,"unable to retrieve actual download url");
retrieveFailed(null);
});
}
private void retrieveFailed(@Nullable Exception e) { private void checkFileSize(boolean interactive) {
changeStatus(STATUS_OFFER_CHECK_FILESIZE); new Thread(new FileSizeChecker(interactive)).start();
if (interactive) { }
if (e != null) {
showToastForException(e);
}
} else {
HttpDownloadConnection.this.acceptedAutomatically = false;
HttpDownloadConnection.this.mXmppConnectionService.getNotificationService().push(message);
}
cancel();
}
private void check() { @Override
long size; public void cancel() {
try { this.canceled = true;
size = retrieveFileSize(); mHttpConnectionManager.finishConnection(this);
} catch (Exception e) { message.setTransferable(null);
Log.d(Config.LOGTAG, "io exception in http file size checker: " + e.getMessage()); if (message.isFileOrImage()) {
retrieveFailed(e); message.setDeleted(true);
return; }
} mHttpConnectionManager.updateConversationUi(true);
final Message.FileParams fileParams = message.getFileParams(); }
FileBackend.updateFileParams(message, fileParams.url, size);
message.setOob(true);
mXmppConnectionService.databaseBackend.updateMessage(message, true);
file.setExpectedSize(size);
message.resetFileParams();
if (mHttpConnectionManager.hasStoragePermission()
&& size <= mHttpConnectionManager.getAutoAcceptFileSize()
&& mXmppConnectionService.isDataSaverDisabled()) {
HttpDownloadConnection.this.acceptedAutomatically = true;
download(interactive);
} else {
changeStatus(STATUS_OFFER);
HttpDownloadConnection.this.acceptedAutomatically = false;
HttpDownloadConnection.this.mXmppConnectionService.getNotificationService().push(message);
}
}
private long retrieveFileSize() throws IOException { private void decryptFile() throws IOException {
try { final DownloadableFile outputFile = mXmppConnectionService.getFileBackend().getFile(message, true);
Log.d(Config.LOGTAG, "retrieve file size. interactive:" + String.valueOf(interactive));
changeStatus(STATUS_CHECKING);
HttpURLConnection connection;
final String hostname = mUrl.getHost();
final boolean onion = hostname != null && hostname.endsWith(".onion");
if (mUseTor || message.getConversation().getAccount().isOnion() || onion) {
connection = (HttpURLConnection) mUrl.openConnection(HttpConnectionManager.getProxy());
} else {
connection = (HttpURLConnection) mUrl.openConnection();
}
if (method == Method.P1_S3) {
connection.setRequestMethod("GET");
connection.addRequestProperty("Range","bytes=0-0");
} else {
connection.setRequestMethod("HEAD");
}
connection.setUseCaches(false);
Log.d(Config.LOGTAG, "url: " + connection.getURL().toString());
connection.setRequestProperty("User-Agent", mXmppConnectionService.getIqGenerator().getUserAgent());
if (connection instanceof HttpsURLConnection) {
mHttpConnectionManager.setupTrustManager((HttpsURLConnection) connection, interactive);
}
connection.setConnectTimeout(Config.SOCKET_TIMEOUT * 1000);
connection.setReadTimeout(Config.SOCKET_TIMEOUT * 1000);
connection.connect();
String contentLength;
if (method == Method.P1_S3) {
String contentRange = connection.getHeaderField("Content-Range");
String[] contentRangeParts = contentRange == null ? new String[0] : contentRange.split("/");
if (contentRangeParts.length != 2) {
contentLength = null;
} else {
contentLength = contentRangeParts[1];
}
} else {
contentLength = connection.getHeaderField("Content-Length");
}
connection.disconnect();
if (contentLength == null) {
throw new IOException("no content-length found in HEAD response");
}
return Long.parseLong(contentLength, 10);
} catch (IOException e) {
Log.d(Config.LOGTAG, "io exception during HEAD " + e.getMessage());
throw e;
} catch (NumberFormatException e) {
throw new IOException();
}
}
} if (outputFile.getParentFile().mkdirs()) {
Log.d(Config.LOGTAG, "created parent directories for " + outputFile.getAbsolutePath());
}
private class FileDownloader implements Runnable { if (!outputFile.createNewFile()) {
Log.w(Config.LOGTAG, "unable to create output file " + outputFile.getAbsolutePath());
}
private final boolean interactive; final InputStream is = new FileInputStream(this.file);
private OutputStream os; outputFile.setKey(this.file.getKey());
outputFile.setIv(this.file.getIv());
final OutputStream os = AbstractConnectionManager.createOutputStream(outputFile, false, true);
public FileDownloader(boolean interactive) { ByteStreams.copy(is, os);
this.interactive = interactive;
}
@Override FileBackend.close(is);
public void run() { FileBackend.close(os);
try {
changeStatus(STATUS_DOWNLOADING);
download();
decryptIfNeeded();
updateImageBounds();
finish();
} catch (SSLHandshakeException e) {
changeStatus(STATUS_OFFER);
} catch (Exception e) {
if (interactive) {
showToastForException(e);
} else {
HttpDownloadConnection.this.acceptedAutomatically = false;
HttpDownloadConnection.this.mXmppConnectionService.getNotificationService().push(message);
}
cancel();
}
}
private void download() throws Exception { if (!file.delete()) {
InputStream is = null; Log.w(Config.LOGTAG, "unable to delete temporary OMEMO encrypted file " + file.getAbsolutePath());
HttpURLConnection connection = null; }
PowerManager.WakeLock wakeLock = mHttpConnectionManager.createWakeLock("http_download_" + message.getUuid()); }
try {
wakeLock.acquire();
if (mUseTor || message.getConversation().getAccount().isOnion()) {
connection = (HttpURLConnection) mUrl.openConnection(HttpConnectionManager.getProxy());
} else {
connection = (HttpURLConnection) mUrl.openConnection();
}
if (connection instanceof HttpsURLConnection) {
mHttpConnectionManager.setupTrustManager((HttpsURLConnection) connection, interactive);
}
connection.setUseCaches(false);
connection.setRequestProperty("User-Agent", mXmppConnectionService.getIqGenerator().getUserAgent());
final long expected = file.getExpectedSize();
final boolean tryResume = file.exists() && file.getSize() > 0 && file.getSize() < expected;
long resumeSize = 0;
if (tryResume) { private void finish() {
resumeSize = file.getSize(); message.setTransferable(null);
Log.d(Config.LOGTAG, "http download trying resume after " + resumeSize + " of " + expected); mHttpConnectionManager.finishConnection(this);
connection.setRequestProperty("Range", "bytes=" + resumeSize + "-"); boolean notify = acceptedAutomatically && !message.isRead();
} if (message.getEncryption() == Message.ENCRYPTION_PGP) {
connection.setConnectTimeout(Config.SOCKET_TIMEOUT * 1000); notify = message.getConversation().getAccount().getPgpDecryptionService().decrypt(message, notify);
connection.setReadTimeout(Config.SOCKET_TIMEOUT * 1000); }
connection.connect(); mHttpConnectionManager.updateConversationUi(true);
is = new BufferedInputStream(connection.getInputStream()); final boolean notifyAfterScan = notify;
final String contentRange = connection.getHeaderField("Content-Range"); final DownloadableFile file = mXmppConnectionService.getFileBackend().getFile(message, true);
boolean serverResumed = tryResume && contentRange != null && contentRange.startsWith("bytes " + resumeSize + "-"); mXmppConnectionService.getFileBackend().updateMediaScanner(file, () -> {
long transmitted = 0; if (notifyAfterScan) {
if (tryResume && serverResumed) { mXmppConnectionService.getNotificationService().push(message);
Log.d(Config.LOGTAG, "server resumed"); }
transmitted = file.getSize(); });
updateProgress(Math.round(((double) transmitted / expected) * 100)); }
os = AbstractConnectionManager.createOutputStream(file, true, false);
if (os == null) {
throw new FileWriterException();
}
} else {
long reportedContentLengthOnGet;
try {
reportedContentLengthOnGet = Long.parseLong(connection.getHeaderField("Content-Length"));
} catch (NumberFormatException | NullPointerException e) {
reportedContentLengthOnGet = 0;
}
if (expected != reportedContentLengthOnGet) {
Log.d(Config.LOGTAG, "content-length reported on GET (" + reportedContentLengthOnGet + ") did not match Content-Length reported on HEAD (" + expected + ")");
}
file.getParentFile().mkdirs();
if (!file.exists() && !file.createNewFile()) {
throw new FileWriterException();
}
os = AbstractConnectionManager.createOutputStream(file, false, false);
}
int count;
byte[] buffer = new byte[4096];
while ((count = is.read(buffer)) != -1) {
transmitted += count;
try {
os.write(buffer, 0, count);
} catch (IOException e) {
throw new FileWriterException();
}
updateProgress(Math.round(((double) transmitted / expected) * 100));
if (canceled) {
throw new CancellationException();
}
}
try {
os.flush();
} catch (IOException e) {
throw new FileWriterException();
}
} catch (CancellationException | IOException e) {
Log.d(Config.LOGTAG, "http download failed " + e.getMessage());
throw e;
} finally {
FileBackend.close(os);
FileBackend.close(is);
if (connection != null) {
connection.disconnect();
}
WakeLockHelper.release(wakeLock);
}
}
private void updateImageBounds() { private void decryptIfNeeded() throws IOException {
final boolean privateMessage = message.isPrivateMessage(); if (file.getKey() != null && file.getIv() != null) {
message.setType(privateMessage ? Message.TYPE_PRIVATE_FILE : Message.TYPE_FILE); decryptFile();
final URL url; }
final String ref = mUrl.getRef(); }
if (method == Method.P1_S3) {
url = message.getFileParams().url;
} else if (ref != null && AesGcmURLStreamHandler.IV_KEY.matcher(ref).matches()) {
url = CryptoHelper.toAesGcmUrl(mUrl);
} else {
url = mUrl;
}
mXmppConnectionService.getFileBackend().updateFileParams(message, url);
mXmppConnectionService.updateMessage(message);
}
} private void changeStatus(int status) {
this.mStatus = status;
mHttpConnectionManager.updateConversationUi(true);
}
private void showToastForException(Exception e) {
if (e instanceof java.net.UnknownHostException) {
mXmppConnectionService.showErrorToastInUi(R.string.download_failed_server_not_found);
} else if (e instanceof java.net.ConnectException) {
mXmppConnectionService.showErrorToastInUi(R.string.download_failed_could_not_connect);
} else if (e instanceof FileWriterException) {
mXmppConnectionService.showErrorToastInUi(R.string.download_failed_could_not_write_file);
} else if (!(e instanceof CancellationException)) {
mXmppConnectionService.showErrorToastInUi(R.string.download_failed_file_not_found);
}
}
private void updateProgress(long i) {
this.mProgress = (int) i;
mHttpConnectionManager.updateConversationUi(false);
}
@Override
public int getStatus() {
return this.mStatus;
}
@Override
public long getFileSize() {
if (this.file != null) {
return this.file.getExpectedSize();
} else {
return 0;
}
}
@Override
public int getProgress() {
return this.mProgress;
}
public Message getMessage() {
return message;
}
private class FileSizeChecker implements Runnable {
private final boolean interactive;
FileSizeChecker(boolean interactive) {
this.interactive = interactive;
}
@Override
public void run() {
if (mUrl.getProtocol().equalsIgnoreCase(P1S3UrlStreamHandler.PROTOCOL_NAME)) {
retrieveUrl();
} else {
check();
}
}
private void retrieveUrl() {
changeStatus(STATUS_CHECKING);
final Account account = message.getConversation().getAccount();
IqPacket request = mXmppConnectionService.getIqGenerator().requestP1S3Url(Jid.of(account.getJid().getDomain()), mUrl.getHost());
mXmppConnectionService.sendIqPacket(message.getConversation().getAccount(), request, (a, packet) -> {
if (packet.getType() == IqPacket.TYPE.RESULT) {
String download = packet.query().getAttribute("download");
if (download != null) {
try {
mUrl = new URL(download);
check();
return;
} catch (MalformedURLException e) {
//fallthrough
}
}
}
Log.d(Config.LOGTAG, "unable to retrieve actual download url");
retrieveFailed(null);
});
}
private void retrieveFailed(@Nullable Exception e) {
changeStatus(STATUS_OFFER_CHECK_FILESIZE);
if (interactive) {
if (e != null) {
showToastForException(e);
}
} else {
HttpDownloadConnection.this.acceptedAutomatically = false;
HttpDownloadConnection.this.mXmppConnectionService.getNotificationService().push(message);
}
cancel();
}
private void check() {
long size;
try {
size = retrieveFileSize();
} catch (Exception e) {
Log.d(Config.LOGTAG, "io exception in http file size checker: " + e.getMessage());
retrieveFailed(e);
return;
}
final Message.FileParams fileParams = message.getFileParams();
FileBackend.updateFileParams(message, fileParams.url, size);
message.setOob(true);
mXmppConnectionService.databaseBackend.updateMessage(message, true);
file.setExpectedSize(size);
message.resetFileParams();
if (mHttpConnectionManager.hasStoragePermission()
&& size <= mHttpConnectionManager.getAutoAcceptFileSize()
&& mXmppConnectionService.isDataSaverDisabled()) {
HttpDownloadConnection.this.acceptedAutomatically = true;
download(interactive);
} else {
changeStatus(STATUS_OFFER);
HttpDownloadConnection.this.acceptedAutomatically = false;
HttpDownloadConnection.this.mXmppConnectionService.getNotificationService().push(message);
}
}
private long retrieveFileSize() throws IOException {
try {
Log.d(Config.LOGTAG, "retrieve file size. interactive:" + String.valueOf(interactive));
changeStatus(STATUS_CHECKING);
HttpURLConnection connection;
final String hostname = mUrl.getHost();
final boolean onion = hostname != null && hostname.endsWith(".onion");
if (mUseTor || message.getConversation().getAccount().isOnion() || onion) {
connection = (HttpURLConnection) mUrl.openConnection(HttpConnectionManager.getProxy());
} else {
connection = (HttpURLConnection) mUrl.openConnection();
}
if (method == Method.P1_S3) {
connection.setRequestMethod("GET");
connection.addRequestProperty("Range", "bytes=0-0");
} else {
connection.setRequestMethod("HEAD");
}
connection.setUseCaches(false);
Log.d(Config.LOGTAG, "url: " + connection.getURL().toString());
connection.setRequestProperty("User-Agent", mXmppConnectionService.getIqGenerator().getUserAgent());
if (connection instanceof HttpsURLConnection) {
mHttpConnectionManager.setupTrustManager((HttpsURLConnection) connection, interactive);
}
connection.setConnectTimeout(Config.SOCKET_TIMEOUT * 1000);
connection.setReadTimeout(Config.SOCKET_TIMEOUT * 1000);
connection.connect();
String contentLength;
if (method == Method.P1_S3) {
String contentRange = connection.getHeaderField("Content-Range");
String[] contentRangeParts = contentRange == null ? new String[0] : contentRange.split("/");
if (contentRangeParts.length != 2) {
contentLength = null;
} else {
contentLength = contentRangeParts[1];
}
} else {
contentLength = connection.getHeaderField("Content-Length");
}
connection.disconnect();
if (contentLength == null) {
throw new IOException("no content-length found in HEAD response");
}
return Long.parseLong(contentLength, 10);
} catch (IOException e) {
Log.d(Config.LOGTAG, "io exception during HEAD " + e.getMessage());
throw e;
} catch (NumberFormatException e) {
throw new IOException();
}
}
}
private class FileDownloader implements Runnable {
private final boolean interactive;
private OutputStream os;
public FileDownloader(boolean interactive) {
this.interactive = interactive;
}
@Override
public void run() {
try {
changeStatus(STATUS_DOWNLOADING);
download();
decryptIfNeeded();
updateImageBounds();
finish();
} catch (SSLHandshakeException e) {
changeStatus(STATUS_OFFER);
} catch (Exception e) {
if (interactive) {
showToastForException(e);
} else {
HttpDownloadConnection.this.acceptedAutomatically = false;
HttpDownloadConnection.this.mXmppConnectionService.getNotificationService().push(message);
}
cancel();
}
}
private void download() throws Exception {
InputStream is = null;
HttpURLConnection connection = null;
PowerManager.WakeLock wakeLock = mHttpConnectionManager.createWakeLock("http_download_" + message.getUuid());
try {
wakeLock.acquire();
if (mUseTor || message.getConversation().getAccount().isOnion()) {
connection = (HttpURLConnection) mUrl.openConnection(HttpConnectionManager.getProxy());
} else {
connection = (HttpURLConnection) mUrl.openConnection();
}
if (connection instanceof HttpsURLConnection) {
mHttpConnectionManager.setupTrustManager((HttpsURLConnection) connection, interactive);
}
connection.setUseCaches(false);
connection.setRequestProperty("User-Agent", mXmppConnectionService.getIqGenerator().getUserAgent());
final long expected = file.getExpectedSize();
final boolean tryResume = file.exists() && file.getSize() > 0 && file.getSize() < expected;
long resumeSize = 0;
if (tryResume) {
resumeSize = file.getSize();
Log.d(Config.LOGTAG, "http download trying resume after " + resumeSize + " of " + expected);
connection.setRequestProperty("Range", "bytes=" + resumeSize + "-");
}
connection.setConnectTimeout(Config.SOCKET_TIMEOUT * 1000);
connection.setReadTimeout(Config.SOCKET_TIMEOUT * 1000);
connection.connect();
is = new BufferedInputStream(connection.getInputStream());
final String contentRange = connection.getHeaderField("Content-Range");
boolean serverResumed = tryResume && contentRange != null && contentRange.startsWith("bytes " + resumeSize + "-");
long transmitted = 0;
if (tryResume && serverResumed) {
Log.d(Config.LOGTAG, "server resumed");
transmitted = file.getSize();
updateProgress(Math.round(((double) transmitted / expected) * 100));
os = AbstractConnectionManager.createOutputStream(file, true, false);
if (os == null) {
throw new FileWriterException();
}
} else {
long reportedContentLengthOnGet;
try {
reportedContentLengthOnGet = Long.parseLong(connection.getHeaderField("Content-Length"));
} catch (NumberFormatException | NullPointerException e) {
reportedContentLengthOnGet = 0;
}
if (expected != reportedContentLengthOnGet) {
Log.d(Config.LOGTAG, "content-length reported on GET (" + reportedContentLengthOnGet + ") did not match Content-Length reported on HEAD (" + expected + ")");
}
file.getParentFile().mkdirs();
if (!file.exists() && !file.createNewFile()) {
throw new FileWriterException();
}
os = AbstractConnectionManager.createOutputStream(file, false, false);
}
int count;
byte[] buffer = new byte[4096];
while ((count = is.read(buffer)) != -1) {
transmitted += count;
try {
os.write(buffer, 0, count);
} catch (IOException e) {
throw new FileWriterException();
}
updateProgress(Math.round(((double) transmitted / expected) * 100));
if (canceled) {
throw new CancellationException();
}
}
try {
os.flush();
} catch (IOException e) {
throw new FileWriterException();
}
} catch (CancellationException | IOException e) {
Log.d(Config.LOGTAG, message.getConversation().getAccount().getJid().asBareJid() + ": http download failed", e);
throw e;
} finally {
FileBackend.close(os);
FileBackend.close(is);
if (connection != null) {
connection.disconnect();
}
WakeLockHelper.release(wakeLock);
}
}
private void updateImageBounds() {
final boolean privateMessage = message.isPrivateMessage();
message.setType(privateMessage ? Message.TYPE_PRIVATE_FILE : Message.TYPE_FILE);
final URL url;
final String ref = mUrl.getRef();
if (method == Method.P1_S3) {
url = message.getFileParams().url;
} else if (ref != null && AesGcmURLStreamHandler.IV_KEY.matcher(ref).matches()) {
url = CryptoHelper.toAesGcmUrl(mUrl);
} else {
url = mUrl;
}
mXmppConnectionService.getFileBackend().updateFileParams(message, url);
mXmppConnectionService.updateMessage(message);
}
}
} }

View File

@ -19,6 +19,7 @@ import eu.siacs.conversations.R;
import eu.siacs.conversations.crypto.axolotl.AxolotlService; import eu.siacs.conversations.crypto.axolotl.AxolotlService;
import eu.siacs.conversations.crypto.axolotl.BrokenSessionException; import eu.siacs.conversations.crypto.axolotl.BrokenSessionException;
import eu.siacs.conversations.crypto.axolotl.NotEncryptedForThisDeviceException; import eu.siacs.conversations.crypto.axolotl.NotEncryptedForThisDeviceException;
import eu.siacs.conversations.crypto.axolotl.OutdatedSenderException;
import eu.siacs.conversations.crypto.axolotl.XmppAxolotlMessage; import eu.siacs.conversations.crypto.axolotl.XmppAxolotlMessage;
import eu.siacs.conversations.entities.Account; import eu.siacs.conversations.entities.Account;
import eu.siacs.conversations.entities.Bookmark; import eu.siacs.conversations.entities.Bookmark;
@ -140,6 +141,8 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
} }
} catch (NotEncryptedForThisDeviceException e) { } catch (NotEncryptedForThisDeviceException e) {
return new Message(conversation, "", Message.ENCRYPTION_AXOLOTL_NOT_FOR_THIS_DEVICE, status); return new Message(conversation, "", Message.ENCRYPTION_AXOLOTL_NOT_FOR_THIS_DEVICE, status);
} catch (OutdatedSenderException e) {
return new Message(conversation, "", Message.ENCRYPTION_AXOLOTL_FAILED, status);
} }
if (plaintextMessage != null) { if (plaintextMessage != null) {
Message finishedMessage = new Message(conversation, plaintextMessage.getPlaintext(), Message.ENCRYPTION_AXOLOTL, status); Message finishedMessage = new Message(conversation, plaintextMessage.getPlaintext(), Message.ENCRYPTION_AXOLOTL, status);

View File

@ -508,6 +508,8 @@ public class FileBackend {
return getFile(message, true); return getFile(message, true);
} }
public DownloadableFile getFileForPath(String path) { public DownloadableFile getFileForPath(String path) {
return getFileForPath(path, MimeUtils.guessMimeTypeFromExtension(MimeUtils.extractRelevantExtension(path))); return getFileForPath(path, MimeUtils.guessMimeTypeFromExtension(MimeUtils.extractRelevantExtension(path)));
} }

View File

@ -34,7 +34,6 @@ import android.provider.ContactsContract;
import android.security.KeyChain; import android.security.KeyChain;
import android.support.annotation.BoolRes; import android.support.annotation.BoolRes;
import android.support.annotation.IntegerRes; import android.support.annotation.IntegerRes;
import android.support.annotation.NonNull;
import android.support.v4.app.RemoteInput; import android.support.v4.app.RemoteInput;
import android.support.v4.content.ContextCompat; import android.support.v4.content.ContextCompat;
import android.text.TextUtils; import android.text.TextUtils;
@ -1515,7 +1514,7 @@ public class XmppConnectionService extends Service {
if (delay) { if (delay) {
mMessageGenerator.addDelay(packet, message.getTimeSent()); mMessageGenerator.addDelay(packet, message.getTimeSent());
} }
if (conversation.setOutgoingChatState(Config.DEFAULT_CHATSTATE)) { if (conversation.setOutgoingChatState(Config.DEFAULT_CHAT_STATE)) {
if (this.sendChatStates()) { if (this.sendChatStates()) {
packet.addChild(ChatState.toElement(conversation.getOutgoingChatState())); packet.addChild(ChatState.toElement(conversation.getOutgoingChatState()));
} }
@ -1855,6 +1854,9 @@ public class XmppConnectionService extends Service {
for (Conversation conversation : getConversations()) { for (Conversation conversation : getConversations()) {
deleted |= conversation.markAsDeleted(uuids); deleted |= conversation.markAsDeleted(uuids);
} }
for(final String uuid : uuids) {
evictPreview(uuid);
}
if (deleted) { if (deleted) {
updateConversationUi(); updateConversationUi();
} }
@ -2514,7 +2516,7 @@ public class XmppConnectionService extends Service {
if (conversation.getMode() == Conversation.MODE_MULTI) { if (conversation.getMode() == Conversation.MODE_MULTI) {
conversation.getMucOptions().resetChatState(); conversation.getMucOptions().resetChatState();
} else { } else {
conversation.setIncomingChatState(Config.DEFAULT_CHATSTATE); conversation.setIncomingChatState(Config.DEFAULT_CHAT_STATE);
} }
} }
for (Account account : getAccounts()) { for (Account account : getAccounts()) {
@ -4576,6 +4578,12 @@ public class XmppConnectionService extends Service {
sendIqPacket(account, set, null); sendIqPacket(account, set, null);
} }
public void evictPreview(String uuid) {
if (mBitmapCache.remove(uuid) != null) {
Log.d(Config.LOGTAG,"deleted cached preview");
}
}
public interface OnMamPreferencesFetched { public interface OnMamPreferencesFetched {
void onPreferencesFetched(Element prefs); void onPreferencesFetched(Element prefs);

View File

@ -1653,6 +1653,7 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke
builder.setPositiveButton(R.string.confirm, (dialog, which) -> { builder.setPositiveButton(R.string.confirm, (dialog, which) -> {
if (activity.xmppConnectionService.getFileBackend().deleteFile(message)) { if (activity.xmppConnectionService.getFileBackend().deleteFile(message)) {
message.setDeleted(true); message.setDeleted(true);
activity.xmppConnectionService.evictPreview(message.getUuid());
activity.xmppConnectionService.updateMessage(message, false); activity.xmppConnectionService.updateMessage(message, false);
activity.onConversationsListItemUpdated(); activity.onConversationsListItemUpdated();
refresh(); refresh();
@ -1721,7 +1722,7 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke
} }
public void privateMessageWith(final Jid counterpart) { public void privateMessageWith(final Jid counterpart) {
if (conversation.setOutgoingChatState(Config.DEFAULT_CHATSTATE)) { if (conversation.setOutgoingChatState(Config.DEFAULT_CHAT_STATE)) {
activity.xmppConnectionService.sendChatState(conversation); activity.xmppConnectionService.sendChatState(conversation);
} }
this.binding.textinput.setText(""); this.binding.textinput.setText("");
@ -1859,7 +1860,7 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke
} }
private void updateChatState(final Conversation conversation, final String msg) { private void updateChatState(final Conversation conversation, final String msg) {
ChatState state = msg.length() == 0 ? Config.DEFAULT_CHATSTATE : ChatState.PAUSED; ChatState state = msg.length() == 0 ? Config.DEFAULT_CHAT_STATE : ChatState.PAUSED;
Account.State status = conversation.getAccount().getStatus(); Account.State status = conversation.getAccount().getStatus();
if (status == Account.State.ONLINE && conversation.setOutgoingChatState(state)) { if (status == Account.State.ONLINE && conversation.setOutgoingChatState(state)) {
activity.xmppConnectionService.sendChatState(conversation); activity.xmppConnectionService.sendChatState(conversation);
@ -2619,7 +2620,7 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke
return; return;
} }
Account.State status = conversation.getAccount().getStatus(); Account.State status = conversation.getAccount().getStatus();
if (status == Account.State.ONLINE && conversation.setOutgoingChatState(Config.DEFAULT_CHATSTATE)) { if (status == Account.State.ONLINE && conversation.setOutgoingChatState(Config.DEFAULT_CHAT_STATE)) {
service.sendChatState(conversation); service.sendChatState(conversation);
} }
if (storeNextMessage()) { if (storeNextMessage()) {

View File

@ -88,7 +88,7 @@ public class UriHandlerActivity extends AppCompatActivity {
final List<Jid> accounts = DatabaseBackend.getInstance(this).getAccountJids(true); final List<Jid> accounts = DatabaseBackend.getInstance(this).getAccountJids(true);
if (SignupUtils.isSupportTokenRegistry() && xmppUri.isValidJid()) { if (SignupUtils.isSupportTokenRegistry() && xmppUri.isValidJid()) {
final String preauth = xmppUri.getParamater("preauth"); final String preauth = xmppUri.getParameter("preauth");
final Jid jid = xmppUri.getJid(); final Jid jid = xmppUri.getJid();
if (xmppUri.isAction(XmppUri.ACTION_REGISTER)) { if (xmppUri.isAction(XmppUri.ACTION_REGISTER)) {
if (jid.getEscapedLocal() != null && accounts.contains(jid.asBareJid())) { if (jid.getEscapedLocal() != null && accounts.contains(jid.asBareJid())) {
@ -99,7 +99,7 @@ public class UriHandlerActivity extends AppCompatActivity {
startActivity(intent); startActivity(intent);
return; return;
} }
if (xmppUri.isAction(XmppUri.ACTION_ROSTER) && "y".equals(xmppUri.getParamater("ibr"))) { if (xmppUri.isAction(XmppUri.ACTION_ROSTER) && "y".equals(xmppUri.getParameter("ibr"))) {
intent = SignupUtils.getTokenRegistrationIntent(this, Jid.ofDomain(jid.getDomain()), preauth); intent = SignupUtils.getTokenRegistrationIntent(this, Jid.ofDomain(jid.getDomain()), preauth);
intent.putExtra(StartConversationActivity.EXTRA_INVITE_URI, xmppUri.toString()); intent.putExtra(StartConversationActivity.EXTRA_INVITE_URI, xmppUri.toString());
startActivity(intent); startActivity(intent);

View File

@ -31,8 +31,10 @@ package eu.siacs.conversations.utils;
import android.content.Context; import android.content.Context;
import android.content.SharedPreferences; import android.content.SharedPreferences;
import android.content.res.Configuration;
import android.content.res.Resources; import android.content.res.Resources;
import android.content.res.TypedArray; import android.content.res.TypedArray;
import android.os.Build;
import android.preference.PreferenceManager; import android.preference.PreferenceManager;
import android.support.annotation.StyleRes; import android.support.annotation.StyleRes;
import android.support.design.widget.Snackbar; import android.support.design.widget.Snackbar;
@ -45,10 +47,10 @@ import eu.siacs.conversations.ui.SettingsActivity;
public class ThemeHelper { public class ThemeHelper {
public static int find(Context context) { public static int find(final Context context) {
final SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context); final SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context);
final Resources resources = context.getResources(); final Resources resources = context.getResources();
final boolean dark = sharedPreferences.getString(SettingsActivity.THEME, resources.getString(R.string.theme)).equals("dark"); final boolean dark = isDark(sharedPreferences, resources);
final String fontSize = sharedPreferences.getString("font_size", resources.getString(R.string.default_font_size)); final String fontSize = sharedPreferences.getString("font_size", resources.getString(R.string.default_font_size));
switch (fontSize) { switch (fontSize) {
case "medium": case "medium":
@ -63,7 +65,7 @@ public class ThemeHelper {
public static int findDialog(Context context) { public static int findDialog(Context context) {
final SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context); final SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context);
final Resources resources = context.getResources(); final Resources resources = context.getResources();
final boolean dark = sharedPreferences.getString(SettingsActivity.THEME, resources.getString(R.string.theme)).equals("dark"); final boolean dark = isDark(sharedPreferences, resources);
final String fontSize = sharedPreferences.getString("font_size", resources.getString(R.string.default_font_size)); final String fontSize = sharedPreferences.getString("font_size", resources.getString(R.string.default_font_size));
switch (fontSize) { switch (fontSize) {
case "medium": case "medium":
@ -75,6 +77,15 @@ public class ThemeHelper {
} }
} }
private static boolean isDark(final SharedPreferences sharedPreferences, final Resources resources) {
final String setting = sharedPreferences.getString(SettingsActivity.THEME, resources.getString(R.string.theme));
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && "automatic".equals(setting)) {
return (resources.getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES;
} else {
return "dark".equals(setting);
}
}
public static boolean isDark(@StyleRes int id) { public static boolean isDark(@StyleRes int id) {
switch (id) { switch (id) {
case R.style.ConversationsTheme_Dark: case R.style.ConversationsTheme_Dark:

View File

@ -18,241 +18,240 @@ import rocks.xmpp.addr.Jid;
public class XmppUri { public class XmppUri {
protected Uri uri; public static final String ACTION_JOIN = "join";
protected String jid; public static final String ACTION_MESSAGE = "message";
private List<Fingerprint> fingerprints = new ArrayList<>(); public static final String ACTION_REGISTER = "register";
private Map<String,String> parameters = Collections.emptyMap(); public static final String ACTION_ROSTER = "roster";
private boolean safeSource = true; private static final String OMEMO_URI_PARAM = "omemo-sid-";
protected Uri uri;
protected String jid;
private List<Fingerprint> fingerprints = new ArrayList<>();
private Map<String, String> parameters = Collections.emptyMap();
private boolean safeSource = true;
private static final String OMEMO_URI_PARAM = "omemo-sid-"; public XmppUri(String uri) {
try {
parse(Uri.parse(uri));
} catch (IllegalArgumentException e) {
try {
jid = Jid.of(uri).asBareJid().toString();
} catch (IllegalArgumentException e2) {
jid = null;
}
}
}
public static final String ACTION_JOIN = "join"; public XmppUri(Uri uri) {
public static final String ACTION_MESSAGE = "message"; parse(uri);
public static final String ACTION_REGISTER = "register"; }
public static final String ACTION_ROSTER = "roster";
public XmppUri(String uri) { public XmppUri(Uri uri, boolean safeSource) {
try { this.safeSource = safeSource;
parse(Uri.parse(uri)); parse(uri);
} catch (IllegalArgumentException e) { }
try {
jid = Jid.of(uri).asBareJid().toString();
} catch (IllegalArgumentException e2) {
jid = null;
}
}
}
public XmppUri(Uri uri) { private static Map<String, String> parseParameters(final String query, final char seperator) {
parse(uri); final ImmutableMap.Builder<String, String> builder = new ImmutableMap.Builder<>();
} final String[] pairs = query == null ? new String[0] : query.split(String.valueOf(seperator));
for (String pair : pairs) {
final String[] parts = pair.split("=", 2);
if (parts.length == 0) {
continue;
}
final String key = parts[0].toLowerCase(Locale.US);
final String value;
if (parts.length == 2) {
String decoded;
try {
decoded = URLDecoder.decode(parts[1], "UTF-8");
} catch (UnsupportedEncodingException e) {
decoded = "";
}
value = decoded;
} else {
value = "";
}
builder.put(key, value);
}
return builder.build();
}
public XmppUri(Uri uri, boolean safeSource) { private static List<Fingerprint> parseFingerprints(Map<String, String> parameters) {
this.safeSource = safeSource; ImmutableList.Builder<Fingerprint> builder = new ImmutableList.Builder<>();
parse(uri); for (Map.Entry<String, String> parameter : parameters.entrySet()) {
} final String key = parameter.getKey();
final String value = parameter.getValue().toLowerCase(Locale.US);
if (key.startsWith(OMEMO_URI_PARAM)) {
try {
final int id = Integer.parseInt(key.substring(OMEMO_URI_PARAM.length()));
builder.add(new Fingerprint(FingerprintType.OMEMO, value, id));
} catch (Exception e) {
//ignoring invalid device id
}
} else if ("omemo".equals(key)) {
builder.add(new Fingerprint(FingerprintType.OMEMO, value, 0));
}
}
return builder.build();
}
public boolean isSafeSource() { public static String getFingerprintUri(final String base, final List<XmppUri.Fingerprint> fingerprints, char separator) {
return safeSource; final StringBuilder builder = new StringBuilder(base);
} builder.append('?');
for (int i = 0; i < fingerprints.size(); ++i) {
XmppUri.FingerprintType type = fingerprints.get(i).type;
if (type == XmppUri.FingerprintType.OMEMO) {
builder.append(XmppUri.OMEMO_URI_PARAM);
builder.append(fingerprints.get(i).deviceId);
}
builder.append('=');
builder.append(fingerprints.get(i).fingerprint);
if (i != fingerprints.size() - 1) {
builder.append(separator);
}
}
return builder.toString();
}
protected void parse(final Uri uri) { private static String lameUrlDecode(String url) {
if (uri == null) { return url.replace("%23", "#").replace("%25", "%");
return; }
}
this.uri = uri;
String scheme = uri.getScheme();
String host = uri.getHost();
List<String> segments = uri.getPathSegments();
if ("https".equalsIgnoreCase(scheme) && "conversations.im".equalsIgnoreCase(host)) {
if (segments.size() >= 2 && segments.get(1).contains("@")) {
// sample : https://conversations.im/i/foo@bar.com
try {
jid = Jid.of(lameUrlDecode(segments.get(1))).toString();
} catch (Exception e) {
jid = null;
}
} else if (segments.size() >= 3) {
// sample : https://conversations.im/i/foo/bar.com
jid = segments.get(1) + "@" + segments.get(2);
}
if (segments.size() > 1 && "j".equalsIgnoreCase(segments.get(0))) {
this.parameters = ImmutableMap.of(ACTION_JOIN, "");
}
final Map<String,String> parameters = parseParameters(uri.getQuery(), '&');
this.fingerprints = parseFingerprints(parameters);
} else if ("xmpp".equalsIgnoreCase(scheme)) {
// sample: xmpp:foo@bar.com
this.parameters = parseParameters(uri.getQuery(), ';');
if (uri.getAuthority() != null) {
jid = uri.getAuthority();
} else {
final String[] parts = uri.getSchemeSpecificPart().split("\\?");
if (parts.length > 0) {
jid = parts[0];
} else {
return;
}
}
this.fingerprints = parseFingerprints(parameters);
} else if ("imto".equalsIgnoreCase(scheme)) {
// sample: imto://xmpp/foo@bar.com
try {
jid = URLDecoder.decode(uri.getEncodedPath(), "UTF-8").split("/")[1].trim();
} catch (final UnsupportedEncodingException ignored) {
jid = null;
}
} else {
try {
jid = Jid.of(uri.toString()).asBareJid().toString();
} catch (final IllegalArgumentException ignored) {
jid = null;
}
}
}
public static String lameUrlEncode(String url) {
return url.replace("%", "%25").replace("#", "%23");
}
private static Map<String,String> parseParameters(final String query, final char seperator) { public boolean isSafeSource() {
final ImmutableMap.Builder<String,String> builder = new ImmutableMap.Builder<>(); return safeSource;
final String[] pairs = query == null ? new String[0] : query.split(String.valueOf(seperator)); }
for (String pair : pairs) {
final String[] parts = pair.split("=", 2);
if (parts.length == 0) {
continue;
}
final String key = parts[0].toLowerCase(Locale.US);
final String value;
if (parts.length == 2) {
String decoded;
try {
decoded = URLDecoder.decode(parts[1],"UTF-8");
} catch (UnsupportedEncodingException e) {
decoded = "";
}
value = decoded;
} else {
value = "";
}
builder.put(key, value);
}
return builder.build();
}
@Override protected void parse(final Uri uri) {
@NonNull if (uri == null) {
public String toString() { return;
if (uri != null) { }
return uri.toString(); this.uri = uri;
} String scheme = uri.getScheme();
return ""; String host = uri.getHost();
} List<String> segments = uri.getPathSegments();
if ("https".equalsIgnoreCase(scheme) && "conversations.im".equalsIgnoreCase(host)) {
if (segments.size() >= 2 && segments.get(1).contains("@")) {
// sample : https://conversations.im/i/foo@bar.com
try {
jid = Jid.of(lameUrlDecode(segments.get(1))).toString();
} catch (Exception e) {
jid = null;
}
} else if (segments.size() >= 3) {
// sample : https://conversations.im/i/foo/bar.com
jid = segments.get(1) + "@" + segments.get(2);
}
if (segments.size() > 1 && "j".equalsIgnoreCase(segments.get(0))) {
this.parameters = ImmutableMap.of(ACTION_JOIN, "");
}
final Map<String, String> parameters = parseParameters(uri.getQuery(), '&');
this.fingerprints = parseFingerprints(parameters);
} else if ("xmpp".equalsIgnoreCase(scheme)) {
// sample: xmpp:foo@bar.com
this.parameters = parseParameters(uri.getQuery(), ';');
if (uri.getAuthority() != null) {
jid = uri.getAuthority();
} else {
final String[] parts = uri.getSchemeSpecificPart().split("\\?");
if (parts.length > 0) {
jid = parts[0];
} else {
return;
}
}
this.fingerprints = parseFingerprints(parameters);
} else if ("imto".equalsIgnoreCase(scheme)) {
// sample: imto://xmpp/foo@bar.com
try {
jid = URLDecoder.decode(uri.getEncodedPath(), "UTF-8").split("/")[1].trim();
} catch (final UnsupportedEncodingException ignored) {
jid = null;
}
} else {
try {
jid = Jid.of(uri.toString()).asBareJid().toString();
} catch (final IllegalArgumentException ignored) {
jid = null;
}
}
}
private static List<Fingerprint> parseFingerprints(Map<String,String> parameters) { @Override
ImmutableList.Builder<Fingerprint> builder = new ImmutableList.Builder<>(); @NonNull
for (Map.Entry<String, String> parameter : parameters.entrySet()) { public String toString() {
final String key = parameter.getKey(); if (uri != null) {
final String value = parameter.getValue().toLowerCase(Locale.US); return uri.toString();
if (key.startsWith(OMEMO_URI_PARAM)) { }
try { return "";
final int id = Integer.parseInt(key.substring(OMEMO_URI_PARAM.length())); }
builder.add(new Fingerprint(FingerprintType.OMEMO, value, id));
} catch (Exception e) {
//ignoring invalid device id
}
}
}
return builder.build();
}
public boolean isAction(final String action) { public boolean isAction(final String action) {
return parameters.containsKey(action); return parameters.containsKey(action);
} }
public Jid getJid() { public Jid getJid() {
try { try {
return this.jid == null ? null : Jid.of(this.jid); return this.jid == null ? null : Jid.of(this.jid);
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
return null; return null;
} }
} }
public boolean isValidJid() { public boolean isValidJid() {
if (jid == null) { if (jid == null) {
return false; return false;
} }
try { try {
Jid.of(jid); Jid.of(jid);
return true; return true;
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
return false; return false;
} }
} }
public String getBody() { public String getBody() {
return parameters.get("body"); return parameters.get("body");
} }
public String getName() { public String getName() {
return parameters.get("name"); return parameters.get("name");
} }
public String getParamater(String key) { public String getParameter(String key) {
return this.parameters.get(key); return this.parameters.get(key);
} }
public List<Fingerprint> getFingerprints() { public List<Fingerprint> getFingerprints() {
return this.fingerprints; return this.fingerprints;
} }
public boolean hasFingerprints() { public boolean hasFingerprints() {
return fingerprints.size() > 0; return fingerprints.size() > 0;
} }
public enum FingerprintType { public enum FingerprintType {
OMEMO OMEMO
} }
public static String getFingerprintUri(String base, List<XmppUri.Fingerprint> fingerprints, char separator) { public static class Fingerprint {
StringBuilder builder = new StringBuilder(base); public final FingerprintType type;
builder.append('?'); public final String fingerprint;
for (int i = 0; i < fingerprints.size(); ++i) { final int deviceId;
XmppUri.FingerprintType type = fingerprints.get(i).type;
if (type == XmppUri.FingerprintType.OMEMO) {
builder.append(XmppUri.OMEMO_URI_PARAM);
builder.append(fingerprints.get(i).deviceId);
}
builder.append('=');
builder.append(fingerprints.get(i).fingerprint);
if (i != fingerprints.size() - 1) {
builder.append(separator);
}
}
return builder.toString();
}
public static class Fingerprint { public Fingerprint(FingerprintType type, String fingerprint, int deviceId) {
public final FingerprintType type; this.type = type;
public final String fingerprint; this.fingerprint = fingerprint;
final int deviceId; this.deviceId = deviceId;
}
public Fingerprint(FingerprintType type, String fingerprint, int deviceId) { @NonNull
this.type = type; @Override
this.fingerprint = fingerprint; public String toString() {
this.deviceId = deviceId; return type.toString() + ": " + fingerprint + (deviceId != 0 ? " " + deviceId : "");
} }
}
@NonNull
@Override
public String toString() {
return type.toString() + ": " + fingerprint + (deviceId != 0 ? " " + deviceId : "");
}
}
private static String lameUrlDecode(String url) {
return url.replace("%23", "#").replace("%25", "%");
}
public static String lameUrlEncode(String url) {
return url.replace("%", "%25").replace("#", "%23");
}
} }

View File

@ -155,6 +155,7 @@
<string name="account_status_regis_conflict">A felhasználónév már használatban van</string> <string name="account_status_regis_conflict">A felhasználónév már használatban van</string>
<string name="account_status_regis_success">Regisztráció befejezve</string> <string name="account_status_regis_success">Regisztráció befejezve</string>
<string name="account_status_regis_not_sup">A kiszolgáló nem támogatja a regisztrációt</string> <string name="account_status_regis_not_sup">A kiszolgáló nem támogatja a regisztrációt</string>
<string name="account_status_regis_invalid_token">Érvénytelen regisztrációs token</string>
<string name="account_status_tls_error">A TLS-egyeztetés sikertelen</string> <string name="account_status_tls_error">A TLS-egyeztetés sikertelen</string>
<string name="account_status_policy_violation">Irányelv megsértése</string> <string name="account_status_policy_violation">Irányelv megsértése</string>
<string name="account_status_incompatible_server">Nem kompatibilis kiszolgáló</string> <string name="account_status_incompatible_server">Nem kompatibilis kiszolgáló</string>
@ -879,4 +880,8 @@
<string name="pref_channel_discovery">Csatornafelderítés módszere</string> <string name="pref_channel_discovery">Csatornafelderítés módszere</string>
<string name="backup">Biztonsági mentés</string> <string name="backup">Biztonsági mentés</string>
<string name="category_about">Névjegy</string> <string name="category_about">Névjegy</string>
<plurals name="view_users">
<item quantity="one">%1$d résztvevő megtekintése</item>
<item quantity="other">%1$d résztvevő megtekintése</item>
</plurals>
</resources> </resources>

View File

@ -155,6 +155,7 @@
<string name="account_status_regis_conflict">Nazwa jest już w użyciu</string> <string name="account_status_regis_conflict">Nazwa jest już w użyciu</string>
<string name="account_status_regis_success">Zarejestrowano pomyślnie</string> <string name="account_status_regis_success">Zarejestrowano pomyślnie</string>
<string name="account_status_regis_not_sup">Serwer nie umożliwia rejestracji</string> <string name="account_status_regis_not_sup">Serwer nie umożliwia rejestracji</string>
<string name="account_status_regis_invalid_token">Nieprawidłowy żeton rejestracji</string>
<string name="account_status_tls_error">Nie powiodła się negocjacja TLS</string> <string name="account_status_tls_error">Nie powiodła się negocjacja TLS</string>
<string name="account_status_policy_violation">Naruszenie zasad</string> <string name="account_status_policy_violation">Naruszenie zasad</string>
<string name="account_status_incompatible_server">Serwer niekompatybilny</string> <string name="account_status_incompatible_server">Serwer niekompatybilny</string>

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="theme">automatic</string>
<string-array name="themes" tools:ignore="InconsistentArrays">
<item>@string/pref_theme_automatic</item>
<item>@string/pref_theme_light</item>
<item>@string/pref_theme_dark</item>
</string-array>
<string-array name="themes_values" tools:ignore="InconsistentArrays">
<item>automatic</item>
<item>light</item>
<item>dark</item>
</string-array>
</resources>

View File

@ -31,7 +31,7 @@
<resources> <resources>
<string name="pref_about_message" translatable="false"> <string name="pref_about_message" translatable="false">
Conversations • the very last word in instant messaging. Conversations • the very last word in instant messaging.
\n\nCopyright © 2014-2019 Daniel Gultsch \n\nCopyright © 2014-2020 Daniel Gultsch
\n\nThis program is free software: you can redistribute it and/or modify \n\nThis program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or the Free Software Foundation, either version 3 of the License, or

View File

@ -1,14 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string-array name="themes">
<item>@string/pref_theme_light</item>
<item>@string/pref_theme_dark</item>
</string-array>
<string-array name="themes_values">
<item>light</item>
<item>dark</item>
</string-array>
<string-array name="filesizes"> <string-array name="filesizes">
<item>@string/never</item> <item>@string/never</item>
<item>256 KiB</item> <item>256 KiB</item>

View File

@ -15,7 +15,6 @@
<integer name="grace_period">144</integer> <integer name="grace_period">144</integer>
<integer name="auto_accept_filesize">524288</integer> <integer name="auto_accept_filesize">524288</integer>
<string name="picture_compression">auto</string> <string name="picture_compression">auto</string>
<string name="theme">light</string>
<string name="quick_action">recent</string> <string name="quick_action">recent</string>
<bool name="show_dynamic_tags">true</bool> <bool name="show_dynamic_tags">true</bool>
<bool name="btbv">true</bool> <bool name="btbv">true</bool>

View File

@ -559,6 +559,7 @@
<string name="pref_privacy">Privacy</string> <string name="pref_privacy">Privacy</string>
<string name="pref_theme_options">Theme</string> <string name="pref_theme_options">Theme</string>
<string name="pref_theme_options_summary">Select the color palette</string> <string name="pref_theme_options_summary">Select the color palette</string>
<string name="pref_theme_automatic">Automatic</string>
<string name="pref_theme_light">Light theme</string> <string name="pref_theme_light">Light theme</string>
<string name="pref_theme_dark">Dark theme</string> <string name="pref_theme_dark">Dark theme</string>
<string name="unable_to_connect_to_keychain">Unable to connect to OpenKeychain</string> <string name="unable_to_connect_to_keychain">Unable to connect to OpenKeychain</string>

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="theme">light</string>
<string-array name="themes">
<item>@string/pref_theme_light</item>
<item>@string/pref_theme_dark</item>
</string-array>
<string-array name="themes_values">
<item>light</item>
<item>dark</item>
</string-array>
</resources>